From 895f14de5f982263ae6ee04fb24bfb9f736ca5ca Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 16 Sep 2025 21:00:21 +0000 Subject: [PATCH 001/177] Add clock-bound crate for initial squat/publish --- Cargo.lock | 4 + Cargo.toml | 4 +- clock-bound/Cargo.toml | 15 +++ clock-bound/LICENSE.Apache-2.0 | 202 +++++++++++++++++++++++++++++++++ clock-bound/LICENSE.MIT | 9 ++ clock-bound/src/lib.rs | 1 + 6 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 clock-bound/Cargo.toml create mode 100644 clock-bound/LICENSE.Apache-2.0 create mode 100644 clock-bound/LICENSE.MIT create mode 100644 clock-bound/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index d574bbc..0baaa9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,6 +239,10 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +[[package]] +name = "clock-bound" +version = "0.1.0-alpha" + [[package]] name = "clock-bound-client" version = "2.0.3" diff --git a/Cargo.toml b/Cargo.toml index bdb0c51..79b3f6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "clock-bound-ffi", "clock-bound-client", "clock-bound-d", + "clock-bound", "examples/client/rust", "test/clock-bound-vmclock-client-test", "test/vmclock-updater", @@ -23,6 +24,7 @@ authors = [ "Wenhao Piao ", "Daniel Franke ", "Thoth Gunter ", + "Shamik Chakraborty ", ] categories = [ "date-and-time" ] edition = "2021" @@ -30,4 +32,4 @@ exclude = [] keywords = ["aws", "ntp", "ec2", "time"] publish = true repository = "https://github.com/aws/clock-bound" -version = "2.0.3" +version = "2.0.3" \ No newline at end of file diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml new file mode 100644 index 0000000..fe51aa9 --- /dev/null +++ b/clock-bound/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "clock-bound" +description = "A crate to provide error bounded timestamp intervals." +license = "MIT OR Apache-2.0" + +authors.workspace = true +categories.workspace = true +edition = "2024" +exclude.workspace = true +keywords.workspace = true +publish.workspace = true +repository.workspace = true +version = "0.1.0-alpha" + +[dependencies] diff --git a/clock-bound/LICENSE.Apache-2.0 b/clock-bound/LICENSE.Apache-2.0 new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/clock-bound/LICENSE.Apache-2.0 @@ -0,0 +1,202 @@ + + 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 + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/clock-bound/LICENSE.MIT b/clock-bound/LICENSE.MIT new file mode 100644 index 0000000..9a3be15 --- /dev/null +++ b/clock-bound/LICENSE.MIT @@ -0,0 +1,9 @@ +MIT License + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +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. \ No newline at end of file diff --git a/clock-bound/src/lib.rs b/clock-bound/src/lib.rs new file mode 100644 index 0000000..53abdfb --- /dev/null +++ b/clock-bound/src/lib.rs @@ -0,0 +1 @@ +//! ClockBound From 126eacad26d038422c665f1fc3eaa1a37105910c Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 19 Sep 2025 18:40:04 +0000 Subject: [PATCH 002/177] temporarily disable publishing while working on clockbound 3.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 79b3f6c..42c1e6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,6 @@ categories = [ "date-and-time" ] edition = "2021" exclude = [] keywords = ["aws", "ntp", "ec2", "time"] -publish = true +publish = false repository = "https://github.com/aws/clock-bound" version = "2.0.3" \ No newline at end of file From f76f6a516666522f349899e8f26d7dad989341bf Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty <94866491+shamchak808@users.noreply.github.com> Date: Sat, 20 Sep 2025 23:49:11 -0400 Subject: [PATCH 003/177] Create rust.yml --- .github/workflows/rust.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/rust.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..9fd45e0 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,22 @@ +name: Rust + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose From 4f5eb5cf3060b9bbc14b671849b5d5be5e566e7d Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 22 Sep 2025 15:29:32 +0000 Subject: [PATCH 004/177] Refactor clockbound into a singular crate This takes the 3.0 bump to simplify the project and move all of the non-ffi code into the 'clockbound' crate. The primary motivation for this is that clock-bound is a single entity of code, and the client + daemon should be build and packaged together when possible. This also removes a series of papercuts when deploying changes across multiple of the old crates. --- Cargo.lock | 1262 +++-------------- Cargo.toml | 14 +- clock-bound-client/CHANGELOG.md | 68 - clock-bound-client/Cargo.toml | 25 - clock-bound-client/LICENSE | 202 --- clock-bound-client/NOTICE | 2 - clock-bound-client/README.md | 34 - clock-bound-d/CHANGELOG.md | 98 -- clock-bound-d/Cargo.toml | 45 - clock-bound-d/LICENSE | 339 ----- clock-bound-d/NOTICE | 2 - clock-bound-d/README.md | 329 ----- clock-bound-d/src/chrony_client.rs | 515 ------- clock-bound-d/src/clock_bound_runner.rs | 746 ---------- clock-bound-d/src/clock_snapshot_poller.rs | 21 - .../chronyd_snapshot_poller.rs | 404 ------ clock-bound-d/src/clock_state_fsm.rs | 509 ------- .../src/clock_state_fsm_no_disruption.rs | 331 ----- clock-bound-d/src/lib.rs | 249 ---- clock-bound-d/src/main.rs | 192 --- clock-bound-d/src/phc_utils.rs | 189 --- clock-bound-d/src/signal.rs | 201 --- clock-bound-ffi/Cargo.toml | 3 +- clock-bound-ffi/src/lib.rs | 55 +- clock-bound-shm/Cargo.toml | 25 - clock-bound-shm/LICENSE | 202 --- clock-bound-shm/NOTICE | 2 - clock-bound-shm/README.md | 30 - clock-bound-vmclock/Cargo.toml | 28 - clock-bound-vmclock/LICENSE | 202 --- clock-bound-vmclock/NOTICE | 2 - clock-bound-vmclock/README.md | 9 - clock-bound/Cargo.toml | 15 +- .../src/lib.rs => clock-bound/src/client.rs | 12 +- clock-bound/src/lib.rs | 7 + .../src/lib.rs => clock-bound/src/shm.rs | 10 +- .../src => clock-bound/src/shm}/common.rs | 2 +- .../src => clock-bound/src/shm}/reader.rs | 6 +- .../src => clock-bound/src/shm}/shm_header.rs | 2 +- .../src => clock-bound/src/shm}/writer.rs | 34 +- .../src/lib.rs => clock-bound/src/vmclock.rs | 10 +- .../src => clock-bound/src/vmclock}/shm.rs | 3 +- .../src/vmclock}/shm_reader.rs | 7 +- .../src/vmclock}/shm_writer.rs | 8 +- examples/client/rust/Cargo.toml | 2 +- examples/client/rust/src/main.rs | 4 +- .../Cargo.toml | 4 +- .../src/main.rs | 4 +- test/vmclock-updater/Cargo.toml | 2 +- test/vmclock-updater/src/main.rs | 4 +- 50 files changed, 324 insertions(+), 6147 deletions(-) delete mode 100644 clock-bound-client/CHANGELOG.md delete mode 100644 clock-bound-client/Cargo.toml delete mode 100644 clock-bound-client/LICENSE delete mode 100644 clock-bound-client/NOTICE delete mode 100644 clock-bound-client/README.md delete mode 100644 clock-bound-d/CHANGELOG.md delete mode 100644 clock-bound-d/Cargo.toml delete mode 100644 clock-bound-d/LICENSE delete mode 100644 clock-bound-d/NOTICE delete mode 100644 clock-bound-d/README.md delete mode 100644 clock-bound-d/src/chrony_client.rs delete mode 100644 clock-bound-d/src/clock_bound_runner.rs delete mode 100644 clock-bound-d/src/clock_snapshot_poller.rs delete mode 100644 clock-bound-d/src/clock_snapshot_poller/chronyd_snapshot_poller.rs delete mode 100644 clock-bound-d/src/clock_state_fsm.rs delete mode 100644 clock-bound-d/src/clock_state_fsm_no_disruption.rs delete mode 100644 clock-bound-d/src/lib.rs delete mode 100644 clock-bound-d/src/main.rs delete mode 100644 clock-bound-d/src/phc_utils.rs delete mode 100644 clock-bound-d/src/signal.rs delete mode 100644 clock-bound-shm/Cargo.toml delete mode 100644 clock-bound-shm/LICENSE delete mode 100644 clock-bound-shm/NOTICE delete mode 100644 clock-bound-shm/README.md delete mode 100644 clock-bound-vmclock/Cargo.toml delete mode 100644 clock-bound-vmclock/LICENSE delete mode 100644 clock-bound-vmclock/NOTICE delete mode 100644 clock-bound-vmclock/README.md rename clock-bound-client/src/lib.rs => clock-bound/src/client.rs (98%) rename clock-bound-shm/src/lib.rs => clock-bound/src/shm.rs (99%) rename {clock-bound-shm/src => clock-bound/src/shm}/common.rs (98%) rename {clock-bound-shm/src => clock-bound/src/shm}/reader.rs (99%) rename {clock-bound-shm/src => clock-bound/src/shm}/shm_header.rs (99%) rename {clock-bound-shm/src => clock-bound/src/shm}/writer.rs (96%) rename clock-bound-vmclock/src/lib.rs => clock-bound/src/vmclock.rs (97%) rename {clock-bound-vmclock/src => clock-bound/src/vmclock}/shm.rs (99%) rename {clock-bound-vmclock/src => clock-bound/src/vmclock}/shm_reader.rs (99%) rename {clock-bound-vmclock/src => clock-bound/src/vmclock}/shm_writer.rs (98%) diff --git a/Cargo.lock b/Cargo.lock index 0baaa9e..a09a5b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,111 +2,62 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - [[package]] name = "anstream" -version = "0.6.12" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b09b5178381e0874812a9b157f7fe84982617e48f71f4e3235482775e5b540" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.6" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" -version = "0.2.3" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.2" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "once_cell_polyfill", + "windows-sys 0.60.2", ] -[[package]] -name = "anyhow" -version = "1.0.97" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" - -[[package]] -name = "arrayvec" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" - [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" -[[package]] -name = "backtrace" -version = "0.3.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -119,91 +70,23 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" -[[package]] -name = "bon" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97493a391b4b18ee918675fb8663e53646fd09321c58b46afa04e8ce2499c869" -dependencies = [ - "bon-macros", - "rustversion", -] - -[[package]] -name = "bon-macros" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2af3eac944c12cdf4423eab70d310da0a8e5851a18ffb192c0a5e3f7ae1663" -dependencies = [ - "darling", - "ident_case", - "proc-macro2", - "quote", - "syn 2.0.58", -] - [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "bytes" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" - -[[package]] -name = "cc" -version = "1.0.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" -dependencies = [ - "libc", -] - [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "chrony-candm" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6795fd778edcee3737dcd3fcf1b65caa19a2cab3ef4afb7645e5e9b0cd72f7" -dependencies = [ - "arrayvec", - "bitflags 1.3.2", - "bytes", - "chrony-candm-derive", - "futures", - "hex", - "libc", - "num_enum", - "rand", - "siphasher", - "tokio", -] - -[[package]] -name = "chrony-candm-derive" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6cebed9b99e24249d179ca6b41be7198776e72206d8943fe3ee8ff0e301849d" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "clap" -version = "4.5.1" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c918d541ef2913577a0f9566e9ce27cb35b6df072075769e0b26cb5a554520da" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -211,9 +94,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.1" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3e7391dad68afb0c2ede1bf619f579a3dc9c2ec67f089baa397123a2f3d1eb" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", @@ -223,60 +106,31 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.0" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "307bc0538d5f0f83b8248db3087aa92fe504e4691294d0c96c0eabc33f47ba47" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.58", + "syn", ] [[package]] name = "clap_lex" -version = "0.7.0" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "clock-bound" -version = "0.1.0-alpha" - -[[package]] -name = "clock-bound-client" version = "2.0.3" dependencies = [ "byteorder", - "clock-bound-shm", - "clock-bound-vmclock", "errno", "libc", "nix", "tempfile", -] - -[[package]] -name = "clock-bound-d" -version = "2.0.3" -dependencies = [ - "anyhow", - "bon", - "byteorder", - "chrony-candm", - "clap", - "clock-bound-shm", - "clock-bound-vmclock", - "lazy_static", - "libc", - "mockall", - "mockall_double", - "nix", - "retry", - "rstest", - "serial_test", - "socket2", - "tempfile", "tracing", "tracing-subscriber", ] @@ -286,712 +140,186 @@ name = "clock-bound-ffi" version = "2.0.3" dependencies = [ "byteorder", - "clock-bound-shm", - "clock-bound-vmclock", - "errno", - "libc", - "nix", - "tempfile", -] - -[[package]] -name = "clock-bound-shm" -version = "2.0.3" -dependencies = [ - "byteorder", - "errno", - "libc", - "nix", - "tempfile", -] - -[[package]] -name = "clock-bound-vmclock" -version = "2.0.3" -dependencies = [ - "byteorder", - "clock-bound-shm", + "clock-bound", "errno", "libc", - "nix", - "tempfile", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "clock-bound-vmclock-client-example" -version = "2.0.3" -dependencies = [ - "byteorder", - "clock-bound-client", - "errno", - "nix", -] - -[[package]] -name = "clock-bound-vmclock-client-test" -version = "2.0.3" -dependencies = [ - "byteorder", - "clock-bound-client", - "errno", - "nix", -] - -[[package]] -name = "colorchoice" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" - -[[package]] -name = "darling" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.58", -] - -[[package]] -name = "darling_macro" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.58", -] - -[[package]] -name = "downcast" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" - -[[package]] -name = "equivalent" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" - -[[package]] -name = "errno" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "fragile" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" - -[[package]] -name = "futures" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" - -[[package]] -name = "futures-executor" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" - -[[package]] -name = "futures-macro" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.58", -] - -[[package]] -name = "futures-sink" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" - -[[package]] -name = "futures-task" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" - -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - -[[package]] -name = "futures-util" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.13.3+wasi-0.2.2", - "windows-targets 0.52.0", -] - -[[package]] -name = "gimli" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" - -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "indexmap" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "itoa" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "libc" -version = "0.2.170" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" - -[[package]] -name = "linux-raw-sys" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" - -[[package]] -name = "lock_api" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" - -[[package]] -name = "memchr" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" - -[[package]] -name = "memoffset" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" -dependencies = [ - "autocfg", -] - -[[package]] -name = "miniz_oxide" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" -dependencies = [ - "adler", -] - -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - -[[package]] -name = "mockall" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" -dependencies = [ - "cfg-if", - "downcast", - "fragile", - "mockall_derive", - "predicates", - "predicates-tree", -] - -[[package]] -name = "mockall_derive" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn 2.0.58", -] - -[[package]] -name = "mockall_double" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1ca96e5ac35256ae3e13536edd39b172b88f41615e1d7b653c8ad24524113e8" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn 2.0.58", -] - -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset", - "pin-utils", -] - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num_enum" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" -dependencies = [ - "num_enum_derive", -] - -[[package]] -name = "num_enum_derive" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "object" -version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + "nix", + "tempfile", +] [[package]] -name = "parking_lot" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +name = "clock-bound-vmclock-client-example" +version = "2.0.3" dependencies = [ - "lock_api", - "parking_lot_core", + "byteorder", + "clock-bound", + "errno", + "nix", ] [[package]] -name = "parking_lot_core" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +name = "clock-bound-vmclock-client-test" +version = "2.0.3" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.0", + "byteorder", + "clock-bound", + "errno", + "nix", ] [[package]] -name = "pin-project-lite" -version = "0.2.13" +name = "colorchoice" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "errno" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] [[package]] -name = "ppv-lite86" -version = "0.2.17" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "predicates" -version = "3.1.3" +name = "getrandom" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" dependencies = [ - "anstyle", - "predicates-core", + "cfg-if", + "libc", + "wasi", + "windows-targets 0.52.0", ] [[package]] -name = "predicates-core" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" - -[[package]] -name = "predicates-tree" -version = "1.0.12" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" -dependencies = [ - "predicates-core", - "termtree", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "proc-macro-crate" -version = "1.3.1" +name = "is_terminal_polyfill" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] -name = "proc-macro-crate" -version = "3.3.0" +name = "itoa" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" -dependencies = [ - "toml_edit 0.22.24", -] +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] -name = "proc-macro2" -version = "1.0.78" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" -dependencies = [ - "unicode-ident", -] +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "quote" -version = "1.0.35" +name = "libc" +version = "0.2.170" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" -dependencies = [ - "proc-macro2", -] +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" [[package]] -name = "rand" -version = "0.8.5" +name = "linux-raw-sys" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] +checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "log" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] -name = "rand_core" -version = "0.6.4" +name = "memchr" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.12", -] +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] -name = "redox_syscall" -version = "0.5.10" +name = "memoffset" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" dependencies = [ - "bitflags 2.4.2", + "autocfg", ] [[package]] -name = "regex" -version = "1.11.1" +name = "nix" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", ] [[package]] -name = "regex-automata" -version = "0.4.9" +name = "nu-ansi-term" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "windows-sys 0.52.0", ] [[package]] -name = "regex-syntax" -version = "0.8.5" +name = "once_cell" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] -name = "relative-path" -version = "1.9.3" +name = "once_cell_polyfill" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] -name = "retry" -version = "2.0.0" +name = "pin-project-lite" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9166d72162de3575f950507683fac47e30f6f2c3836b71b7fbc61aa517c9c5f4" -dependencies = [ - "rand", -] +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] -name = "rstest" -version = "0.22.0" +name = "pin-utils" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" -dependencies = [ - "futures", - "futures-timer", - "rstest_macros", - "rustc_version", -] +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "rstest_macros" -version = "0.22.0" +name = "proc-macro2" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate 3.3.0", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.58", "unicode-ident", ] [[package]] -name = "rustc-demangle" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" - -[[package]] -name = "rustc_version" -version = "0.4.1" +name = "quote" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ - "semver", + "proc-macro2", ] [[package]] @@ -1007,99 +335,52 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustversion" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - [[package]] name = "ryu" -version = "1.0.17" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] -name = "scc" -version = "2.3.3" +name = "serde" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea091f6cac2595aa38993f04f4ee692ed43757035c36e67c180b6828356385b1" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" dependencies = [ - "sdd", + "serde_core", ] [[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sdd" -version = "3.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "584e070911c7017da6cb2eb0788d09f43d789029b5877d3e5ecc8acf86ceee21" - -[[package]] -name = "semver" -version = "1.0.26" +name = "serde_core" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" - -[[package]] -name = "serde" -version = "1.0.197" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", - "syn 2.0.58", + "syn", ] [[package]] name = "serde_json" -version = "1.0.114" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", + "memchr", "ryu", "serde", -] - -[[package]] -name = "serial_test" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" -dependencies = [ - "futures", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.58", + "serde_core", ] [[package]] @@ -1111,36 +392,11 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "slab" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] - [[package]] name = "smallvec" -version = "1.13.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" - -[[package]] -name = "socket2" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "strsim" @@ -1150,20 +406,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.58" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -1178,87 +423,26 @@ checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" dependencies = [ "cfg-if", "fastrand", - "getrandom 0.3.1", + "getrandom", "once_cell", "rustix", "windows-sys 0.52.0", ] -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - [[package]] name = "thread_local" -version = "1.1.7" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", -] - -[[package]] -name = "tokio" -version = "1.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" -dependencies = [ - "backtrace", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.48.0", -] - -[[package]] -name = "tokio-macros" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.58", -] - -[[package]] -name = "toml_datetime" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "toml_datetime", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.22.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" -dependencies = [ - "indexmap", - "toml_datetime", - "winnow 0.7.3", ] [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1267,20 +451,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.58", + "syn", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", "valuable", @@ -1299,9 +483,9 @@ dependencies = [ [[package]] name = "tracing-serde" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ "serde", "tracing-core", @@ -1309,9 +493,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "nu-ansi-term", "serde", @@ -1326,21 +510,21 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "valuable" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vmclock-updater" @@ -1348,7 +532,7 @@ version = "2.0.3" dependencies = [ "byteorder", "clap", - "clock-bound-vmclock", + "clock-bound", "errno", "libc", "nix", @@ -1356,12 +540,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - [[package]] name = "wasi" version = "0.13.3+wasi-0.2.2" @@ -1372,35 +550,10 @@ dependencies = [ ] [[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-sys" -version = "0.48.0" +name = "windows-link" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-sys" @@ -1412,18 +565,12 @@ dependencies = [ ] [[package]] -name = "windows-targets" -version = "0.48.5" +name = "windows-sys" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-targets 0.53.3", ] [[package]] @@ -1442,10 +589,21 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" +name = "windows-targets" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] [[package]] name = "windows_aarch64_gnullvm" @@ -1454,10 +612,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" [[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" +name = "windows_aarch64_gnullvm" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" [[package]] name = "windows_aarch64_msvc" @@ -1466,10 +624,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" [[package]] -name = "windows_i686_gnu" -version = "0.48.5" +name = "windows_aarch64_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" [[package]] name = "windows_i686_gnu" @@ -1478,10 +636,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" [[package]] -name = "windows_i686_msvc" -version = "0.48.5" +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" [[package]] name = "windows_i686_msvc" @@ -1490,10 +654,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" [[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" +name = "windows_i686_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" [[package]] name = "windows_x86_64_gnu" @@ -1502,10 +666,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" +name = "windows_x86_64_gnu" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" [[package]] name = "windows_x86_64_gnullvm" @@ -1514,10 +678,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" [[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" +name = "windows_x86_64_gnullvm" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" [[package]] name = "windows_x86_64_msvc" @@ -1526,22 +690,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.3" +name = "windows_x86_64_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" -dependencies = [ - "memchr", -] +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "wit-bindgen-rt" diff --git a/Cargo.toml b/Cargo.toml index 79b3f6c..a30ec0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,12 @@ [workspace] members = [ - # NOTE: the order in which the workspaces are listed does matter. It has to - # follow the dependency tree, to ensure a crate that is depended upon is - # built and published first. - "clock-bound-shm", - "clock-bound-vmclock", - "clock-bound-ffi", - "clock-bound-client", - "clock-bound-d", "clock-bound", + "clock-bound-ffi", "examples/client/rust", "test/clock-bound-vmclock-client-test", "test/vmclock-updater", ] -resolver = "2" +resolver = "3" [workspace.package] authors = [ @@ -22,12 +15,11 @@ authors = [ "Tam Phan ", "Ryan Luu ", "Wenhao Piao ", - "Daniel Franke ", "Thoth Gunter ", "Shamik Chakraborty ", ] categories = [ "date-and-time" ] -edition = "2021" +edition = "2024" exclude = [] keywords = ["aws", "ntp", "ec2", "time"] publish = true diff --git a/clock-bound-client/CHANGELOG.md b/clock-bound-client/CHANGELOG.md deleted file mode 100644 index 7036965..0000000 --- a/clock-bound-client/CHANGELOG.md +++ /dev/null @@ -1,68 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [2.0.3] - 2025-08-13 - -## [2.0.2] - 2025-07-30 - -### Removed - -- In dependency 'clock-bound-vmclock', the Cargo.toml no longer specifies - logging level filter features for the 'tracing' crate. - -## [2.0.1] - 2025-05-26 - -## [2.0.0] - 2025-04-21 - -### Added - -- VMClock is utilized in the algorithm for determining the clock status. - -- Support for reading ClockBound shared memory format version 2. - -- New error enum value `ClockBoundErrorKind::SegmentVersionNotSupported`. - -### Changed - -- The default ClockBound shared memory path has changed from - `/var/run/clockbound/shm` to `/var/run/clockbound/shm0`. - -### Removed - -- Support for reading ClockBound shared memory format version 1. - -## [1.0.0] - 2024-04-05 - -### Added - -- Communication with the ClockBound daemon is now performed via shared memory, - resulting in a large performance improvement. - -### Changed - -- Types used in the API have changed with this release. - -### Removed - -- Communication with the ClockBound daemon via Unix datagram socket has been - removed with this release. - -- Prior to 1.0.0, functions now(), before(), after() and timing() were - supported. With this release, before(), after() and timing() have been - removed. - -## [0.1.1] - 2022-03-11 - -### Added - -- Support for the `timing` call. - -## [0.1.0] - 2021-11-02 - -### Added - -- Initial working version diff --git a/clock-bound-client/Cargo.toml b/clock-bound-client/Cargo.toml deleted file mode 100644 index a784122..0000000 --- a/clock-bound-client/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "clock-bound-client" -description = "A Rust library to communicate with ClockBound daemon." -license = "Apache-2.0" -readme = "README.md" - -authors.workspace = true -categories.workspace = true -edition.workspace = true -exclude.workspace = true -keywords.workspace = true -publish.workspace = true -repository.workspace = true -version.workspace = true - -[dependencies] -clock-bound-shm = { version = "2.0", path = "../clock-bound-shm" } -clock-bound-vmclock = { version = "2.0", path = "../clock-bound-vmclock" } -errno = { version = "0.3.0", default-features = false } -nix = { version = "0.26", features = ["feature", "time"] } - -[dev-dependencies] -byteorder = "1" -libc = { version = "0.2", default-features = false, features = ["extra_traits"] } -tempfile = { version = "3.13" } diff --git a/clock-bound-client/LICENSE b/clock-bound-client/LICENSE deleted file mode 100644 index 7a4a3ea..0000000 --- a/clock-bound-client/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - 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 - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file diff --git a/clock-bound-client/NOTICE b/clock-bound-client/NOTICE deleted file mode 100644 index cb89e28..0000000 --- a/clock-bound-client/NOTICE +++ /dev/null @@ -1,2 +0,0 @@ -clock-bound-client -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/clock-bound-client/README.md b/clock-bound-client/README.md deleted file mode 100644 index 0e3acf2..0000000 --- a/clock-bound-client/README.md +++ /dev/null @@ -1,34 +0,0 @@ -[![Crates.io](https://img.shields.io/crates/v/clock-bound-client.svg)](https://crates.io/crates/clock-bound-client) -[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) - -# ClockBound client library - -A client library to communicate with ClockBound daemon. This client library is written in pure Rust. - -## Usage - -The ClockBound client library requires ClockBound daemon to be running to work. - -See [ClockBound daemon documentation](../clock-bound-d/README.md) for installation instructions. - -### Examples - -Source code of a runnable example program can be found at [../examples/rust](../examples/rust). - -See the [README.md](../examples/rust/README.md) in that directory for more details on how to build and run the example. - -### Building - -Run the following to build the source code of this crate using Cargo: - -```sh -cargo build --release -``` - -## Security - -See [CONTRIBUTING](../CONTRIBUTING.md#security-issue-notifications) for more information. - -## License - -Licensed under the [Apache 2.0](LICENSE) license. diff --git a/clock-bound-d/CHANGELOG.md b/clock-bound-d/CHANGELOG.md deleted file mode 100644 index 1d853f9..0000000 --- a/clock-bound-d/CHANGELOG.md +++ /dev/null @@ -1,98 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [2.0.3] - 2025-08-13 - -### Changed - -- Updates the polling rate of clockbound to be once every 100 milliseconds. - -## [2.0.2] - 2025-07-30 - -## [2.0.1] - 2025-05-26 - -### Changed - -- Fix bug in clock status transitions after a clock disruption. - -- Log more details when ChronyClient query_tracking fails. - -- Documentation: - Update clock status documentation. - Update finite state machine image to match the underlying source code. - -## [2.0.0] - 2025-04-21 - -### Added - -- VMClock is utilized for being informed of clock disruptions. - By default, ClockBound requires VMClock. - -- CLI option `--disable-clock-disruption-support`. - Using this option disables clock disruption support and causes - ClockBound to skip the VMClock requirement. - -- ClockBound shared memory format version 2. - This new shared memory format is not backwards compatible with the - shared memory format used in prior ClockBound releases. - See [PROTOCOL.md](../docs/PROTOCOL.md) for more details. - -### Changed - -- The default ClockBound shared memory path has changed from - `/var/run/clockbound/shm` to `/var/run/clockbound/shm0`. - -### Removed - -- Support for writing ClockBound shared memory format version 1. - -## [1.0.0] - 2024-04-05 - -### Changed - -- The communication mechanism used in the ClockBound daemon with clients has - changed from using Unix datagram socket to using shared memory. - -- The communication mechanism used to communicate between the ClockBound daemon - and Chrony has changed from UDP to Unix datagram socket. - -- ClockBound daemon must be run as the chrony user so that it can communicate - with Chrony. - -### Removed - -- Removed support for ClockBound clients that are using the *clock-bound-c* library - which communicates with the ClockBound daemon using Unix datagram socket. - -## [0.1.4] - 2023-11-16 - -### Added - -- ClockBound now supports [reading error bound from a PHC device](https://github.com/amzn/amzn-drivers/tree/master/kernel/linux/ena) as exposed from ENA driver -- Bump tokio dependency from 1.18.4 to 1.18.5 - -## [0.1.3] - 2023-01-11 - -### Added - -- Bump tokio dependency from 1.17.0 to 1.18.4 - -## [0.1.2] - 2022-03-11 - -### Added - -- Daemon now correctly handles queries originating from abstract sockets. - -## [0.1.1] - 2021-12-28 - -No changes, dependency bump only. - -## [0.1.0] - 2021-11-02 - -### Added - -- Initial working version diff --git a/clock-bound-d/Cargo.toml b/clock-bound-d/Cargo.toml deleted file mode 100644 index 0e5372e..0000000 --- a/clock-bound-d/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -[package] -name = "clock-bound-d" -description = "A daemon to provide clients with an error bounded timestamp interval." -license = "GPL-2.0-only" -readme = "README.md" - -authors.workspace = true -categories.workspace = true -edition.workspace = true -exclude.workspace = true -keywords.workspace = true -publish.workspace = true -repository.workspace = true -version.workspace = true - -[[bin]] -name = "clockbound" -path = "src/main.rs" - -[dependencies] -clock-bound-shm = { version = "2.0", path = "../clock-bound-shm", features = ["writer"]} -clock-bound-vmclock = { version = "2.0", path = "../clock-bound-vmclock"} -anyhow = "1" -byteorder = "1" -chrony-candm = "0.1.1" -clap = { version = "4", features = ["derive"] } -lazy_static = "1" -libc = { version = "0.2", default-features = false } -mockall = { version = "0.13", optional = true } -nix = { version = "0.26", features = ["feature", "time"] } -retry = "2.0.0" -socket2 = "0.5" -tracing = { version = "0.1", features = ["max_level_debug", "release_max_level_info"]} -tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } - -[dev-dependencies] -bon = "2.3" -mockall = "0.13" -mockall_double = "0.3.1" -rstest = "0.22" -serial_test = { version = "3" } -tempfile = {version = "3.13" } - -[features] -test = ["dep:mockall"] diff --git a/clock-bound-d/LICENSE b/clock-bound-d/LICENSE deleted file mode 100644 index ecbc059..0000000 --- a/clock-bound-d/LICENSE +++ /dev/null @@ -1,339 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. \ No newline at end of file diff --git a/clock-bound-d/NOTICE b/clock-bound-d/NOTICE deleted file mode 100644 index b5d6773..0000000 --- a/clock-bound-d/NOTICE +++ /dev/null @@ -1,2 +0,0 @@ -clock-bound-d -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. \ No newline at end of file diff --git a/clock-bound-d/README.md b/clock-bound-d/README.md deleted file mode 100644 index 0d68f27..0000000 --- a/clock-bound-d/README.md +++ /dev/null @@ -1,329 +0,0 @@ -# ClockBound daemon - -## Overview - -The ClockBound daemon `clockbound` interfaces with the Chrony daemon `chronyd` and the Operating System clock to provide clients with a bound on the error of the system clock. The ClockBound daemon periodically updates a shared memory segment that stores parameters to calculate the bound on clock error at any time. The ClockBound clients open the shared memory segment and read a timestamp interval within which true time exists. - -The ClockBound daemon has support for features that are provided by the Linux [VMClock](#VMClock). When the VMClock indicates that a clock disruption has occurred, the ClockBound daemon will communicate with Chrony and tell it to resynchronize the clock. The ClockBound client via its API will present an accurate representation of the clock status while this occurs. - -If the ClockBound daemon is running in an environment where clock disruptions are not expected to occur, the ClockBound daemon can be started with CLI option `--disable-clock-disruption-support`. This CLI option will bypass the requirement to have VMClock available and ClockBound will not handle clock disruptions. - -## Prerequisites - -### The synchronization daemon - chronyd - -The ClockBound daemon continuously communicates with Chrony daemon [chronyd](https://chrony-project.org/) to compose the clock error bound parameters. The Chrony daemon must be running to synchronize the system clock and provide clock correction parameters. - -#### Chrony installation - -- If running on Amazon Linux 2, Chrony daemon `chronyd` is already set as the default NTP daemon for you. -- If running on Amazon EC2, see the [EC2 User Guide](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/set-time.html) for more information on installing `chronyd` and syncing to the Amazon Time Sync Service. - -#### Chrony permissions - -The Chrony daemon `chronyd` has the ability to drop privileges once initialized. The rest of this guide assumes that `chronyd` runs under the `chrony` system user, which is the default for most distributions. - -Note that this impacts which process can communicate with `chronyd`. The ClockBound daemon `clockbound` communicates with Chrony daemon `chronyd` over Unix Datagram Socket (usually at `/var/run/chrony/chronyd.sock`). The Chrony daemon sets permissions such that only processes running under `root` or the `chrony` user can write to it. - -#### Chrony configuration - -**IMPORTANT: configuring the maxclockerror directive** - -Several sources of synchronization errors are taken into account by `clockbound` to provide the guarantee that true time is within a clock error bound interval. One of these components captures the stability of the local oscillator the system clock is built upon. By default, `chronyd` uses a very optimistic value of 1 PPM, which is appropriate for a clock error _estimate_ but not for a _bound_. The exact value to use depends on your hardware (you should check), otherwise, a value of 50 PPM should be appropriate for most configuration to capture the maximum drift in between clock updates. - -Update the `/etc/chrony.conf` configuration file and add the following directive to configure a 50 PPM max drift rate: - -``` -# Ensures chronyd grows local dispersion at a rate that is realistic and -# aligned with clockbound. -maxclockerror 50 -``` - -```sh -# Restart chronyd to ensure the configuration change is applied. -sudo systemctl restart chronyd -``` - -### VMClock - -The VMClock is a vDSO-style clock provided to VM guests. - -During maintenance events, VM guests may experience a clock disruption and it is possible that the underlying clock hardware is changed. This violates assumptions made by time-synchronization software running on VM guests. The VMClock allows us to address this problem by providing a mechanism for user-space applications such as ClockBound to be aware of clock disruptions, and take appropriate actions to ensure correctness for applications that depend on clock accuracy. - -For more details, see the description provided in file [vmclock-abi.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/vmclock-abi.h). - -The VMClock is included by default in: - -- Amazon Linux 2 `kernel-5.10.223-211.872.amzn2` and later. -- Amazon Linux 2023 `kernel-6.1.102-108.177.amzn2023` and later. -- Linux kernel `6.13` and later. - -If you are running a Linux kernel that is mentioned above, you will see VMClock at file path `/dev/vmclock0`, assuming that the cloud provider supports it for your virtual machine. - -Amazon Web Services (AWS) is rolling out VMClock support on EC2. This is being added first on AWS Graviton, with Intel and AMD following soon after. - -#### VMClock configuration - -VMClock at path `/dev/vmclock0` may not have the read permissions needed by ClockBound. Run the following command to add read permissions. - -```sh -sudo chmod a+r /dev/vmclock0 -``` - -## Installation - -#### Cargo - -ClockBound daemon can be installed using Cargo. Instructions on how to install Cargo can be found at [doc.rust-lang.org](https://doc.rust-lang.org/cargo/getting-started/installation.html). - -Install dependencies: - -```sh -sudo yum install gcc -``` - -Run cargo build with the release flag: - -```sh -cargo build --release -``` - -Cargo install will place the ClockBound daemon binary at this relative path: - -``` -target/release/clockbound -``` - -Optionally, copy the ClockBound daemon binary to the `/usr/local/bin` directory: - -```sh -sudo cp target/release/clockbound /usr/local/bin/ -``` - -## Configuration - -### One off configuration - -The ClockBound daemon `clockbound` needs to: - -- Write to a shared memory segment back by a file in `/var/run/clockbound/shm0`. -- Read from and write to chrony UDS socket. -- Read from a shared memory segment provided by the VMClock kernel module at file path `/dev/vmclock0`. This is not required if `clockbound` is started with the `--disable-clock-disruption-support` option. -- Have a `--max-drift-rate` parameter that matches `chronyd` configuration. - -```sh -# Set up ClockBound permissions. -sudo mkdir -p /run/clockbound -sudo chmod 775 /run/clockbound -sudo chown chrony:chrony /run/clockbound -sudo chmod a+r /dev/vmclock0 - -# Start the ClockBound daemon. -sudo -u chrony /usr/local/bin/clockbound --max-drift-rate 50 -``` - -#### Systemd configuration - -The rest of this section assumes the use of `systemd` to control the `clockbound` daemon. - -- Create unit file `/usr/lib/systemd/system/clockbound.service` with the following contents. -- Note that: - - The `clockbound` daemon runs under the `chrony` user to access `chronyd` UDS socket. - - The aim is to ensure the `RuntimeDirectory` that contains the file backing the shared memory segment is preserved over clockbound restart events. This lets client code run without interruption when the clockbound daemon is restarted. - - Depending on the version of systemd used (>=235), the `RuntimeDirectory` can be used in combination with - `RuntimeDirectoryPreserve`. - - - -**Systemd version >= 235** - -```ini -[Unit] -Description=ClockBound - -[Service] -Type=simple -Restart=always -RestartSec=10 -PermissionsStartOnly=true -ExecStartPre=/bin/chmod a+r /dev/vmclock0 -ExecStart=/usr/local/bin/clockbound --max-drift-rate 50 -RuntimeDirectory=clockbound -RuntimeDirectoryPreserve=yes -WorkingDirectory=/run/clockbound -User=chrony -Group=chrony - -[Install] -WantedBy=multi-user.target -``` - -**Systemd version < 235** - -```ini -[Unit] -Description=ClockBound - -[Service] -Type=simple -Restart=always -RestartSec=10 -PermissionsStartOnly=true -ExecStartPre=/bin/chmod a+r /dev/vmclock0 -ExecStartPre=/bin/mkdir -p /run/clockbound -ExecStartPre=/bin/chmod 775 /run/clockbound -ExecStartPre=/bin/chown chrony:chrony /run/clockbound -ExecStartPre=/bin/cd /run/clockbound -ExecStart=/usr/local/bin/clockbound --max-drift-rate 50 -WorkingDirectory=/run/clockbound -User=chrony -Group=chrony - -[Install] -WantedBy=multi-user.target -``` - -- Reload systemd and install and start the `clockbound` daemon - -```sh -sudo systemctl daemon-reload -sudo systemctl enable clockbound -sudo systemctl start clockbound -``` - -- You can then check the status of the service with: - -```sh -systemctl status clockbound -``` - -- Logs are accessible at `/var/log/daemon.log` or through - -```sh -# Show the ClockBound daemon logs. -sudo journalctl -u clockbound - -# Follow the ClockBound daemon logs. -sudo journalctl -f -u clockbound -``` - -## Clock status - -The value of the clock status written to the shared memory segment is driven by the Finite State Machine described below. The clock status exposed is a combination of the clock status known by chronyd as well as the disruption status. - -Each transition in the FSM is triggered by either: - -- An update retrieved from Chrony with the clock status which can be one of: `Unknown`, `Synchronized`, `FreeRunning`. -- An update retrieved from VMClock to signal clock disruption. Disruption status is one of: `Unknown`, `Reliable`, `Disrupted`. - -![graph](../docs/assets/FSM.png) - -If ClockBound daemon was started with CLI option `--disable-clock-disruption-support`, then the FSM is as follows: - -![graph](../docs/assets/FSM_clock_disruption_support_disabled.png) - -## PTP Hardware Clock (PHC) Support on EC2 - -### Configuring the PHC on Linux and Chrony. - -Steps to setup the PHC on Amazon Linux and Chrony is provided here: - -- https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configure-ec2-ntp.html - -On non-Amazon Linux distributions, the ENA Linux driver will need to be installed and configured with support for the PHC enabled: - -- https://github.com/amzn/amzn-drivers/tree/master/kernel/linux/ena - -### Configuring ClockBound to use the PHC. - -To get accurate clock error bound values when `chronyd` is synchronizing to the PHC (since `chronyd` assumes the PHC itself has 0 error bound which is not necesarily true), a PHC reference ID and PHC network interface (i.e. ENA interface like eth0) need to be supplied for ClockBound to read the clock error bound of the PHC and add it to `chronyd`'s clock error bound. This can be done via CLI args `-r` (ref ID) and `-i` (interface). Ref ID is seen in `chronyc tracking`, i.e.: - -``` -$ chronyc tracking -Reference ID : 50484330 (PHC0) <-- This 4 character ASCII code -Stratum : 1 -Ref time (UTC) : Wed Nov 15 18:24:30 2023 -System time : 0.000000014 seconds fast of NTP time -Last offset : +0.000000000 seconds -RMS offset : 0.000000060 seconds -Frequency : 6.614 ppm fast -Residual freq : +0.000 ppm -Skew : 0.019 ppm -Root delay : 0.000010000 seconds -Root dispersion : 0.000001311 seconds -Update interval : 1.0 seconds -Leap status : Normal -``` - -and network interface should be the primary network interface (from `ifconfig`, the interface with index 0) - on Amazon Linux 2 this will generally be `eth0`, and on Amazon Linux 2023 this will generally be `ens5`. - -For example: -``` -/usr/local/bin/clockbound -r PHC0 -i eth0 -``` - -To have your systemd unit do this, you'll need to edit the above line to supply the right arguments. - -For example: -```ini -[Unit] -Description=ClockBound - -[Service] -Type=simple -Restart=always -RestartSec=10 -PermissionsStartOnly=true -ExecStartPre=/bin/chmod a+r /dev/vmclock0 -ExecStart=/usr/local/bin/clockbound --max-drift-rate 50 -r PHC0 -i eth0 -RuntimeDirectory=clockbound -RuntimeDirectoryPreserve=yes -WorkingDirectory=/run/clockbound -User=chrony -Group=chrony - -[Install] -WantedBy=multi-user.target -``` - - -## Testing clock disruption support - -### Manual testing - VMClock - -ClockBound reads from the VMClock to know that the clock is disrupted. - -If you would like to do testing of ClockBound, simulating various VMClock states, one possibility is to use the vmclock-updater CLI tool. - -See the vmclock-updater [README.md](../test/vmclock-updater/README.md) for more details. - -### Manual testing - POSIX signal - -The ClockBound daemon supports triggering fake clock disruption events. - -Sending POSIX signal `SIGUSR1` to the ClockBound process turns clock disruption status ON. - -Sending POSIX signal `SIGUSR2` to the ClockBound process turns clock disruption status OFF. - -Quick example, assuming ClockBound is running with PID 1234, starting not disrupted: - -```sh -# Send a SIGUSR1 signal to ClockBound -kill -SIGUSR1 1234 -``` - -The ClockBound daemon emits a log message indicating it is entering a forced disruption period. - -> 2023-10-05T05:25:11.373568Z INFO main ThreadId(01) clock-bound-d/src/main.rs:40: Received SIGUSR1 signal, setting forced clock disruption to true - -An application using libclockbound will then see a clock status indicating the clock is "DISRUPTED". - -```sh -# Send a SIGUSR2 signal to ClockBound -kill -SIGUSR2 1234 -``` - -The ClockBound daemon emits a log message indicating it is leaving a forced disruption period. - -> 2023-10-05T05:25:19.590361Z INFO main ThreadId(01) clock-bound-d/src/main.rs:40: Received SIGUSR2 signal, setting forced clock disruption to false - diff --git a/clock-bound-d/src/chrony_client.rs b/clock-bound-d/src/chrony_client.rs deleted file mode 100644 index 8c1690f..0000000 --- a/clock-bound-d/src/chrony_client.rs +++ /dev/null @@ -1,515 +0,0 @@ -//! Abstractions to connect to a chrony client - -use std::time::{Duration, Instant}; - -use anyhow::Context; -use chrony_candm::{ - blocking_query_uds, - common::ChronyAddr, - reply::{Reply, ReplyBody, Status, Tracking}, - request::{Burst, RequestBody}, - ClientOptions, -}; -use retry::{delay::Fixed, retry_with_index}; -use tracing::info; - -/// The default client options for Chrony communications in ClockBound. -/// -/// The number of tries is set to 1 because retries are performed in the -/// ClockBound code so that we can have logs about the retry -/// attempts. -const CHRONY_CANDM_CLIENT_OPTIONS: ClientOptions = ClientOptions { - timeout: Duration::from_secs(1), - n_tries: 1, -}; - -/// Convenience trait for requesting information from Chrony -/// -/// The only fn that needs to be implemented is [`ChronyClient::query`]. After that, the default -/// implementations of the trait will be able to write to request the various metrics -#[cfg_attr(any(test, feature = "test"), mockall::automock)] -pub trait ChronyClient: Send { - /// Polls `chrony` for user requested statistics. - fn query(&self, request_body: RequestBody) -> std::io::Result; -} - -#[cfg(any(test, feature = "test"))] -impl ChronyClientExt for MockChronyClient {} - -impl core::fmt::Debug for (dyn ChronyClient + '_) { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("dyn ChronyClient") - } -} - -/// Extension trait on [`ChronyClient`] to implement high level chrony commands, like getting `tracking`. -pub trait ChronyClientExt: ChronyClient { - /// Polls `chrony` for tracking info - fn query_tracking(&self) -> anyhow::Result { - // Queries tracking data using `chronyc tracking`. - let request_body = RequestBody::Tracking; - let reply = self.query(request_body).context("query tracking")?; - - // Verifying query contains expected, tracking, metrics. - let ReplyBody::Tracking(tracking) = reply.body else { - anyhow::bail!( - "Reply body does not contain tracking statistics. {:?}", - reply - ); - }; - - Ok(tracking) - } - - /// Send command to chronyd to reset its sources - /// - /// Note that this is only supported by chronyd >= 4.0 - /// TODO: if chronyd is running a version < 4.0, may have to delete and add the peer back instead. - fn reset_sources(&self) -> anyhow::Result<()> { - let request_body = RequestBody::ResetSources; - let reply = self.query(request_body).context("reset chronyd")?; - if reply.status == Status::Success { - Ok(()) - } else { - Err(anyhow::anyhow!("Bad reply status {:?}", reply.status)) - } - } - - /// Send command to chronyd to send burst requests to its sources. - /// - /// Note that this is supported by chronyd >= 2.4 - fn burst_sources(&self) -> anyhow::Result<()> { - let burst_params = Burst { - mask: ChronyAddr::Unspec, - address: ChronyAddr::Unspec, - n_good_samples: 4, - n_total_samples: 8, - }; - let request_body = RequestBody::Burst(burst_params); - let reply = self.query(request_body).context("burst chronyd")?; - if reply.status == Status::Success { - Ok(()) - } else { - Err(anyhow::anyhow!("Bad reply status {:?}", reply.status)) - } - } - - /// Helper function, to reset chronyd and quickly poll for new samples. - /// - /// When we recover from a clock disruption, we want to make sure Chronyd gets reset and try to help it recover quickly. - /// Thus, we try to reset chronyd and then burst our upstream time sources for more samples. - fn reset_chronyd_with_retries(&self, num_retries: usize) -> anyhow::Result<()> { - let num_attempts = num_retries + 1; - retry_with_index( - Fixed::from_millis(5).take(num_retries), |attempt_number| { - let attempt_start_instant = Instant::now(); - self.reset_sources() - .inspect(|_| { - let attempt_duration = attempt_start_instant.elapsed(); - info!( - attempt = %attempt_number, - "Resetting chronyd sources (attempt {:?} of {:?}) was successful. Attempt duration: {:?}", - attempt_number, - num_attempts, - attempt_duration - ); - }) - .inspect_err(|e| { - let attempt_duration = attempt_start_instant.elapsed(); - info!( - attempt = %attempt_number, - "Resetting chronyd sources (attempt {:?} of {:?}) was unsuccessful. Err({:?}). Attempt duration: {:?}", - attempt_number, - num_attempts, - e, - attempt_duration - ); - }) - } - ).map_err(|e| anyhow::anyhow!("Failed to reset chronyd after {:?} attempts. Err({:?})", num_attempts, e))?; - - retry_with_index( - Fixed::from_millis(100).take(num_retries), |attempt_number| { - let attempt_start_instant = Instant::now(); - self.burst_sources() - .inspect(|_| { - let attempt_duration = attempt_start_instant.elapsed(); - info!( - attempt = %attempt_number, - "Bursting chronyd sources (attempt {:?} of {:?}) was successful. Attempt duration: {:?}", - attempt_number, - num_attempts, - attempt_duration - ); - }) - .inspect_err(|e| { - let attempt_duration = attempt_start_instant.elapsed(); - info!( - attempt = %attempt_number, - "Bursting chronyd sources (attempt {:?} of {:?}) was unsuccessful. Err({:?}). Attempt duration: {:?}", - attempt_number, - num_attempts, - e, - attempt_duration - ); - }) - } - ).map_err(|e| anyhow::anyhow!("Failed to burst chronyd after {:?} attempts. Err({:?})", num_attempts, e)) - } -} - -#[cfg(any(test, feature = "test"))] -mod mock_chrony_client_ext { - use super::*; - mockall::mock! { - pub ChronyClientExt {} - - impl ChronyClientExt for ChronyClientExt { - fn query_tracking(&self) -> anyhow::Result; - fn reset_sources(&self) -> anyhow::Result<()>; - fn burst_sources(&self) -> anyhow::Result<()>; - fn reset_chronyd_with_retries(&self, num_attempts: usize) -> anyhow::Result<()>; - } - } - - impl ChronyClient for MockChronyClientExt { - fn query(&self, _request_body: RequestBody) -> std::io::Result { - unimplemented!("mocks shouldn't call this") - } - } -} - -#[cfg(any(test, feature = "test"))] -pub use mock_chrony_client_ext::MockChronyClientExt; - -impl core::fmt::Debug for (dyn ChronyClientExt + '_) { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("dyn ChronyClientExt") - } -} - -/// Unix Domain Socket client for Chrony-CandM protocol. -/// -/// Getting Tracking data is a read-only operation. The chronyd daemon accepts these operations -/// over both a UDS as well as a UDP socket over the IPv4/IPv6 loopback addresses by default. -/// To support clock disruption, however, chronyd may be instructed to be reset. This mutable -/// operations are only accepted over a local UDS socket. -/// -/// The use of a UDS socket brings all sorts of permission issues. In particular, if chronyd -/// runs as the "chrony" user, chronyd sets the permissions on the UDS to the "chrony" user -/// only. So ... we don't want to wait for a clock disruption event to realize we have a -/// permission problem. Hence, even if the UDS socket is not strictly required here, we use it -/// to have an early and periodic signal that things are off. -pub struct UnixDomainSocket { - client_options: ClientOptions, -} - -impl Default for UnixDomainSocket { - fn default() -> Self { - Self { - client_options: CHRONY_CANDM_CLIENT_OPTIONS, - } - } -} - -impl ChronyClient for UnixDomainSocket { - fn query(&self, request_body: RequestBody) -> std::io::Result { - blocking_query_uds(request_body, self.client_options) - } -} - -impl ChronyClientExt for UnixDomainSocket {} - -#[cfg(test)] -mod test { - use super::*; - use chrony_candm::common::{ChronyAddr, ChronyFloat}; - use rstest::rstest; - - fn internal_error_response() -> chrony_candm::reply::Reply { - Reply { - status: chrony_candm::reply::Status::Failed, - cmd: 0, - sequence: 0, - body: ReplyBody::Null, - } - } - - fn example_candm_tracking() -> chrony_candm::reply::Reply { - let tracking = chrony_candm::reply::Tracking { - ref_id: 0u32, - ip_addr: ChronyAddr::Unspec, - stratum: 1u16, - leap_status: 0u16, - ref_time: std::time::SystemTime::now(), - current_correction: ChronyFloat::default(), - last_offset: ChronyFloat::default(), - rms_offset: ChronyFloat::default(), - freq_ppm: ChronyFloat::default(), - resid_freq_ppm: ChronyFloat::default(), - skew_ppm: ChronyFloat::default(), - root_delay: ChronyFloat::default(), - root_dispersion: ChronyFloat::default(), - last_update_interval: ChronyFloat::default(), - }; - - Reply { - status: chrony_candm::reply::Status::Success, - cmd: 0, - sequence: 0, - body: ReplyBody::Tracking(tracking), - } - } - - fn example_success_reply() -> Reply { - Reply { - status: Status::Success, - cmd: 0, - sequence: 0, - body: ReplyBody::Null, - } - } - - fn example_fail_reply() -> Reply { - Reply { - status: Status::Failed, - cmd: 0, - sequence: 0, - body: ReplyBody::Null, - } - } - - /// Test verifying failure modes of `gather_metrics`. If the mock chrony client returns a - /// `Err` or a success with an unexpected return type we expect an `Err` as a result. - #[rstest] - #[case::io_error(Err(std::io::Error::new(std::io::ErrorKind::Other, "oops")))] - #[case::wrong_response(Ok(internal_error_response()))] - fn test_chrony_tracking_fail(#[case] return_value: std::io::Result) { - let mut mock_chrony_client = MockChronyClient::new(); - - mock_chrony_client - .expect_query() - .once() - .withf(|body| matches!(body, RequestBody::Tracking)) - .return_once(|_| return_value); - - let rt = mock_chrony_client.query_tracking(); - assert!(rt.is_err()); - } - - #[test] - fn test_chrony_tracking_success() { - let mut mock_chrony_client = MockChronyClient::new(); - - mock_chrony_client - .expect_query() - .once() - .withf(|body| matches!(body, RequestBody::Tracking)) - .return_once(|_| Ok(example_candm_tracking())); - let rt = mock_chrony_client.query_tracking(); - assert!(rt.is_ok()); - } - - #[rstest] - #[case::io_error(Err(std::io::Error::new(std::io::ErrorKind::Other, "oops")))] - #[case::wrong_response(Ok(internal_error_response()))] - fn test_chrony_reset_sources_fail(#[case] return_value: std::io::Result) { - let mut mock_chrony_client = MockChronyClient::new(); - - mock_chrony_client - .expect_query() - .once() - .withf(|body| matches!(body, RequestBody::ResetSources)) - .return_once(|_| return_value); - - let rt = mock_chrony_client.reset_sources(); - assert!(rt.is_err()); - } - - #[test] - fn test_chrony_reset_sources_success() { - let mut mock_chrony_client = MockChronyClient::new(); - mock_chrony_client - .expect_query() - .once() - .withf(|body| matches!(body, RequestBody::ResetSources)) - .return_once(|_| { - Ok(Reply { - status: Status::Success, - cmd: 0, - sequence: 0, - body: ReplyBody::Null, - }) - }); - let rt = mock_chrony_client.reset_sources(); - assert!(rt.is_ok()); - } - - #[rstest] - #[case::io_error(Err(std::io::Error::new(std::io::ErrorKind::Other, "oops")))] - #[case::wrong_response(Ok(internal_error_response()))] - fn test_chrony_burst_sources_fail(#[case] return_value: std::io::Result) { - let mut mock_chrony_client = MockChronyClient::new(); - mock_chrony_client - .expect_query() - .once() - .withf(|body| { - matches!( - body, - RequestBody::Burst(Burst { - mask: ChronyAddr::Unspec, - address: ChronyAddr::Unspec, - n_good_samples: 4, - n_total_samples: 8, - }) - ) - }) - .return_once(|_| return_value); - let rt = mock_chrony_client.burst_sources(); - assert!(rt.is_err()); - } - - #[test] - fn test_chrony_burst_sources_success() { - let mut mock_chrony_client = MockChronyClient::new(); - mock_chrony_client - .expect_query() - .once() - .withf(|body| { - matches!( - body, - RequestBody::Burst(Burst { - mask: ChronyAddr::Unspec, - address: ChronyAddr::Unspec, - n_good_samples: 4, - n_total_samples: 8, - }) - ) - }) - .return_once(|_| { - Ok(Reply { - status: Status::Success, - cmd: 0, - sequence: 0, - body: ReplyBody::Null, - }) - }); - let rt = mock_chrony_client.burst_sources(); - assert!(rt.is_ok()); - } - - #[rstest] - #[case::succeed_on_first_try_for_both_requests( - vec![example_success_reply()], - vec![example_success_reply()], - 1, - 1, - 10 - )] - #[case::succeed_after_some_fails_for_both_requests( - vec![example_fail_reply(), example_fail_reply(), example_success_reply()], - vec![example_fail_reply(), example_success_reply()], - 3, - 2, - 10 - )] - #[case::single_attempt_with_no_retries_success( - vec![example_success_reply()], - vec![example_success_reply()], - 1, - 1, - 0 - )] - fn test_reset_chronyd_with_retries_success( - #[case] reset_return_values: Vec, - #[case] burst_return_values: Vec, - #[case] expected_reset_call_count: usize, - #[case] expected_burst_call_count: usize, - #[case] num_attempts: usize, - ) { - let mut sequence = mockall::Sequence::new(); - let mut mock_chrony_client = MockChronyClient::new(); - - let mut reset_return_values = reset_return_values.into_iter(); - let mut burst_return_values = burst_return_values.into_iter(); - - mock_chrony_client - .expect_query() - .times(expected_reset_call_count) - .withf(|body| matches!(body, RequestBody::ResetSources)) - .returning(move |_| Ok(reset_return_values.next().unwrap())) - .in_sequence(&mut sequence); - mock_chrony_client - .expect_query() - .times(expected_burst_call_count) - .withf(|body| { - matches!( - body, - RequestBody::Burst(Burst { - mask: ChronyAddr::Unspec, - address: ChronyAddr::Unspec, - n_good_samples: 4, - n_total_samples: 8, - }) - ) - }) - .returning(move |_| Ok(burst_return_values.next().unwrap())) - .in_sequence(&mut sequence); - let res = mock_chrony_client.reset_chronyd_with_retries(num_attempts); - assert!(res.is_ok()); - } - - #[rstest] - #[case::fail_after_too_many_reset_sources_fails( - vec![example_fail_reply(); 10], - vec![], - 10, - 0, - 9 - )] - #[case::fail_after_too_many_burst_sources_fails( - vec![example_success_reply()], - vec![example_fail_reply(); 10], - 1, - 10, - 9 - )] - fn test_reset_chronyd_with_retries_failure( - #[case] reset_return_values: Vec, - #[case] burst_return_values: Vec, - #[case] expected_reset_call_count: usize, - #[case] expected_burst_call_count: usize, - #[case] num_attempts: usize, - ) { - let mut sequence = mockall::Sequence::new(); - let mut mock_chrony_client = MockChronyClient::new(); - - let mut reset_return_values = reset_return_values.into_iter(); - let mut burst_return_values = burst_return_values.into_iter(); - - mock_chrony_client - .expect_query() - .times(expected_reset_call_count) - .withf(|body| matches!(body, RequestBody::ResetSources)) - .returning(move |_| Ok(reset_return_values.next().unwrap())) - .in_sequence(&mut sequence); - mock_chrony_client - .expect_query() - .times(expected_burst_call_count) - .withf(|body| { - matches!( - body, - RequestBody::Burst(Burst { - mask: ChronyAddr::Unspec, - address: ChronyAddr::Unspec, - n_good_samples: 4, - n_total_samples: 8, - }) - ) - }) - .returning(move |_| Ok(burst_return_values.next().unwrap())) - .in_sequence(&mut sequence); - let res = mock_chrony_client.reset_chronyd_with_retries(num_attempts); - assert!(res.is_err()); - } -} diff --git a/clock-bound-d/src/clock_bound_runner.rs b/clock-bound-d/src/clock_bound_runner.rs deleted file mode 100644 index f589c75..0000000 --- a/clock-bound-d/src/clock_bound_runner.rs +++ /dev/null @@ -1,746 +0,0 @@ -use std::sync::atomic::Ordering; -use std::time::Duration; - -use crate::chrony_client::ChronyClientExt; -use crate::clock_snapshot_poller::{ClockStatusSnapshot, ClockStatusSnapshotPoller}; -use crate::clock_state_fsm::{FSMState, ShmClockState}; -use crate::clock_state_fsm_no_disruption::ShmClockStateNoDisruption; -use crate::{ - ChronyClockStatus, ClockDisruptionState, FORCE_DISRUPTION_PENDING, FORCE_DISRUPTION_STATE, -}; -use clock_bound_shm::common::{clock_gettime_safe, CLOCK_MONOTONIC}; -use clock_bound_shm::{ClockErrorBound, ClockStatus, ShmWrite}; -use clock_bound_vmclock::shm::VMClockShmBody; -use clock_bound_vmclock::shm_reader::VMClockShmReader; -use nix::sys::time::TimeSpec; -use retry::delay::Fixed; -use retry::retry; -use tracing::error; -use tracing::{debug, info}; - -/// The chronyd daemon may be restarted from time to time. This may not necessarily implies that -/// the clock error should not be trusted. This constant defines the amount of time the clock can -/// be kept in FREE_RUNNING mode, before moving to UNKNOWN. -const CHRONY_RESTART_GRACE_PERIOD: Duration = Duration::from_secs(5); - -/// Number of chronyd reset retries in a row that we should take -/// before waiting for cooldown duration. -const CHRONY_RESET_NUM_RETRIES: usize = 29; - -/// Duration to sleep after attempting to reset and burst chronyd's sources. -const CHRONY_RESET_COOLDOWN_DURATION: Duration = Duration::from_secs(10); - -/// Central state of the ClockBound daemon. -/// This struct holds all the internal state of the ClockBound daemon. -pub(crate) struct ClockBoundRunner { - /// State: FSM that tracks the status of the clock written to the SHM segment. - shm_clock_state: Box, - /// State: The last calculated clock error bound based on a snapshot of clock sync info. - bound_nsec: i64, - /// State: The time at which a clock status snapshot was sampled last. - as_of: TimeSpec, - /// State: The count of clock disruption events. - disruption_marker: u64, - /// Config: Maximum drift rate of the clock between updates of the synchronization daemon. - max_drift_ppb: u32, - /// Config: Flag indicating whether or not clock disruption support is enabled. - clock_disruption_support_enabled: bool, -} - -impl ClockBoundRunner { - pub fn new(clock_disruption_support_enabled: bool, max_drift_ppb: u32) -> Self { - // Select a FSM that supports (or doesn't support) clock disruption - if clock_disruption_support_enabled { - ClockBoundRunner { - shm_clock_state: Box::::default(), - bound_nsec: 0, - as_of: TimeSpec::new(0, 0), - disruption_marker: 0, - max_drift_ppb, - clock_disruption_support_enabled, - } - } else { - ClockBoundRunner { - shm_clock_state: Box::::default(), - bound_nsec: 0, - as_of: TimeSpec::new(0, 0), - disruption_marker: 0, - max_drift_ppb, - clock_disruption_support_enabled, - } - } - } - - /// Write the clock error bound to the shared memory segment - /// - /// The internal state and parameters kept on the ShmUpdater allow to call this function on any - /// external event received. - fn write_clock_error_bound(&mut self, shm_writer: &mut impl ShmWrite) { - // Set a future point in time by which a stale value of the error bound accessed by a - // reader should not be used. For now, set it to 1000 seconds, which maps to how long a - // linear model of drift is valid. This is fairly arbitrary and needs to be revisited. - // - // TODO: calibrate the value passed to void_after - let void_after = TimeSpec::new(self.as_of.tv_sec() + 1000, 0); - - let ceb = ClockErrorBound::new( - self.as_of, - void_after, - self.bound_nsec, - self.disruption_marker, - self.max_drift_ppb, - self.shm_clock_state.value(), - self.clock_disruption_support_enabled, - ); - - debug!("Writing ClockErrorBound to shared memory {:?}", ceb); - shm_writer.write(&ceb); - } - - /// Handles all ClockDisruptionState sources, transitioning the FSM as needed. Today, these are: - /// - User-sent signals (SIGUSR1/2) - /// - VMClock Disruption Marker checking - /// - /// We defer vmclock snapshot handling of "disruption state", if a "forced disruption" is pending. - /// That "forced disruption" should be handled on the next call, unless another SIGUSR1/2 comes in. - fn handle_disruption_sources( - &mut self, - shm_writer: &mut impl ShmWrite, - vm_clock_reader: &mut Option, - ) { - if FORCE_DISRUPTION_PENDING.load(Ordering::SeqCst) { - info!("FORCE_DISRUPTION_PENDING was set, handling forced disruption"); - self.handle_forced_disruption_state(shm_writer); - FORCE_DISRUPTION_PENDING.store(false, Ordering::SeqCst); - } else { - // Check for clock disruptions if we are running with clock disruption support. - if let Some(ref mut vm_clock_reader) = vm_clock_reader { - match vm_clock_reader.snapshot() { - Ok(snapshot) => self.handle_vmclock_disruption_marker(snapshot), - Err(e) => error!( - "Failed to snapshot the VMClock shared memory segment: {:?}", - e - ), - } - } - } - } - - /// Handler for forced disruption state scenario. - /// - /// We will always apply ClockDisruptionState::Disrupted if we saw there was a disruption pending set, even - /// if FORCE_DISRUPTION_STATE is not set, in case that FORCE_DISRUPTION_STATE flipped quickly and we could have missed - /// an actual disruption. That case could happen if SIGUSR1/SIGUSR2 are sent consecutively before FORCE_DISRUPTION_STATE - /// is checked. - fn handle_forced_disruption_state(&mut self, shm_writer: &mut impl ShmWrite) { - info!("Applying ClockDisruptionState::Disrupted and waiting for disruption state to be set to false"); - self.shm_clock_state = self - .shm_clock_state - .apply_disruption(ClockDisruptionState::Disrupted); - // We have to write this state to the SHM in this case - otherwise, the client cannot see that it is disrupted - // (vmclock and ClockBound SHM segment might not differ in disruption marker) - self.write_clock_error_bound(shm_writer); - while FORCE_DISRUPTION_STATE.load(Ordering::SeqCst) { - info!("FORCE_DISRUPTION_STATE is still true, waiting for it to be set to false, ClockBound will do no other work at this time"); - std::thread::sleep(Duration::from_secs(1)); - } - info!("FORCE_DISRUPTION_STATE is now false, continuing execution of ClockBound"); - } - - /// Handles checking VMClock disruption marker - /// - /// Today, this is only used for detecting whether VMClock has "disrupted" the clock. - fn handle_vmclock_disruption_marker(&mut self, current_snapshot: &VMClockShmBody) { - // We've seen a change in our disruption marker, so we should apply "Disrupted" and update our disruption marker. - if self.disruption_marker != current_snapshot.disruption_marker { - debug!( - "Disruption marker changed from {:?} to {:?}", - self.disruption_marker, current_snapshot.disruption_marker - ); - debug!("Current VMClock snapshot: {:?}", current_snapshot); - self.shm_clock_state = self - .shm_clock_state - .apply_disruption(ClockDisruptionState::Disrupted); - self.disruption_marker = current_snapshot.disruption_marker; - } else { - // If the disruption marker is consistent across reads, then at this point we can assume our ClockDisruptionState - // is reliable. - self.shm_clock_state = self - .shm_clock_state - .apply_disruption(ClockDisruptionState::Reliable); - } - } - - /// Processes a ClockStatusSnapshot. - /// - /// This snapshot is used to update the error bound value written to ClockBound SHM, - /// and the clock state FSM. - fn apply_clock_status_snapshot(&mut self, snapshot: &ClockStatusSnapshot) { - debug!("Current ClockStatusSnapshot: {:?}", snapshot); - self.shm_clock_state = self - .shm_clock_state - .apply_chrony(snapshot.chrony_clock_status); - // Only update the clock error bound value if chrony is synchronized. This helps ensure - // that the default value of root delay and root dispersion (both set to 1 second) do not - // distort the linear growth of the clock error bound when chronyd restarts. - if snapshot.chrony_clock_status == ChronyClockStatus::Synchronized { - self.bound_nsec = snapshot.error_bound_nsec; - self.as_of = snapshot.as_of; - } - } - - /// Handles the case where a clock status snapshot was not retrieved successfully - /// (Chronyd may be non-responsive, or VMClock polling failed.) - /// - /// If beyond our grace period, clock status "Unknown" should be applied. - fn handle_missing_clock_status_snapshot(&mut self, as_of: TimeSpec) { - if (as_of - self.as_of) < TimeSpec::from_duration(CHRONY_RESTART_GRACE_PERIOD) { - debug!("Current timestamp is within grace period for Chronyd restarts, applying ChronyClockStatus::FreeRunning"); - self.shm_clock_state = self - .shm_clock_state - .apply_chrony(ChronyClockStatus::FreeRunning); - } else { - debug!("Current timestamp is not within grace period for Chronyd restarts, applying ChronyClockStatus::Unknown"); - self.shm_clock_state = self - .shm_clock_state - .apply_chrony(ChronyClockStatus::Unknown); - } - } - - /// Processes the current FSM state, performing any work or transitions needed. - /// - /// Currently, we only check if we're "Disrupted", and try continually to reset Chronyd - /// if we are, followed by applying ClockDisruptionState::Unknown. - fn process_current_fsm_state(&mut self, chrony_client: &impl ChronyClientExt) { - match self.shm_clock_state.value() { - ClockStatus::Disrupted => { - // This will continue to retry FOREVER until it succeeds. This is what we intend, since if our clock was "disrupted", - // resetting chronyd is a MUST, else chronyd will report stale and possibly dishonest tracking data. - let _ = retry(Fixed::from(CHRONY_RESET_COOLDOWN_DURATION), || { - chrony_client.reset_chronyd_with_retries(CHRONY_RESET_NUM_RETRIES) - }); - self.shm_clock_state = self - .shm_clock_state - .apply_disruption(ClockDisruptionState::Unknown); - } - _ => { - // Do nothing - } - } - } - - /// The "main loop" of the ClockBound daemon. - /// 1. Handle any ClockDisruptionState sources. - /// 2. Handle any ClockStatusSnapshot sources. - /// 3. Handle the state of the ClockState FSM. - /// 4. Write into the ClockBound SHM segment, which our clients read the clock error bound and current ClockStatus from. - pub(crate) fn run( - &mut self, - vm_clock_reader: &mut Option, - shm_writer: &mut impl ShmWrite, - clock_status_snapshot_poller: impl ClockStatusSnapshotPoller, - chrony_client: impl ChronyClientExt, - ) { - loop { - self.handle_disruption_sources(shm_writer, vm_clock_reader); - - if self.shm_clock_state.value() == ClockStatus::Disrupted { - info!("Clock is disrupted"); - self.process_current_fsm_state(&chrony_client); - } - - // First, make sure we take a MONOTONIC timestamp *before* getting ClockSyncInfoSnapshot data. This will - // slightly inflate the dispersion component of the clock error bound but better be - // pessimistic and correct, than greedy and wrong. The actual error added here is expected - // to be small. For example, let's assume a 50PPM drift rate. Let's also assume it takes 10 - // milliseconds for chronyd to respond. This will inflate the CEB by 500 nanoseconds. - // Assuming it takes 1 second (the timeout of our requests to chronyd), this would inflate the CEB by 50 microseconds. - match clock_gettime_safe(CLOCK_MONOTONIC) { - Ok(as_of) => { - match clock_status_snapshot_poller.retrieve_clock_status_snapshot(as_of) { - Ok(snapshot) => self.apply_clock_status_snapshot(&snapshot), - Err(e) => { - error!( - error = ?e, - "Failed to get clock status snapshot" - ); - self.handle_missing_clock_status_snapshot(as_of); - } - } - } - Err(e) => { - error!("Failed to get current monotonic timestamp {:?}", e); - } - } - - self.process_current_fsm_state(&chrony_client); - // Finally, write to Clockbound SHM. - self.write_clock_error_bound(shm_writer); - - // ClockErrorBound increases as the amount of time increases between when Chrony polls - // the reference clock and when ClockBound polls Chrony. ClockBound's polling of Chrony - // occurs periodically; its timing is independent of Chrony's polling of the - // reference clock. This means that ClockBound's polling could occur immediately after - // Chrony polls the reference clock (best-case scenario), at a time equal to the time - // Chrony last polled + Chrony polling period + ClockBound polling period (worst-case - // scenario) or any time in between. ClockBound's polling period can be leveraged to - // reduce the impact of the worst case scenario. A lower ClockBound polling period - // results in higher system resource utilization with diminishing returns on lowering - // the ClockErrorBound inflation. A polling period of 100 milliseconds strikes a good balance. - std::thread::sleep(Duration::from_millis(100)); - } - } -} - -#[cfg(test)] -mod t_clockbound_state_manager { - use std::fs::{File, OpenOptions}; - use std::io::Write; - - use rstest::rstest; - use serial_test::serial; - /// We make use of tempfile::NamedTempFile to ensure that - /// local files that are created during a test get removed - /// afterwards. - use tempfile::NamedTempFile; - - use clock_bound_vmclock::shm::VMClockClockStatus; - - use crate::chrony_client::MockChronyClientExt; - - use super::*; - - /// Test struct used to hold the expected fields in the VMClock shared memory segment. - #[repr(C)] - #[derive(Debug, Copy, Clone, PartialEq, bon::Builder)] - struct VMClockContent { - #[builder(default = 0x4B4C4356)] - magic: u32, - #[builder(default = 104_u32)] - size: u32, - #[builder(default = 1_u16)] - version: u16, - #[builder(default = 1_u8)] - counter_id: u8, - #[builder(default)] - time_type: u8, - #[builder(default)] - seq_count: u32, - #[builder(default)] - disruption_marker: u64, - #[builder(default)] - flags: u64, - #[builder(default)] - _padding: [u8; 2], - #[builder(default = VMClockClockStatus::Synchronized)] - clock_status: VMClockClockStatus, - #[builder(default)] - leap_second_smearing_hint: u8, - #[builder(default)] - tai_offset_sec: i16, - #[builder(default)] - leap_indicator: u8, - #[builder(default)] - counter_period_shift: u8, - #[builder(default)] - counter_value: u64, - #[builder(default)] - counter_period_frac_sec: u64, - #[builder(default)] - counter_period_esterror_rate_frac_sec: u64, - #[builder(default)] - counter_period_maxerror_rate_frac_sec: u64, - #[builder(default)] - time_sec: u64, - #[builder(default)] - time_frac_sec: u64, - #[builder(default)] - time_esterror_nanosec: u64, - #[builder(default)] - time_maxerror_nanosec: u64, - } - - impl Into for VMClockContent { - fn into(self) -> VMClockShmBody { - VMClockShmBody { - disruption_marker: self.disruption_marker, - flags: self.flags, - _padding: self._padding, - clock_status: self.clock_status, - leap_second_smearing_hint: self.leap_second_smearing_hint, - tai_offset_sec: self.tai_offset_sec, - leap_indicator: self.leap_indicator, - counter_period_shift: self.counter_period_shift, - counter_value: self.counter_value, - counter_period_frac_sec: self.counter_period_frac_sec, - counter_period_esterror_rate_frac_sec: self.counter_period_esterror_rate_frac_sec, - counter_period_maxerror_rate_frac_sec: self.counter_period_maxerror_rate_frac_sec, - time_sec: self.time_sec, - time_frac_sec: self.time_frac_sec, - time_esterror_nanosec: self.time_esterror_nanosec, - time_maxerror_nanosec: self.time_maxerror_nanosec, - } - } - } - - mockall::mock! { - pub ShmWrite {} - impl ShmWrite for ShmWrite { - fn write(&mut self, ceb: &ClockErrorBound); - } - } - - /// Helper to build a SHM clock state - the default starts off unknown, - /// then we apply chrony transitions and disruption transitions so we reach an intended end state. - /// Each `apply_*` result depends on the current state of FSM and the current ChronyClockStatus and ClockDisruptionState - /// but to simplify things we just start off unknown and apply chrony then disruption states. - fn build_shm_clock_state( - chrony_clock_status: ChronyClockStatus, - clock_disruption_state: ClockDisruptionState, - ) -> Box { - Box::::default() - .apply_chrony(chrony_clock_status) - .apply_disruption(clock_disruption_state) - } - - fn write_vmclock_content(file: &mut File, vmclock_content: &VMClockContent) { - // Convert the VMClockShmBody struct into a slice so we can write it all out, fairly magic. - // Definitely needs the #[repr(C)] layout. - let slice = unsafe { - ::core::slice::from_raw_parts( - (vmclock_content as *const VMClockContent) as *const u8, - ::core::mem::size_of::(), - ) - }; - - file.write_all(slice).expect("Write failed VMClockContent"); - file.sync_all().expect("Sync to disk failed"); - } - - fn write_mock_vmclock_content( - vmclock_shm_tempfile: &NamedTempFile, - vmclock_content: &VMClockContent, - ) { - let vmclock_shm_temppath = vmclock_shm_tempfile.path(); - let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); - let mut vmclock_shm_file = OpenOptions::new() - .write(true) - .open(vmclock_shm_path) - .expect("open vmclock file failed"); - write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); - } - - #[rstest] - #[case::start_synchronized_and_with_same_disruption_marker_should_stay_synchronized( - VMClockContent::builder().disruption_marker(0).build().into(), - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatus::Synchronized, - 0 - )] - #[case::start_synchronized_and_with_different_disruption_marker_should_get_disrupted( - VMClockContent::builder().disruption_marker(1).build().into(), - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatus::Disrupted, - 1 - )] - #[case::start_unknown_and_with_same_disruption_marker_should_become_synchronized( - VMClockContent::builder().disruption_marker(0).build().into(), - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Unknown), - ClockStatus::Unknown, - ClockStatus::Synchronized, - 0 - )] - #[case::start_unknown_and_with_different_disruption_marker_should_become_disrupted( - VMClockContent::builder().disruption_marker(1).build().into(), - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Unknown), - ClockStatus::Unknown, - ClockStatus::Disrupted, - 1 - )] - #[case::start_disrupted_and_with_same_disruption_marker_should_become_unknown( - VMClockContent::builder().disruption_marker(0).build().into(), - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Disrupted), - ClockStatus::Disrupted, - ClockStatus::Unknown, - 0 - )] - #[case::start_disrupted_and_with_different_disruption_marker_should_stay_disrupted( - VMClockContent::builder().disruption_marker(1).build().into(), - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Disrupted), - ClockStatus::Disrupted, - ClockStatus::Disrupted, - 1 - )] - fn test_handle_vmclock_disruption_marker( - #[case] vmclock_shm_body: VMClockShmBody, - #[case] initial_clock_fsm: Box, - #[case] expected_initial_clock_status: ClockStatus, - #[case] expected_final_clock_status: ClockStatus, - #[case] expected_disruption_marker: u64, - ) { - let mut clockbound_state_manager = ClockBoundRunner::new( - // Clock disruption enabled. - true, 0, - ); - clockbound_state_manager.shm_clock_state = initial_clock_fsm; - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_initial_clock_status - ); - clockbound_state_manager.handle_vmclock_disruption_marker(&vmclock_shm_body); - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_final_clock_status - ); - assert_eq!( - clockbound_state_manager.disruption_marker, - expected_disruption_marker - ); - } - - #[rstest] - #[case::start_unknown_and_apply_synchronized_snapshot_should_get_us_synchronized( - build_shm_clock_state(ChronyClockStatus::Unknown, ClockDisruptionState::Reliable), - ClockStatus::Unknown, - ClockStatusSnapshot { - chrony_clock_status: ChronyClockStatus::Synchronized, - error_bound_nsec: 123, - as_of: TimeSpec::new(456, 789), - }, - ClockStatus::Synchronized, - 123, - TimeSpec::new(456, 789), - )] - #[case::start_synchronized_and_apply_freerunning_snapshot_should_get_us_freerunning( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatusSnapshot { - chrony_clock_status: ChronyClockStatus::FreeRunning, - error_bound_nsec: 123, - as_of: TimeSpec::new(456, 789), - }, - ClockStatus::FreeRunning, - 123, - TimeSpec::new(456, 789), - )] - fn test_apply_clock_status_snapshot( - #[case] initial_clock_fsm: Box, - #[case] expected_initial_clock_status: ClockStatus, - #[case] snapshot_to_apply: ClockStatusSnapshot, - #[case] expected_final_clock_status: ClockStatus, - #[case] expected_bound_nsec: i64, - #[case] expected_as_of: TimeSpec, - ) { - let mut clockbound_state_manager = ClockBoundRunner::new(true, 0); - clockbound_state_manager.shm_clock_state = initial_clock_fsm; - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_initial_clock_status - ); - clockbound_state_manager.apply_clock_status_snapshot(&snapshot_to_apply); - - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_final_clock_status - ); - if snapshot_to_apply.chrony_clock_status == ChronyClockStatus::Synchronized { - assert_eq!(clockbound_state_manager.bound_nsec, expected_bound_nsec); - assert_eq!(clockbound_state_manager.as_of, expected_as_of); - } else { - assert_eq!(clockbound_state_manager.bound_nsec, 0); - assert_eq!(clockbound_state_manager.as_of, TimeSpec::new(0, 0)); - } - } - - #[rstest] - #[case::within_grace_period_so_freerunning_is_applied( - TimeSpec::new(0, 0), - TimeSpec::new(0, 0), - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatus::FreeRunning - )] - #[case::beyond_grace_period_so_unknown_is_applied( - TimeSpec::new(0, 0), - TimeSpec::new(5, 0), // current time as_of is 5 seconds after the initial as_of - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatus::Unknown - )] - fn test_handle_missing_clock_status_snapshot( - #[case] initial_as_of: TimeSpec, - #[case] current_time_as_of: TimeSpec, - #[case] initial_clock_fsm: Box, - #[case] expected_initial_clock_status: ClockStatus, - #[case] expected_final_clock_status: ClockStatus, - ) { - let mut clockbound_state_manager = ClockBoundRunner::new(true, 0); - clockbound_state_manager.as_of = initial_as_of; - clockbound_state_manager.shm_clock_state = initial_clock_fsm; - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_initial_clock_status - ); - clockbound_state_manager.handle_missing_clock_status_snapshot(current_time_as_of); - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_final_clock_status - ); - } - - #[rstest] - #[case::clock_is_synchronized_should_be_noop( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatus::Synchronized - )] - #[case::clock_is_unknown_should_be_noop( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Unknown), - ClockStatus::Unknown, - ClockStatus::Unknown - )] - #[case::clock_is_disrupted_should_reset_chronyd_and_apply_unknown( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Disrupted), - ClockStatus::Disrupted, - ClockStatus::Unknown - )] - fn test_process_current_fsm_state( - #[case] initial_clock_fsm: Box, - #[case] expected_initial_clock_status: ClockStatus, - #[case] expected_final_clock_status: ClockStatus, - ) { - let mut clockbound_state_manager = ClockBoundRunner::new(true, 0); - let mut mock_chrony_client = MockChronyClientExt::new(); - clockbound_state_manager.shm_clock_state = initial_clock_fsm; - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_initial_clock_status - ); - if clockbound_state_manager.shm_clock_state.value() != ClockStatus::Disrupted { - mock_chrony_client - .expect_reset_chronyd_with_retries() - .never(); - } else { - mock_chrony_client - .expect_reset_chronyd_with_retries() - .once() - .with(mockall::predicate::eq(CHRONY_RESET_NUM_RETRIES)) - .return_once(|_| Ok(())); - } - clockbound_state_manager.process_current_fsm_state(&mock_chrony_client); - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_final_clock_status - ); - } - - #[rstest] - #[case::no_forced_disruption_and_disruption_marker_is_consistent( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatus::Synchronized, - false, - 0, - 0 - )] - #[case::force_disruption_pending_true_with_consistent_disruption_marker( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatus::Disrupted, - true, - 0, - 0 - )] - #[case::force_disruption_pending_true_with_changed_disruption_marker_should_not_handle_disruption_marker( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatus::Disrupted, - true, - 1, - 0, - )] - #[serial] - fn test_handle_disruption_sources( - #[case] initial_clock_fsm: Box, - #[case] expected_initial_clock_status: ClockStatus, - #[case] expected_final_clock_status: ClockStatus, - #[case] initial_forced_disruption_pending: bool, - #[case] disruption_marker_to_write_to_vmclock: u64, - #[case] expected_disruption_marker: u64, - ) { - let mut mock_shm_writer = MockShmWrite::new(); - if initial_forced_disruption_pending { - mock_shm_writer.expect_write().once().return_const(()); - } else { - mock_shm_writer.expect_write().never(); - } - let mut clockbound_state_manager = ClockBoundRunner::new(true, 0); - clockbound_state_manager.shm_clock_state = initial_clock_fsm; - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_initial_clock_status - ); - - let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); - // disruption marker is 0, which is same as our default in ClockBoundRunner - write_mock_vmclock_content( - &vmclock_shm_tempfile, - &VMClockContent::builder() - .disruption_marker(disruption_marker_to_write_to_vmclock) - .build(), - ); - let vm_clock_reader = - VMClockShmReader::new(vmclock_shm_tempfile.path().to_str().unwrap()).unwrap(); - FORCE_DISRUPTION_PENDING.store(initial_forced_disruption_pending, Ordering::SeqCst); - clockbound_state_manager - .handle_disruption_sources(&mut mock_shm_writer, &mut Some(vm_clock_reader)); - // Clear the disruption pending signal to avoid polluting other tests - FORCE_DISRUPTION_PENDING.store(false, Ordering::SeqCst); - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_final_clock_status - ); - assert_eq!( - clockbound_state_manager.disruption_marker, - expected_disruption_marker - ); - } - - #[rstest] - #[case::clock_is_synchronized_should_become_disrupted( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable), - ClockStatus::Synchronized, - ClockStatus::Disrupted - )] - #[case::clock_is_unknown_should_become_disrupted( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Unknown), - ClockStatus::Unknown, - ClockStatus::Disrupted - )] - #[case::clock_is_disrupted_should_stay_disrupted( - build_shm_clock_state(ChronyClockStatus::Synchronized, ClockDisruptionState::Disrupted), - ClockStatus::Disrupted, - ClockStatus::Disrupted - )] - fn test_handle_forced_disruption_state( - #[case] initial_clock_fsm: Box, - #[case] expected_initial_clock_status: ClockStatus, - #[case] expected_final_clock_status: ClockStatus, - ) { - let mut mock_shm_writer = MockShmWrite::new(); - mock_shm_writer.expect_write().once().return_const(()); - let mut clockbound_state_manager = ClockBoundRunner::new(true, 0); - clockbound_state_manager.shm_clock_state = initial_clock_fsm; - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_initial_clock_status - ); - clockbound_state_manager.handle_forced_disruption_state(&mut mock_shm_writer); - assert_eq!( - clockbound_state_manager.shm_clock_state.value(), - expected_final_clock_status - ); - } -} diff --git a/clock-bound-d/src/clock_snapshot_poller.rs b/clock-bound-d/src/clock_snapshot_poller.rs deleted file mode 100644 index cb9a40b..0000000 --- a/clock-bound-d/src/clock_snapshot_poller.rs +++ /dev/null @@ -1,21 +0,0 @@ -use nix::sys::time::TimeSpec; - -use crate::ChronyClockStatus; - -pub(crate) mod chronyd_snapshot_poller; - -/// Trait for retrieving a snapshot of clock sync information -pub trait ClockStatusSnapshotPoller { - fn retrieve_clock_status_snapshot( - &self, - as_of: TimeSpec, - ) -> anyhow::Result; -} - -/// A snapshot of clock sync information at some particular time (CLOCK_MONOTONIC). -#[derive(Debug)] -pub struct ClockStatusSnapshot { - pub error_bound_nsec: i64, - pub chrony_clock_status: ChronyClockStatus, - pub as_of: TimeSpec, -} diff --git a/clock-bound-d/src/clock_snapshot_poller/chronyd_snapshot_poller.rs b/clock-bound-d/src/clock_snapshot_poller/chronyd_snapshot_poller.rs deleted file mode 100644 index df4c238..0000000 --- a/clock-bound-d/src/clock_snapshot_poller/chronyd_snapshot_poller.rs +++ /dev/null @@ -1,404 +0,0 @@ -use chrony_candm::reply::Tracking; -use nix::sys::time::TimeSpec; -use std::time::{Duration, Instant, SystemTimeError}; -use tracing::{error, warn}; - -#[cfg(any(test, feature = "test"))] -use crate::phc_utils::MockPhcWithSysfsErrorBound as PhcWithSysfsErrorBound; -#[cfg(not(any(test, feature = "test")))] -use crate::phc_utils::PhcWithSysfsErrorBound; -use crate::{chrony_client::ChronyClientExt, ChronyClockStatus}; - -use super::{ClockStatusSnapshot, ClockStatusSnapshotPoller}; - -/// Struct implementing ClockSyncInfoPoller which polls Chronyd and adds in -/// ENA PHC error bound data when syncing to an ENA PHC reference clock corresponding to maybe_phc_info, -/// if PhcInfo is supplied. -pub struct ChronyDaemonSnapshotPoller { - chrony_client: Box, - maybe_phc_error_bound_reader: Option, -} - -impl ChronyDaemonSnapshotPoller { - pub fn new( - chrony_client: Box, - maybe_phc_error_bound_reader: Option, - ) -> Self { - Self { - chrony_client, - maybe_phc_error_bound_reader, - } - } -} - -impl ClockStatusSnapshotPoller for ChronyDaemonSnapshotPoller { - fn retrieve_clock_status_snapshot( - &self, - as_of: TimeSpec, - ) -> anyhow::Result { - let tracking_request_start_time = Instant::now(); - let tracking = self.chrony_client.query_tracking()?; - let get_tracking_duration = tracking_request_start_time.elapsed(); - if get_tracking_duration.as_millis() > 2000 { - warn!( - "Chronyd tracking query took a long time. Duration: {:?}", - get_tracking_duration - ); - } - let phc_error_bound_nsec = match &self.maybe_phc_error_bound_reader { - // Only add PHC error bound if PHC info was supplied via CLI and - // current tracking reference uses it. - Some(phc_error_bound_reader) - if phc_error_bound_reader.get_phc_ref_id() == tracking.ref_id => - { - match phc_error_bound_reader.read_phc_error_bound() { - Ok(phc_error_bound) => phc_error_bound, - Err(e) => { - anyhow::bail!("Failed to retrieve PHC error bound: {:?}", e); - } - } - } - _ => 0, - }; - let error_bound_nsec = tracking.extract_error_bound_nsec() + phc_error_bound_nsec; - let chrony_clock_status = tracking.get_chrony_clock_status()?; - Ok(ClockStatusSnapshot { - error_bound_nsec, - chrony_clock_status, - as_of, - }) - } -} - -#[cfg_attr(any(test, feature = "test"), mockall::automock)] -trait TrackingExt { - fn extract_error_bound_nsec(&self) -> i64; - fn get_chrony_clock_status(&self) -> anyhow::Result; -} - -impl TrackingExt for Tracking { - fn extract_error_bound_nsec(&self) -> i64 { - let root_delay: f64 = self.root_delay.into(); - let root_dispersion: f64 = self.root_dispersion.into(); - let current_correction: f64 = f64::from(self.current_correction).abs(); - - // Compute the clock error bound in nanoseconds *at the time chrony reported the tracking data*. - // Remember that the root dispersion reported by chrony is at the time the tracking data is - // retrieved, not at the time of the last system clock update. - ((root_delay / 2. + root_dispersion + current_correction) * 1_000_000_000.0).ceil() as i64 - } - - fn get_chrony_clock_status(&self) -> anyhow::Result { - // Compute the duration since the last time chronyd updated the system clock. - let duration_since_update = self.ref_time.elapsed().inspect_err(|e| { - error!( - error = ?e, - "Failed to get duration since chronyd last clock update", - ); - })?; - - // Compute the time it would take for chronyd 8-wide register to be completely empty (e.g. the - // last 8 NTP requests timed out) - let polling_period = f64::from(self.last_update_interval); - let empty_register_timeout = Duration::from_secs((polling_period * 8.0) as u64); - - // Get the status reported by chrony and tracking data. - // Chronyd tends to report a synchronized status for a very looooong time after it has failed - // to continuously receive NTP responses. Here the status is over-written if the last time - // chronyd successfully updated the system clock is "too old". Too old is define as the time it - // would take for the 8-wide register to become empty. - let chrony_clock_status = match ChronyClockStatus::from(self.leap_status) { - ChronyClockStatus::Synchronized => { - if duration_since_update > empty_register_timeout { - ChronyClockStatus::FreeRunning - } else { - ChronyClockStatus::Synchronized - } - } - status => status, - }; - Ok(chrony_clock_status) - } -} - -#[cfg(test)] -mod test_chrony_daemon_snapshot_poller { - use super::*; - - use crate::chrony_client::MockChronyClientExt; - use crate::{phc_utils::MockPhcWithSysfsErrorBound, ChronyClockStatus}; - - use chrony_candm::common::ChronyFloat; - use chrony_candm::{common::ChronyAddr, reply::Tracking}; - use rstest::rstest; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[derive(bon::Builder)] - struct TrackingBuilder { - #[builder(default)] - pub ref_id: u32, - #[builder(default)] - pub ip_addr: ChronyAddr, - #[builder(default)] - pub stratum: u16, - #[builder(default)] - pub leap_status: u16, - #[builder(default = UNIX_EPOCH)] - pub ref_time: SystemTime, - #[builder(default)] - pub current_correction: ChronyFloat, - #[builder(default)] - pub last_offset: ChronyFloat, - #[builder(default)] - pub rms_offset: ChronyFloat, - #[builder(default)] - pub freq_ppm: ChronyFloat, - #[builder(default)] - pub resid_freq_ppm: ChronyFloat, - #[builder(default)] - pub skew_ppm: ChronyFloat, - #[builder(default)] - pub root_delay: ChronyFloat, - #[builder(default)] - pub root_dispersion: ChronyFloat, - #[builder(default)] - pub last_update_interval: ChronyFloat, - } - - impl Into for TrackingBuilder { - fn into(self) -> Tracking { - Tracking { - ref_id: self.ref_id, - ip_addr: self.ip_addr, - stratum: self.stratum, - leap_status: self.leap_status, - ref_time: self.ref_time, - current_correction: self.current_correction, - last_offset: self.last_offset, - rms_offset: self.rms_offset, - freq_ppm: self.freq_ppm, - resid_freq_ppm: self.resid_freq_ppm, - skew_ppm: self.skew_ppm, - root_delay: self.root_delay, - root_dispersion: self.root_dispersion, - last_update_interval: self.last_update_interval, - } - } - } - - #[rstest] - #[case::query_tracking_failed( - Err(anyhow::anyhow!("Some error")), - None, - 0, - Ok(0), - )] - #[case::get_phc_error_bound_failed( - Ok(TrackingBuilder::builder().ref_id(123).build().into()), - Some(MockPhcWithSysfsErrorBound::default()), - 123, - Err(anyhow::anyhow!("Some error")), - )] - #[case::get_chrony_clock_status_failed( - Ok( - // Invalid tracking should fail get_chrony_clock_status - TrackingBuilder::builder() - .ref_time(SystemTime::now() + Duration::from_secs(123)) - .build() - .into() - ), - None, - 0, - Ok(0), - )] - fn test_retrieve_clock_status_snapshot_failure( - #[case] tracking_return_val: anyhow::Result, - #[case] mut maybe_phc_error_bound_reader: Option, - #[case] phc_ref_id_return_val: u32, - #[case] phc_error_bound_return_val: anyhow::Result, - ) { - // We only ever expect the PHC error bound to be read if both are supplied - // and tracking ref ID == PHC ref ID - if let (Ok(tracking), Some(phc_error_bound_reader)) = - (&tracking_return_val, &mut maybe_phc_error_bound_reader) - { - let mut sequence = mockall::Sequence::new(); - phc_error_bound_reader - .expect_get_phc_ref_id() - .once() - .return_once(move || phc_ref_id_return_val) - .in_sequence(&mut sequence); - if phc_ref_id_return_val == tracking.ref_id { - phc_error_bound_reader - .expect_read_phc_error_bound() - .once() - .return_once(move || phc_error_bound_return_val) - .in_sequence(&mut sequence); - } - } - - let mut mock_chrony_client = Box::new(MockChronyClientExt::new()); - mock_chrony_client - .expect_query_tracking() - .once() - .return_once(|| tracking_return_val); - let poller = - ChronyDaemonSnapshotPoller::new(mock_chrony_client, maybe_phc_error_bound_reader); - let rt = poller.retrieve_clock_status_snapshot(TimeSpec::new(123, 456)); - assert!(rt.is_err()); - } - - #[rstest] - #[case::with_phc_ref_id_matching_tracking_ref_id( - Some(MockPhcWithSysfsErrorBound::default()), - TrackingBuilder::builder() - .ref_id(123) - .ref_time(SystemTime::now()) - .last_update_interval(1.0.into()) - .current_correction((-1.0).into()) - .root_dispersion(2.0.into()) - .root_delay(1.0.into()) - .build().into(), - 123, - 123456, - 3_500_123_456, - ChronyClockStatus::Synchronized, - )] - #[case::with_phc_info_not_matching_ref_id( - Some(MockPhcWithSysfsErrorBound::default()), - TrackingBuilder::builder() - .ref_id(123) - .ref_time(SystemTime::now()) - .last_update_interval(1.0.into()) - .current_correction((-1.0).into()) - .root_dispersion(2.0.into()) - .root_delay(1.0.into()) - .build().into(), - 234, - 123456, - 3_500_000_000, - ChronyClockStatus::Synchronized, - )] - #[case::with_no_phc_info( - None, - TrackingBuilder::builder() - .ref_id(123) - .ref_time(SystemTime::now()) - .last_update_interval(1.0.into()) - .current_correction((-1.0).into()) - .root_dispersion(2.0.into()) - .root_delay(1.0.into()) - .build().into(), - 123, - 123456, - 3_500_000_000, - ChronyClockStatus::Synchronized - )] - #[case::chrony_is_freerunning( - None, - TrackingBuilder::builder() - .leap_status(3) - .ref_id(123) - .ref_time(SystemTime::now()) - .last_update_interval(1.0.into()) - .current_correction((-1.0).into()) - .root_dispersion(2.0.into()) - .root_delay(1.0.into()) - .build().into(), - 234, - 123456, - 3_500_000_000, - ChronyClockStatus::FreeRunning, - )] - fn test_retrieve_clock_status_snapshot_success_synchronized( - #[case] mut maybe_phc_error_bound_reader: Option, - #[case] tracking_return_val: Tracking, - #[case] phc_ref_id_return_val: u32, - #[case] phc_error_bound_return_val: i64, - #[case] expected_bound_nsec: i64, - #[case] expected_chrony_clock_status: ChronyClockStatus, - ) { - if let Some(phc_error_bound_reader) = &mut maybe_phc_error_bound_reader { - let mut sequence = mockall::Sequence::new(); - phc_error_bound_reader - .expect_get_phc_ref_id() - .once() - .return_once(move || phc_ref_id_return_val) - .in_sequence(&mut sequence); - if phc_ref_id_return_val == tracking_return_val.ref_id { - phc_error_bound_reader - .expect_read_phc_error_bound() - .once() - .return_once(move || Ok(phc_error_bound_return_val)) - .in_sequence(&mut sequence); - } - } - let mut mock_chrony_client = Box::new(MockChronyClientExt::new()); - mock_chrony_client - .expect_query_tracking() - .once() - .return_once(move || Ok(tracking_return_val)); - let poller = - ChronyDaemonSnapshotPoller::new(mock_chrony_client, maybe_phc_error_bound_reader); - let rt = poller.retrieve_clock_status_snapshot(TimeSpec::new(123, 456)); - assert!(rt.is_ok()); - let rt = rt.unwrap(); - assert_eq!(rt.chrony_clock_status, expected_chrony_clock_status); - assert_eq!(rt.error_bound_nsec, expected_bound_nsec); - } - - /// Assert that clock error bound is calculated properly from current_correction, root_delay, root_dispersion - /// in both positive and negative current_correction cases. - #[test] - fn test_extract_error_bound_nsec_from_tracking() { - let mut tracking: Tracking = TrackingBuilder::builder() - .current_correction(1.0.into()) // -1 second offset, contributes 1 second to error bound - .root_delay(3.0.into()) // 3 second root delay (contributes 3 / 2 = 1.5 seconds to error bound) - .root_dispersion(2.0.into()) // 2 second root dispersion, contributes 2 seconds to error bound - .build() - .into(); - let error_bound_nsec = tracking.extract_error_bound_nsec(); - assert_eq!(error_bound_nsec, 4_500_000_000); - // validate negative case too, should still contribute 1 second to error bound - tracking.current_correction = (-1.0).into(); - let error_bound_nsec = tracking.extract_error_bound_nsec(); - assert_eq!(error_bound_nsec, 4_500_000_000); - } - - #[rstest] - #[case::synchronized_and_ref_time_within_8_polls( - TrackingBuilder::builder().last_update_interval(2.0.into()).leap_status(0).ref_time(SystemTime::now()).build().into(), - ChronyClockStatus::Synchronized - )] - #[case::synchronized_but_ref_time_more_than_8_polls_ago( - TrackingBuilder::builder().last_update_interval(2.0.into()).leap_status(0).ref_time(UNIX_EPOCH).build().into(), - ChronyClockStatus::FreeRunning - )] - #[case::leap_status_unsynchronized( - TrackingBuilder::builder().last_update_interval(2.0.into()).leap_status(3).ref_time(SystemTime::now()).build().into(), - ChronyClockStatus::FreeRunning - )] - #[case::leap_status_invalid( - TrackingBuilder::builder().last_update_interval(2.0.into()).leap_status(4).ref_time(SystemTime::now()).build().into(), - ChronyClockStatus::Unknown - )] - fn test_get_chrony_clock_status_success( - #[case] tracking: Tracking, - #[case] expected_chrony_clock_status: ChronyClockStatus, - ) { - let rt = tracking.get_chrony_clock_status(); - assert!(rt.is_ok()); - assert_eq!(rt.unwrap(), expected_chrony_clock_status); - } - - #[test] - fn test_get_chrony_clock_status_failure() { - // Set the time in the future, which causes us to fail to determine the current time. - let tracking: Tracking = TrackingBuilder::builder() - .ref_time(SystemTime::now() + Duration::from_secs(123)) - .build() - .into(); - let rt = tracking.get_chrony_clock_status(); - assert!(rt.is_err()); - } -} diff --git a/clock-bound-d/src/clock_state_fsm.rs b/clock-bound-d/src/clock_state_fsm.rs deleted file mode 100644 index f989074..0000000 --- a/clock-bound-d/src/clock_state_fsm.rs +++ /dev/null @@ -1,509 +0,0 @@ -//! Finite State Machine implementation of the clock status written to the SHM segment. -//! -//! The implementation leverages zero-sized types to represent the various states of the FSM. -//! Each state tracks the last clock status retrieved from chronyd as well as the last clock -//! disruption status. -//! The transitions between states are triggered by calling the `apply_chrony()` and -//! `apply_disruption()` to the current state. Pattern matching is used to make sure all -//! combinations of ChronyClockStatus and ClockDisruptionState are covered. - -use tracing::debug; - -use clock_bound_shm::ClockStatus; - -use crate::ChronyClockStatus; -use crate::ClockDisruptionState; - -/// Internal trait to model a FSM transition. -/// -/// This trait is a bound on FSMState, which is the public interface. This means this FSMTransition -/// trait has to be marked public too. -/// An alternative implementation would be to have `transition()` be part of FSMState. This would -/// expose `transition()` to the caller, as a function available on types implementing FSMState. -/// Having this internal trait let us write a blanket implementation of the FSMTransition trait. -pub trait FSMTransition { - /// The execution of the FSM is a transition from one state to another. - /// - /// Applying `transition()` on a state returns the next state. The FSM is a graph, and the - /// input to `transition()` conditions which state is returned. The current implementation - /// leverages marker types: every state is a different type. Hence the return type of - /// `transition()` is "something that implements FSMState". Because `transition()` may return - /// more than one type, the trait has to be Box'ed in. - /// - /// Note that `transition()` returns a (and not a FSMTransition trait!). This - /// hides the internal detail for the caller using this FSM> - fn transition( - &self, - chrony: ChronyClockStatus, - disruption: ClockDisruptionState, - ) -> Box; -} - -/// External trait to execute the FSM that drives the clock status value in the shared memory segment. -/// -/// Note that the FSMState trait is bound by the FSMTransition trait. This decoupling allow for a -/// blanket implementation of the trait for all the FSM states, while enforcing an implementation -/// pattern where the FSM logic is to be implemented in the FSMTransition trait. -pub trait FSMState: FSMTransition { - /// Apply a new chrony clock status to the FSM, possibly changing the current state. - fn apply_chrony(&self, update: ChronyClockStatus) -> Box; - - /// Apply a new clock disruption event to the FSM, possibly changing the current state. - fn apply_disruption(&self, update: ClockDisruptionState) -> Box; - - /// Return the value of the current FSM state, a clock status to write to the SHM segment. - fn value(&self) -> ClockStatus; -} - -/// Define the possible states of the FSM that drives the clock status written to the SHM segment. -/// -/// These zero-sized unit struct parameterize the more generic ShmClockState struct. -pub struct Unknown; -pub struct Synchronized; -pub struct FreeRunning; -pub struct Disrupted; - -/// The state the FSM is currently in. -/// -/// Note the default type parameter is `Unknown`, the expected initial state for the FSM. -pub struct ShmClockState { - // Marker type eliminated at compile time - _state: std::marker::PhantomData, - - // The status of the clock retrieved from chronyd that led to entering this state. - chrony: ChronyClockStatus, - - // The clock disruption event that led to entering this state. - disruption: ClockDisruptionState, - - // The value of the state, determined from the combination of chrony and disruption values. - clock_status: ClockStatus, -} - -/// Implement Default trait for ShmClockState. -/// -/// The type parameter is left out in this impl block, as it defaults to `Unknown` and hides the -/// internals of the FSM away for the caller, while guiding all instantiations to start in the -/// `Unknown` state. -impl Default for ShmClockState { - /// Create a new state, effectively a new FSM whose execution starts at `Unknown` - /// - // The FSM starts with no assumption on the state of the clock. - fn default() -> Self { - ShmClockState:: { - _state: std::marker::PhantomData::, - chrony: ChronyClockStatus::Unknown, - disruption: ClockDisruptionState::Unknown, - clock_status: ClockStatus::Unknown, - } - } -} - -/// Macro to generate generic impl block for the ShmClockState with corresponding type parameter. -/// -/// `new()` needs to store the specific clock_status on the new state, which we cannot easily use a -/// blanket implementation for. So this macro is the next best thing to avoid repetitive blocks of -/// code. Note that `new()` is kept private. `default()` should be the only mechanism for the -/// caller to instantiate a FSM. -macro_rules! shm_clock_state_impl { - ($state:ty, $state_clock:expr) => { - impl ShmClockState<$state> { - fn new(chrony: ChronyClockStatus, disruption: ClockDisruptionState) -> Self { - ShmClockState { - _state: std::marker::PhantomData::<$state>, - clock_status: $state_clock, - chrony, - disruption, - } - } - } - }; -} - -// Generate impl block for all ShmClockState -shm_clock_state_impl!(Unknown, ClockStatus::Unknown); -shm_clock_state_impl!(Synchronized, ClockStatus::Synchronized); -shm_clock_state_impl!(FreeRunning, ClockStatus::FreeRunning); -shm_clock_state_impl!(Disrupted, ClockStatus::Disrupted); - -/// Blanket implementation of external FSMState trait for all ShmClockState -impl FSMState for ShmClockState -where - ShmClockState: FSMTransition, -{ - /// Return the clock status for this FSM state. - fn value(&self) -> ClockStatus { - self.clock_status - } - - /// Apply a new chronyd ChronyClockStatus to the FSM - fn apply_chrony(&self, update: ChronyClockStatus) -> Box { - debug!("Before applying new ChronyClockStatus {:?}, self.chrony is: {:?}, self.disruption is: {:?}, self.value() is: {:?}", - update, self.chrony, self.disruption, self.value()); - let rv = self.transition(update, self.disruption); - debug!( - "After applying new ChronyClockStatus {:?}, rv.value() is: {:?}", - update, - rv.value() - ); - rv - } - - /// Apply a new ClockDisruptionState to the FSM - fn apply_disruption(&self, update: ClockDisruptionState) -> Box { - debug!("Before applying new ClockDisruptionState {:?}, self.chrony is: {:?}, self.disruption is: {:?}, self.value() is: {:?}", - update, self.chrony, self.disruption, self.value()); - let rv = self.transition(self.chrony, update); - debug!( - "After applying new ClockDisruptionState {:?}, rv.value() is: {:?}", - update, - rv.value() - ); - rv - } -} - -/// Macro to create a boxed ShmClockState from a type parameter, chrony and disruption status. -/// -/// This macro makes the implementation of `transition()` cases easier to read and reason -/// about. -macro_rules! bstate { - ($state:ty, $chrony:expr, $disruption:expr) => { - Box::new(ShmClockState::<$state>::new($chrony, $disruption)) - }; -} - -impl FSMTransition for ShmClockState { - /// Implement the transitions from the Unknown FSM state. - fn transition( - &self, - chrony: ChronyClockStatus, - disruption: ClockDisruptionState, - ) -> Box { - // Match on all parameters, the compiler will make sure no combination is missed. Some - // combinations are elided, remember the first matching arm wins. - match (chrony, disruption) { - (ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable) => { - bstate!(Synchronized, chrony, disruption) - } - (ChronyClockStatus::FreeRunning, ClockDisruptionState::Reliable) => { - bstate!(Unknown, chrony, disruption) - } - (_, ClockDisruptionState::Disrupted) => bstate!(Disrupted, chrony, disruption), - (ChronyClockStatus::Unknown, _) => bstate!(Unknown, chrony, disruption), - (_, ClockDisruptionState::Unknown) => bstate!(Unknown, chrony, disruption), - } - } -} - -impl FSMTransition for ShmClockState { - /// Implement the transitions from the Synchronized FSM state. - fn transition( - &self, - chrony: ChronyClockStatus, - disruption: ClockDisruptionState, - ) -> Box { - // Match on all parameters, the compiler will make sure no combination is missed. Some - // combinations are elided, remember the first matching arm wins. - match (chrony, disruption) { - (ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable) => { - bstate!(Synchronized, chrony, disruption) - } - (ChronyClockStatus::FreeRunning, ClockDisruptionState::Reliable) => { - bstate!(FreeRunning, chrony, disruption) - } - (_, ClockDisruptionState::Disrupted) => bstate!(Disrupted, chrony, disruption), - (ChronyClockStatus::Unknown, _) => bstate!(Unknown, chrony, disruption), - (_, ClockDisruptionState::Unknown) => bstate!(Unknown, chrony, disruption), - } - } -} - -impl FSMTransition for ShmClockState { - /// Implement the transitions from the FreeRunning FSM state. - fn transition( - &self, - chrony: ChronyClockStatus, - disruption: ClockDisruptionState, - ) -> Box { - // Match on all parameters, the compiler will make sure no combination is missed. Some - // combinations are elided, remember the first matching arm wins. - match (chrony, disruption) { - (ChronyClockStatus::Synchronized, ClockDisruptionState::Reliable) => { - bstate!(Synchronized, chrony, disruption) - } - (ChronyClockStatus::FreeRunning, ClockDisruptionState::Reliable) => { - bstate!(FreeRunning, chrony, disruption) - } - (_, ClockDisruptionState::Disrupted) => bstate!(Disrupted, chrony, disruption), - (ChronyClockStatus::Unknown, _) => bstate!(Unknown, chrony, disruption), - (_, ClockDisruptionState::Unknown) => bstate!(Unknown, chrony, disruption), - } - } -} - -impl FSMTransition for ShmClockState { - /// Implement the transitions from the Disrupted FSM state. - fn transition( - &self, - chrony: ChronyClockStatus, - disruption: ClockDisruptionState, - ) -> Box { - // Match on all parameters, the compiler will make sure no combination is missed. Some - // combinations are elided, remember the first matching arm wins. - match (chrony, disruption) { - (_, ClockDisruptionState::Disrupted) => bstate!(Disrupted, chrony, disruption), - (_, ClockDisruptionState::Unknown) => bstate!(Unknown, chrony, disruption), - (_, ClockDisruptionState::Reliable) => { - bstate!(Unknown, chrony, disruption) - } - } - } -} - -#[cfg(test)] -mod t_clock_state_fsm { - - use super::*; - - fn _helper_generate_chrony_status() -> Vec { - vec![ - ChronyClockStatus::Unknown, - ChronyClockStatus::Synchronized, - ChronyClockStatus::FreeRunning, - ] - } - - fn _helper_generate_disruption_status() -> Vec { - vec![ - ClockDisruptionState::Unknown, - ClockDisruptionState::Reliable, - ClockDisruptionState::Disrupted, - ] - } - - /// Assert that creating a FSM defaults to the Unknown state. - #[test] - fn test_entry_point_to_fsm() { - let state = ShmClockState::default(); - assert_eq!(state.value(), ClockStatus::Unknown); - } - - /// Assert the clock status value return by each state is correct. - #[test] - fn test_state_and_value() { - let state = bstate!( - Unknown, - ChronyClockStatus::Unknown, - ClockDisruptionState::Unknown - ); - assert_eq!(state.value(), ClockStatus::Unknown); - - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - assert_eq!(state.value(), ClockStatus::Synchronized); - - let state = bstate!( - FreeRunning, - ChronyClockStatus::FreeRunning, - ClockDisruptionState::Reliable - ); - assert_eq!(state.value(), ClockStatus::FreeRunning); - - let state = bstate!( - Disrupted, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Disrupted - ); - assert_eq!(state.value(), ClockStatus::Disrupted); - } - - /// Assert that unknown input from Unknown leads to the unknown state. - #[test] - fn test_transition_with_unknown_from_unknown() { - for status in _helper_generate_chrony_status() { - let state = bstate!( - Unknown, - ChronyClockStatus::Unknown, - ClockDisruptionState::Unknown - ); - let state = state.transition(status, ClockDisruptionState::Unknown); - assert_eq!(state.value(), ClockStatus::Unknown); - } - - for status in _helper_generate_disruption_status() { - let state = bstate!( - Unknown, - ChronyClockStatus::Unknown, - ClockDisruptionState::Unknown - ); - let state = state.transition(ChronyClockStatus::Unknown, status); - if status == ClockDisruptionState::Disrupted { - assert_eq!(state.value(), ClockStatus::Disrupted); - } else { - assert_eq!(state.value(), ClockStatus::Unknown); - } - } - } - - /// Assert that unknown input from Synchronized leads to the Unknown state. - #[test] - fn test_transition_with_unknown_from_synchronized() { - for status in _helper_generate_chrony_status() { - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - let state = state.transition(status, ClockDisruptionState::Unknown); - assert_eq!(state.value(), ClockStatus::Unknown); - } - - for status in _helper_generate_disruption_status() { - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - let state = state.transition(ChronyClockStatus::Unknown, status); - if status == ClockDisruptionState::Disrupted { - assert_eq!(state.value(), ClockStatus::Disrupted); - } else { - assert_eq!(state.value(), ClockStatus::Unknown); - } - } - } - - /// Assert that unknown input from FreeRunning leads to the Unknown state. - #[test] - fn test_transition_with_unknown_from_freerunning() { - for status in _helper_generate_chrony_status() { - let state = bstate!( - FreeRunning, - ChronyClockStatus::FreeRunning, - ClockDisruptionState::Reliable - ); - let state = state.transition(status, ClockDisruptionState::Unknown); - assert_eq!(state.value(), ClockStatus::Unknown); - } - - for status in _helper_generate_disruption_status() { - let state = bstate!( - FreeRunning, - ChronyClockStatus::FreeRunning, - ClockDisruptionState::Reliable - ); - let state = state.transition(ChronyClockStatus::Unknown, status); - if status == ClockDisruptionState::Disrupted { - assert_eq!(state.value(), ClockStatus::Disrupted); - } else { - assert_eq!(state.value(), ClockStatus::Unknown); - } - } - } - - /// Assert that unknown input from Disrupted does NOT transition to Unknown state, except if - /// the clock is reliable - #[test] - fn test_transition_with_unknown_from_disrupted() { - for status in _helper_generate_chrony_status() { - let state = bstate!( - Disrupted, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Disrupted - ); - let state = state.transition(status, ClockDisruptionState::Unknown); - assert_eq!(state.value(), ClockStatus::Unknown); - } - - for status in _helper_generate_disruption_status() { - let state = bstate!( - Disrupted, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Disrupted - ); - let state = state.transition(ChronyClockStatus::Unknown, status); - if status == ClockDisruptionState::Disrupted { - assert_eq!(state.value(), ClockStatus::Disrupted); - } else { - assert_eq!(state.value(), ClockStatus::Unknown); - } - } - } - - /// Assert that disrupted input always lead to the Disrupted state - #[test] - fn test_transition_into_disrupted() { - // Synchronized -> Disrupted - for status in _helper_generate_chrony_status() { - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - let state = state.transition(status, ClockDisruptionState::Disrupted); - assert_eq!(state.value(), ClockStatus::Disrupted); - } - - // FreeRunning -> Disrupted - for status in _helper_generate_chrony_status() { - let state = bstate!( - FreeRunning, - ChronyClockStatus::FreeRunning, - ClockDisruptionState::Reliable - ); - let state = state.transition(status, ClockDisruptionState::Disrupted); - assert_eq!(state.value(), ClockStatus::Disrupted); - } - - // Disrupted -> Disrupted - for status in _helper_generate_chrony_status() { - let state = bstate!( - Disrupted, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Disrupted - ); - let state = state.transition(status, ClockDisruptionState::Disrupted); - assert_eq!(state.value(), ClockStatus::Disrupted); - } - } - - /// Assert that disrupted state always leads to Unknown. - #[test] - fn test_transition_from_disrupted() { - for status in _helper_generate_chrony_status() { - let state = bstate!(Disrupted, status, ClockDisruptionState::Disrupted); - let state = state.transition(status, ClockDisruptionState::Reliable); - assert_eq!(state.value(), ClockStatus::Unknown); - } - } - - /// Assert that apply_chrony is functional. - #[test] - fn test_apply_chrony() { - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - - let state = state.apply_chrony(ChronyClockStatus::Unknown); - assert_eq!(state.value(), ClockStatus::Unknown); - } - - /// Assert that apply_disruption is functional. - #[test] - fn test_apply_disruption() { - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - - let state = state.apply_disruption(ClockDisruptionState::Unknown); - assert_eq!(state.value(), ClockStatus::Unknown); - } -} diff --git a/clock-bound-d/src/clock_state_fsm_no_disruption.rs b/clock-bound-d/src/clock_state_fsm_no_disruption.rs deleted file mode 100644 index 57c55dd..0000000 --- a/clock-bound-d/src/clock_state_fsm_no_disruption.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Finite State Machine implementation of the clock status written to the SHM segment when -//! clock disruption is NOT supported. -//! -//! This implementation is a trimmed down version of the `ShmClockState` that ignores all -//! clock disruption events. - -use clock_bound_shm::ClockStatus; - -use crate::clock_state_fsm::{FSMState, FSMTransition, FreeRunning, Synchronized, Unknown}; -use crate::ChronyClockStatus; -use crate::ClockDisruptionState; - -/// The state the FSM is currently in. -/// -/// Note the default type parameter is `Unknown`, the expected initial state for the FSM. -pub struct ShmClockStateNoDisruption { - // Marker type eliminated at compile time - _state: std::marker::PhantomData, - - // The status of the clock retrieved from chronyd that led to entering this state. - chrony: ChronyClockStatus, - - // The clock disruption event that led to entering this state. - disruption: ClockDisruptionState, - - // The value of the state, determined from the combination of chrony and disruption values. - clock_status: ClockStatus, -} - -/// Implement Default trait for ShmClockStateNoDisruption. -/// -/// The type parameter is left out in this impl block, as it defaults to `Unknown` and hides the -/// internals of the FSM away for the caller, while guiding all instantiations to start in the -/// `Unknown` state. -impl Default for ShmClockStateNoDisruption { - /// Create a new state, effectively a new FSM whose execution starts at `Unknown` - /// - // The FSM starts with no assumption on the state of the clock. - fn default() -> Self { - ShmClockStateNoDisruption:: { - _state: std::marker::PhantomData::, - chrony: ChronyClockStatus::Unknown, - disruption: ClockDisruptionState::Unknown, - clock_status: ClockStatus::Unknown, - } - } -} - -/// Macro to generate generic impl block for the ShmClockStateNoDisruption with corresponding type parameter. -/// -/// `new()` needs to store the specific clock_status on the new state, which we cannot easily use a -/// blanket implementation for. So this macro is the next best thing to avoid repetitive blocks of -/// code. Note that `new()` is kept private. `default()` should be the only mechanism for the -/// caller to instantiate a FSM. -macro_rules! shm_clock_state_no_lm_impl { - ($state:ty, $state_clock:expr) => { - impl ShmClockStateNoDisruption<$state> { - fn new(chrony: ChronyClockStatus, disruption: ClockDisruptionState) -> Self { - ShmClockStateNoDisruption { - _state: std::marker::PhantomData::<$state>, - clock_status: $state_clock, - chrony, - disruption, - } - } - } - }; -} - -// Generate impl block for all ShmClockStateNoDisruption -shm_clock_state_no_lm_impl!(Unknown, ClockStatus::Unknown); -shm_clock_state_no_lm_impl!(Synchronized, ClockStatus::Synchronized); -shm_clock_state_no_lm_impl!(FreeRunning, ClockStatus::FreeRunning); - -/// Blanket implementation of external FSMState trait for all ShmClockStateNoDisruption -impl FSMState for ShmClockStateNoDisruption -where - ShmClockStateNoDisruption: FSMTransition, -{ - /// Return the clock status for this FSM state. - fn value(&self) -> ClockStatus { - self.clock_status - } - - /// Apply a new chronyd ChronyClockStatus to the FSM - fn apply_chrony(&self, update: ChronyClockStatus) -> Box { - self.transition(update, self.disruption) - } - - /// Apply a new ClockDisruptionState to the FSM - fn apply_disruption(&self, update: ClockDisruptionState) -> Box { - self.transition(self.chrony, update) - } -} - -/// Macro to create a boxed ShmClockStateNoDisruption from a type parameter, chrony and disruption status. -/// -/// This macro makes the implementation of `transition()` cases easier to read and reason -/// about. -macro_rules! bstate { - ($state:ty, $chrony:expr, $disruption:expr) => { - Box::new(ShmClockStateNoDisruption::<$state>::new( - $chrony, - $disruption, - )) - }; -} - -impl FSMTransition for ShmClockStateNoDisruption { - /// Implement the transitions from the FSM state Unknown. - fn transition( - &self, - chrony: ChronyClockStatus, - _disruption: ClockDisruptionState, - ) -> Box { - match chrony { - ChronyClockStatus::Synchronized => { - bstate!(Synchronized, chrony, ClockDisruptionState::Reliable) - } - ChronyClockStatus::FreeRunning => { - bstate!(Unknown, chrony, ClockDisruptionState::Reliable) - } - ChronyClockStatus::Unknown => bstate!(Unknown, chrony, ClockDisruptionState::Reliable), - } - } -} - -impl FSMTransition for ShmClockStateNoDisruption { - /// Implement the transitions from the FSM state Synchronized. - fn transition( - &self, - chrony: ChronyClockStatus, - _disruption: ClockDisruptionState, - ) -> Box { - match chrony { - ChronyClockStatus::Synchronized => { - bstate!(Synchronized, chrony, ClockDisruptionState::Reliable) - } - ChronyClockStatus::FreeRunning => { - bstate!(FreeRunning, chrony, ClockDisruptionState::Reliable) - } - ChronyClockStatus::Unknown => bstate!(Unknown, chrony, ClockDisruptionState::Reliable), - } - } -} - -impl FSMTransition for ShmClockStateNoDisruption { - /// Implement the transitions from the FSM state FreeRunning. - fn transition( - &self, - chrony: ChronyClockStatus, - _disruption: ClockDisruptionState, - ) -> Box { - match chrony { - ChronyClockStatus::Synchronized => { - bstate!(Synchronized, chrony, ClockDisruptionState::Reliable) - } - ChronyClockStatus::FreeRunning => { - bstate!(FreeRunning, chrony, ClockDisruptionState::Reliable) - } - ChronyClockStatus::Unknown => bstate!(Unknown, chrony, ClockDisruptionState::Reliable), - } - } -} - -#[cfg(test)] -mod t_clock_state_fsm_no_lm { - - use super::*; - - fn _helper_generate_chrony_status() -> Vec<(ChronyClockStatus, ClockStatus)> { - vec![ - (ChronyClockStatus::Unknown, ClockStatus::Unknown), - (ChronyClockStatus::Synchronized, ClockStatus::Synchronized), - (ChronyClockStatus::FreeRunning, ClockStatus::FreeRunning), - ] - } - - fn _helper_generate_disruption_status() -> Vec { - vec![ - ClockDisruptionState::Unknown, - ClockDisruptionState::Reliable, - ClockDisruptionState::Disrupted, - ] - } - - /// Assert that creating a FSM defaults to the Unknown state. - #[test] - fn test_entry_point_to_fsm() { - let state = ShmClockStateNoDisruption::default(); - assert_eq!(state.value(), ClockStatus::Unknown); - } - - /// Assert the clock status value return by each state is correct. - #[test] - fn test_state_and_value() { - let state = bstate!( - Unknown, - ChronyClockStatus::Unknown, - ClockDisruptionState::Unknown - ); - assert_eq!(state.value(), ClockStatus::Unknown); - - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - assert_eq!(state.value(), ClockStatus::Synchronized); - - let state = bstate!( - FreeRunning, - ChronyClockStatus::FreeRunning, - ClockDisruptionState::Reliable - ); - assert_eq!(state.value(), ClockStatus::FreeRunning); - } - - /// Assert that chrony status drives the correct clock status from Unknown - #[test] - fn test_transition_chrony_from_unknown() { - for (chrony_status, clock_status) in _helper_generate_chrony_status() { - let state = bstate!( - Unknown, - ChronyClockStatus::Unknown, - ClockDisruptionState::Unknown - ); - let state = state.transition(chrony_status, ClockDisruptionState::Unknown); - if chrony_status == ChronyClockStatus::FreeRunning { - assert_eq!(state.value(), ClockStatus::Unknown); - } else { - assert_eq!(state.value(), clock_status); - } - } - } - - /// Assert that chrony status drives the correct clock status from Synchronized - #[test] - fn test_transition_chrony_from_synchronized() { - for (chrony_status, clock_status) in _helper_generate_chrony_status() { - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Unknown - ); - let state = state.transition(chrony_status, ClockDisruptionState::Unknown); - assert_eq!(state.value(), clock_status); - } - } - - /// Assert that chrony status drives the correct clock status from Free Running - #[test] - fn test_transition_chrony_from_free_running() { - for (chrony_status, clock_status) in _helper_generate_chrony_status() { - let state = bstate!( - FreeRunning, - ChronyClockStatus::FreeRunning, - ClockDisruptionState::Unknown - ); - let state = state.transition(chrony_status, ClockDisruptionState::Unknown); - assert_eq!(state.value(), clock_status); - } - } - - #[test] - fn test_transition_ignore_disruption_from_unknown() { - for status in _helper_generate_disruption_status() { - let state = bstate!( - Unknown, - ChronyClockStatus::Unknown, - ClockDisruptionState::Unknown - ); - let state = state.transition(ChronyClockStatus::Unknown, status); - assert_eq!(state.value(), ClockStatus::Unknown); - } - } - - /// Assert that unknown input from Synchronized leads to the Unknown state. - #[test] - fn test_transition_ignore_disruption_from_synchronized() { - for status in _helper_generate_disruption_status() { - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - let state = state.transition(ChronyClockStatus::Synchronized, status); - assert_eq!(state.value(), ClockStatus::Synchronized); - } - } - - /// Assert that unknown input from FreeRunning leads to the Unknown state. - #[test] - fn test_transition_ignore_disruption_freerunning() { - for status in _helper_generate_disruption_status() { - let state = bstate!( - FreeRunning, - ChronyClockStatus::FreeRunning, - ClockDisruptionState::Reliable - ); - let state = state.transition(ChronyClockStatus::FreeRunning, status); - assert_eq!(state.value(), ClockStatus::FreeRunning); - } - } - - /// Assert that apply_chrony is functional. - #[test] - fn test_apply_chrony() { - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - - let state = state.apply_chrony(ChronyClockStatus::Unknown); - assert_eq!(state.value(), ClockStatus::Unknown); - } - - /// Assert that apply_disruption is ignored - #[test] - fn test_apply_disruption() { - let state = bstate!( - Synchronized, - ChronyClockStatus::Synchronized, - ClockDisruptionState::Reliable - ); - - let state = state.apply_disruption(ClockDisruptionState::Unknown); - assert_eq!(state.value(), ClockStatus::Synchronized); - } -} diff --git a/clock-bound-d/src/lib.rs b/clock-bound-d/src/lib.rs deleted file mode 100644 index c0fc685..0000000 --- a/clock-bound-d/src/lib.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! ClockBound Daemon -//! -//! This crate implements the ClockBound daemon - -mod chrony_client; -mod clock_bound_runner; -mod clock_snapshot_poller; -mod clock_state_fsm; -mod clock_state_fsm_no_disruption; -mod phc_utils; -pub mod signal; - -use std::path::Path; -use std::str::FromStr; -use std::sync::atomic; - -#[cfg(any(test, feature = "test"))] -use crate::phc_utils::MockPhcWithSysfsErrorBound as PhcWithSysfsErrorBound; -#[cfg(not(any(test, feature = "test")))] -use crate::phc_utils::PhcWithSysfsErrorBound; -use clock_bound_shm::ShmWriter; -use clock_bound_vmclock::{shm::VMCLOCK_SHM_DEFAULT_PATH, shm_reader::VMClockShmReader}; -use chrony_client::UnixDomainSocket; -use clock_bound_runner::ClockBoundRunner; -use clock_snapshot_poller::chronyd_snapshot_poller::ChronyDaemonSnapshotPoller; -use tracing::{debug, error}; - -pub use phc_utils::get_error_bound_sysfs_path; - -// TODO: make this a parameter on the CLI? -pub const CLOCKBOUND_SHM_DEFAULT_PATH: &str = "/var/run/clockbound/shm0"; - -/// PhcInfo holds the refid of the PHC in chronyd (i.e. PHC0), and the -/// interface on which the PHC is enabled. -#[derive(Clone, PartialEq, Eq, Hash, Debug)] -pub struct PhcInfo { - pub refid: u32, - pub sysfs_error_bound_path: std::path::PathBuf, -} - -/// Boolean value that tracks whether a manually triggered disruption is pending and need to be -/// actioned. -pub static FORCE_DISRUPTION_PENDING: atomic::AtomicBool = atomic::AtomicBool::new(false); - -/// Boolean value that can be toggled to signal periods of forced disruption vs. "normal" periods. -pub static FORCE_DISRUPTION_STATE: atomic::AtomicBool = atomic::AtomicBool::new(false); - -/// The status of the system clock reported by chronyd -#[derive(Debug, Copy, Clone, PartialEq)] -pub enum ChronyClockStatus { - /// The status of the clock is unknown. - Unknown = 0, - - /// The clock is kept accurate by the synchronization daemon. - Synchronized = 1, - - /// The clock is free running and not updated by the synchronization daemon. - FreeRunning = 2, -} - -impl From for ChronyClockStatus { - // Chrony is signalling it is not synchronized by setting both bits in the Leap Indicator. - fn from(value: u16) -> Self { - match value { - 0..=2 => Self::Synchronized, - 3 => Self::FreeRunning, - _ => Self::Unknown, - } - } -} - -/// Enum of possible Clock Disruption States exposed by the daemon. -#[derive(Debug, Copy, Clone, PartialEq)] -pub enum ClockDisruptionState { - Unknown, - Reliable, - Disrupted, -} - -/// Custom struct used for indicating a parsing error when parsing a -/// ClockErrorBoundSource or ClockDisruptionNotificationSource -/// from str. -#[derive(Clone, PartialEq, Eq, Hash, Debug)] -pub struct ParseError; - -/// Enum of possible input sources for obtaining the ClockErrorBound. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub enum ClockErrorBoundSource { - /// Chrony. - Chrony, - - /// VMClock. - VMClock, -} - -/// Performs a case-insensitive conversion from str to enum ClockErrorBoundSource. -impl FromStr for ClockErrorBoundSource { - type Err = ParseError; - fn from_str(input: &str) -> Result { - match input.to_lowercase().as_str() { - "chrony" => Ok(ClockErrorBoundSource::Chrony), - "vmclock" => Ok(ClockErrorBoundSource::VMClock), - _ => { - error!("ClockErrorBoundSource '{:?}' is not supported", input); - Err(ParseError) - } - } - } -} - -/// Helper for converting a string ref_id into a u32 for the chrony command protocol. -/// -/// # Arguments -/// -/// * `ref_id` - The ref_id as a string to be translated to a u32. -pub fn refid_to_u32(ref_id: &str) -> Result { - let bytes = ref_id.bytes(); - if bytes.len() <= 4 && bytes.clone().all(|b| b.is_ascii()) { - let bytes_as_u32: Vec = bytes.map(|val| val as u32).collect(); - Ok(bytes_as_u32 - .iter() - .rev() - .enumerate() - .fold(0, |acc, (i, val)| acc | (val << (i * 8)))) - } else { - Err(String::from( - "The PHC reference ID supplied was not a 4 character ASCII string.", - )) - } -} - -pub fn run( - max_drift_ppb: u32, - maybe_phc_info: Option, - clock_error_bound_source: ClockErrorBoundSource, - clock_disruption_support_enabled: bool, -) { - // Create a writer to update the clock error bound shared memory segment - let mut writer = match ShmWriter::new(Path::new(CLOCKBOUND_SHM_DEFAULT_PATH)) { - Ok(writer) => { - debug!("Created a new ShmWriter"); - writer - } - Err(e) => { - error!( - "Failed to create the SHM writer at {:?} {}", - CLOCKBOUND_SHM_DEFAULT_PATH, e - ); - panic!("Failed to create SHM writer"); - } - }; - let clock_status_snapshot_poller = match clock_error_bound_source { - ClockErrorBoundSource::Chrony => ChronyDaemonSnapshotPoller::new( - Box::new(UnixDomainSocket::default()), - maybe_phc_info.map(|phc_info| { - PhcWithSysfsErrorBound::new(phc_info.sysfs_error_bound_path, phc_info.refid) - }), - ), - ClockErrorBoundSource::VMClock => { - unimplemented!("VMClock ClockErrorBoundSource is not yet implemented"); - } - }; - let mut vmclock_shm_reader = if !clock_disruption_support_enabled { - None - } else { - match VMClockShmReader::new(VMCLOCK_SHM_DEFAULT_PATH) { - Ok(reader) => Some(reader), - Err(e) => { - panic!( - "VMClockPoller: Failed to create VMClockShmReader. Please check if path {:?} exists and is readable. {:?}", - VMCLOCK_SHM_DEFAULT_PATH, e - ); - } - } - }; - - let mut clock_bound_runner = - ClockBoundRunner::new(clock_disruption_support_enabled, max_drift_ppb); - clock_bound_runner.run( - &mut vmclock_shm_reader, - &mut writer, - clock_status_snapshot_poller, - UnixDomainSocket::default(), - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_str_to_clockerrorboundsource_conversion() { - assert_eq!( - ClockErrorBoundSource::from_str("chrony"), - Ok(ClockErrorBoundSource::Chrony) - ); - assert_eq!( - ClockErrorBoundSource::from_str("Chrony"), - Ok(ClockErrorBoundSource::Chrony) - ); - assert_eq!( - ClockErrorBoundSource::from_str("CHRONY"), - Ok(ClockErrorBoundSource::Chrony) - ); - assert_eq!( - ClockErrorBoundSource::from_str("cHrOnY"), - Ok(ClockErrorBoundSource::Chrony) - ); - assert_eq!( - ClockErrorBoundSource::from_str("vmclock"), - Ok(ClockErrorBoundSource::VMClock) - ); - assert_eq!( - ClockErrorBoundSource::from_str("VMClock"), - Ok(ClockErrorBoundSource::VMClock) - ); - assert_eq!( - ClockErrorBoundSource::from_str("VMCLOCK"), - Ok(ClockErrorBoundSource::VMClock) - ); - assert_eq!( - ClockErrorBoundSource::from_str("vmClock"), - Ok(ClockErrorBoundSource::VMClock) - ); - assert!(ClockErrorBoundSource::from_str("other").is_err()); - assert!(ClockErrorBoundSource::from_str("None").is_err()); - assert!(ClockErrorBoundSource::from_str("null").is_err()); - assert!(ClockErrorBoundSource::from_str("").is_err()); - } - - #[test] - fn test_refid_to_u32() { - // Test error cases - assert!(refid_to_u32("morethan4characters").is_err()); - let non_valid_ascii_str = "©"; - assert!(non_valid_ascii_str.len() <= 4); - assert!(refid_to_u32(non_valid_ascii_str).is_err()); - - // Test actual parsing is as expected - // ASCII values: P = 80, H = 72, C = 67, 0 = 48 - assert_eq!( - refid_to_u32("PHC0").unwrap(), - 80 << 24 | 72 << 16 | 67 << 8 | 48 - ); - assert_eq!(refid_to_u32("PHC").unwrap(), 80 << 16 | 72 << 8 | 67); - assert_eq!(refid_to_u32("PH").unwrap(), 80 << 8 | 72); - assert_eq!(refid_to_u32("P").unwrap(), 80); - } -} diff --git a/clock-bound-d/src/main.rs b/clock-bound-d/src/main.rs deleted file mode 100644 index b514ef3..0000000 --- a/clock-bound-d/src/main.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! ClockBound Daemon -//! -//! This crate implements the ClockBound daemon - -use std::str::FromStr; -use std::sync::atomic::Ordering; - -use clap::Parser; -use tracing::{error, info, warn, Level}; - -use clock_bound_d::run; -use clock_bound_d::signal::register_signal_callback; -use clock_bound_d::{ - get_error_bound_sysfs_path, refid_to_u32, ClockErrorBoundSource, PhcInfo, - FORCE_DISRUPTION_PENDING, FORCE_DISRUPTION_STATE, -}; - -// XXX: A default value of 1ppm is VERY wrong for common XO specs these days. -// Sadly we have to align default value with chrony. -pub const DEFAULT_MAX_DRIFT_RATE_PPB: u32 = 1000; - -#[derive(Parser, Debug)] -#[command(author, name = "clockbound", version, about, long_about = None)] -struct Cli { - /// Set the maximum drift rate of the underlying oscillator in ppm (default 1ppm). - /// Chrony `maxclockerror` configuration should be set to match this value. - #[arg(short, long)] - max_drift_rate: Option, - - /// Emit structured log messages. Default to human readable. - #[arg(short, long)] - json_output: bool, - - /// Run without support for clock disruptions. Default to false. - #[arg(short, long)] - disable_clock_disruption_support: bool, - - /// The PHC reference ID from Chronyd (generally, this is PHC0). - /// Required for configuring ClockBound to sync to PHC. - #[arg(short = 'r', long, requires = "phc_interface", value_parser = refid_to_u32)] - phc_ref_id: Option, - - /// The network interface that the ENA driver PHC exists on (e.g. eth0). - /// Required for configuring ClockBound to sync to PHC. - #[arg(short = 'i', long, requires = "phc_ref_id")] - phc_interface: Option, - - /// Clock Error Bound source. - /// - /// Valid values are: 'chrony', 'vmclock'. - /// - /// Selecting `vmclock` will cause us to use the Hypervisor-provided device node - /// for determining the Clock Error Bound. - /// - /// By default, if this argument is not provided, then - /// Clockbound daemon will default to using Chrony. - #[arg(long)] - clock_error_bound_source: Option, -} - -/// SIGUSR1 signal handler to force a clock disruption event. -/// This handler is primarily here for testing the clock disruption functionality in isolation. -fn on_sigusr1() { - let state = FORCE_DISRUPTION_STATE.load(Ordering::SeqCst); - if !state { - info!("Received SIGUSR1 signal. Setting forced clock disruption to true."); - FORCE_DISRUPTION_STATE.store(true, Ordering::SeqCst); - FORCE_DISRUPTION_PENDING.store(true, Ordering::SeqCst); - } else { - info!("Received SIGUSR1 signal. Forced clock disruption is already true."); - } -} - -/// SIGUSR1 signal handler when clock disruption support is disabled. -fn on_sigusr1_ignored() { - warn!("Ignoring received SIGUSR1 signal."); -} - -/// SIGUSR2 signal handler to undo a force clock disruption event. -/// This handler is primarily here for testing the clock disruption functionality in isolation. -fn on_sigusr2() { - let state = FORCE_DISRUPTION_STATE.load(Ordering::SeqCst); - if state { - info!("Received SIGUSR2 signal. Setting forced clock disruption to false."); - FORCE_DISRUPTION_STATE.store(false, Ordering::SeqCst); - FORCE_DISRUPTION_PENDING.store(true, Ordering::SeqCst); - } else { - info!("Received SIGUSR2 signal. Forced clock disruption is already false."); - } -} - -/// SIGUSR2 signal handler when clock disruption support is disabled. -fn on_sigusr2_ignored() { - warn!("Ignoring received SIGUSR2 signal."); -} - -// ClockBound application entry point. -fn main() -> anyhow::Result<()> { - let args = Cli::parse(); - - // Configure the fields emitted in log messages - let format = tracing_subscriber::fmt::format() - .with_level(true) - .with_target(false) - .with_thread_ids(true) - .with_thread_names(true) - .with_file(true) - .with_line_number(true); - - // Create a `fmt` subscriber that uses the event format. - // Enable all levels up to DEBUG here, but remember that the crate is configured to strip out - // DEBUG level for release builds. The builder also provide the option to emit human readable - // or JSON structured logs. - let builder = tracing_subscriber::fmt().with_max_level(Level::DEBUG); - - if args.json_output { - builder - .event_format(format.json().flatten_event(true)) - .init(); - } else { - builder.event_format(format).init(); - }; - - // Log a message confirming the daemon is starting. Always useful if in a reboot loop. - info!("ClockBound daemon is starting"); - - // Register callbacks on UNIX signals - let sigusr1_callback = if args.disable_clock_disruption_support { - on_sigusr1_ignored - } else { - on_sigusr1 - }; - let sigusr2_callback = if args.disable_clock_disruption_support { - on_sigusr2_ignored - } else { - on_sigusr2 - }; - if let Err(e) = register_signal_callback(nix::sys::signal::SIGUSR1, sigusr1_callback) { - error!("Failed to register callback on SIGUSR1 signal [{:?}]", e); - return Err(e.into()); - } - if let Err(e) = register_signal_callback(nix::sys::signal::SIGUSR2, sigusr2_callback) { - error!("Failed to register callback on SIGUSR2 signal [{:?}]", e); - return Err(e.into()); - } - - // TODO: should introduce a config object to gather options on the CLI etc. - let max_drift_ppb = match args.max_drift_rate { - Some(rate) => rate * 1000, - None => { - warn!("Using the default max drift rate of 1PPM, which is likely wrong. \ - Update chrony configuration and clockbound to a value that matches your hardware."); - DEFAULT_MAX_DRIFT_RATE_PPB - } - }; - - let phc_info = match (args.phc_interface, args.phc_ref_id) { - (Some(interface), Some(refid)) => { - let sysfs_error_bound_path = get_error_bound_sysfs_path(&interface)?; - Some(PhcInfo { - refid, - sysfs_error_bound_path, - }) - } - _ => None, - }; - - let clock_error_bound_source: ClockErrorBoundSource = match args.clock_error_bound_source { - Some(source_str) => match ClockErrorBoundSource::from_str(&source_str) { - Ok(v) => v, - Err(_) => { - let err_msg = format!("Unsupported ClockErrorBoundSource: {:?}", source_str); - error!(err_msg); - anyhow::bail!(err_msg); - } - }, - None => ClockErrorBoundSource::Chrony, - }; - info!("ClockErrorBoundSource: {:?}", clock_error_bound_source); - - if args.disable_clock_disruption_support { - warn!("Support for clock disruption is explicitly disabled"); - } - - run( - max_drift_ppb, - phc_info, - clock_error_bound_source, - !args.disable_clock_disruption_support, - ); - Ok(()) -} diff --git a/clock-bound-d/src/phc_utils.rs b/clock-bound-d/src/phc_utils.rs deleted file mode 100644 index fe4713e..0000000 --- a/clock-bound-d/src/phc_utils.rs +++ /dev/null @@ -1,189 +0,0 @@ -#[cfg_attr(any(test, feature = "test"), mockall::automock)] -mod get_pci_slot { - /// Gets the PCI slot name for a given network interface name. - /// - /// # Arguments - /// - /// * `uevent_file_path` - The path of the uevent file where we lookup the PCI_SLOT_NAME. - pub(crate) fn get_pci_slot_name(uevent_file_path: &str) -> anyhow::Result { - let contents = std::fs::read_to_string(uevent_file_path).map_err(|e| { - anyhow::anyhow!( - "Failed to open uevent file {:?} for PHC network interface specified: {}", - uevent_file_path, - e - ) - })?; - - Ok(contents - .lines() - .find_map(|line| line.strip_prefix("PCI_SLOT_NAME=")) - .ok_or(anyhow::anyhow!( - "Failed to find PCI_SLOT_NAME at uevent file path {:?}", - uevent_file_path - ))? - .to_string()) - } -} - -#[cfg(not(any(test, feature = "test")))] -pub(crate) use get_pci_slot::get_pci_slot_name; -#[cfg(any(test, feature = "test"))] -pub(crate) use mock_get_pci_slot::get_pci_slot_name; - -/// Gets the PHC Error Bound sysfs file path given a network interface name. -/// -/// # Arguments -/// -/// * `interface` - The network interface to lookup the PHC error bound path for. -pub fn get_error_bound_sysfs_path(interface: &str) -> anyhow::Result { - let uevent_file_path = format!("/sys/class/net/{}/device/uevent", interface); - let pci_slot_name = get_pci_slot_name(&uevent_file_path)?; - Ok(std::path::PathBuf::from(format!( - "/sys/bus/pci/devices/{}/phc_error_bound", - pci_slot_name - ))) -} - -pub struct PhcWithSysfsErrorBound { - sysfs_phc_error_bound_path: std::path::PathBuf, - phc_ref_id: u32, -} - -#[cfg_attr(any(test, feature = "test"), mockall::automock)] -impl PhcWithSysfsErrorBound { - pub(crate) fn new(phc_error_bound_path: std::path::PathBuf, phc_ref_id: u32) -> Self { - Self { - sysfs_phc_error_bound_path: phc_error_bound_path, - phc_ref_id, - } - } - - pub(crate) fn read_phc_error_bound(&self) -> anyhow::Result { - std::fs::read_to_string(&self.sysfs_phc_error_bound_path)? - .trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Failed to parse PHC error bound value to i64: {}", e)) - } - - pub(crate) fn get_phc_ref_id(&self) -> u32 { - self.phc_ref_id - } -} - -#[cfg(test)] -mod test { - use rstest::rstest; - use tempfile::NamedTempFile; - - use super::*; - - use std::io::Write; - - #[rstest] - #[case::happy_path("PCI_SLOT_NAME=12345", "12345")] - #[case::happy_path_multi_line( - " -oneline -PCI_SLOT_NAME=23456 -twoline", - "23456" - )] - fn test_get_pci_slot_name_success( - #[case] file_contents_to_write: &str, - #[case] return_value: &str, - ) { - let mut test_uevent_file = NamedTempFile::new().expect("create mock uevent file failed"); - test_uevent_file - .write_all(file_contents_to_write.as_bytes()) - .expect("write to mock uevent file failed"); - - let rt = get_pci_slot::get_pci_slot_name(test_uevent_file.path().to_str().unwrap()); - assert!(rt.is_ok()); - assert_eq!(rt.unwrap(), return_value.to_string()); - } - - #[rstest] - #[case::missing_pci_slot_name("no pci slot name")] - fn test_get_pci_slot_name_failure(#[case] file_contents_to_write: &str) { - let mut test_uevent_file = NamedTempFile::new().expect("create mock uevent file failed"); - test_uevent_file - .write_all(file_contents_to_write.as_bytes()) - .expect("write to mock uevent file failed"); - - let rt = get_pci_slot::get_pci_slot_name(test_uevent_file.path().to_str().unwrap()); - assert!(rt.is_err()); - assert!(rt - .unwrap_err() - .to_string() - .contains("Failed to find PCI_SLOT_NAME at uevent file path")); - } - - #[test] - fn test_get_pci_slot_name_file_does_not_exist() { - let rt = get_pci_slot::get_pci_slot_name("/does/not/exist"); - assert!(rt.is_err()); - } - - #[rstest] - #[case::happy_path("12345", 12345)] - fn test_read_phc_error_bound_success( - #[case] file_contents_to_write: &str, - #[case] return_value: i64, - ) { - let mut test_phc_error_bound_file = - NamedTempFile::new().expect("create mock phc error bound file failed"); - test_phc_error_bound_file - .write_all(file_contents_to_write.as_bytes()) - .expect("write to mock phc error bound file failed"); - - let phc_error_bound_reader = - PhcWithSysfsErrorBound::new(test_phc_error_bound_file.path().to_path_buf(), 0); - let rt = phc_error_bound_reader.read_phc_error_bound(); - assert!(rt.is_ok()); - assert_eq!(rt.unwrap(), return_value); - } - - #[rstest] - #[case::parsing_fail("asdf_not_an_i64")] - fn test_read_phc_error_bound_bad_file_contents(#[case] file_contents_to_write: &str) { - let mut test_phc_error_bound_file = - NamedTempFile::new().expect("create mock phc error bound file failed"); - test_phc_error_bound_file - .write_all(file_contents_to_write.as_bytes()) - .expect("write to mock phc error bound file failed"); - - let phc_error_bound_reader = - PhcWithSysfsErrorBound::new(test_phc_error_bound_file.path().to_path_buf(), 0); - let rt = phc_error_bound_reader.read_phc_error_bound(); - assert!(rt.is_err()); - assert!(rt - .unwrap_err() - .to_string() - .contains("Failed to parse PHC error bound value to i64")); - } - - #[test] - fn test_read_phc_error_bound_file_does_not_exist() { - let phc_error_bound_reader = PhcWithSysfsErrorBound::new("/does/not/exist".into(), 0); - let rt = phc_error_bound_reader.read_phc_error_bound(); - assert!(rt.is_err()); - } - - #[test] - fn test_get_phc_ref_id() { - let phc_error_bound_reader = PhcWithSysfsErrorBound::new("/does/not/matter".into(), 12345); - assert_eq!(phc_error_bound_reader.get_phc_ref_id(), 12345); - } - - #[test] - fn test_get_error_bound_sysfs_path() { - let ctx = mock_get_pci_slot::get_pci_slot_name_context(); - ctx.expect().returning(|_| Ok("12345".to_string())); - let rt = get_error_bound_sysfs_path("arbitrary_interface"); - assert!(rt.is_ok()); - assert_eq!( - rt.unwrap().to_str().unwrap(), - "/sys/bus/pci/devices/12345/phc_error_bound" - ); - } -} diff --git a/clock-bound-d/src/signal.rs b/clock-bound-d/src/signal.rs deleted file mode 100644 index 153e8aa..0000000 --- a/clock-bound-d/src/signal.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! Unix signal handler registration. -//! -//! Use the nix crate to register signal callbacks, while keeping any specific notion of libc -//! within this module only. The callbacks are registered into a HashMap and looked up when a -//! signal is received. - -use lazy_static::lazy_static; -use libc; -use nix::sys::signal; -use std::collections::HashMap; -use std::io::Result; -use std::sync::Mutex; -use tracing::{error, info}; - -/// Defines the types of callback that can be registered with the signal handler -type Callback = fn() -> (); - -/// Tiny structure to maintain the association of callbacks registered with signals. -/// -/// The internal representation is a hashmap of signal number and callbacks. -struct SignalHandler { - handlers: HashMap, -} - -impl SignalHandler { - /// A new empty SignalHandler structure. - fn new() -> SignalHandler { - SignalHandler { - handlers: HashMap::new(), - } - } - - /// Get the callback associated with a signal number. - /// - /// Returns the callback wrapped in an Option. Returns None if no callback has been registered - /// with the given signal. - fn get_callback(&self, sig: signal::Signal) -> Option<&Callback> { - self.handlers.get(&sig) - } - - /// Set / Overwrite callback for a given signal - /// - /// Silently ignore the return value of inserting a new callback over an existing one in the - /// HashMap. Last callback registered wins. - fn add_callback(&mut self, sig: signal::Signal, callback: Callback) { - self.handlers.insert(sig, callback); - } -} - -lazy_static! { - /// Global SignalHandler structure, instantiated on first access. - /// - /// Signal handlers have a predefined signature, easier to provide a static variable to lookup the - /// callbacks to run. - static ref SIGNAL_HANDLERS: Mutex = Mutex::new(SignalHandler::new()); -} - -/// Main signal handler function. -/// -/// This function is the one and unique signal handler, looking up and running registered callbacks. -/// This level of indirection helps hide libc specific details away. Potential drawback is that -/// assessing complexity of the callabck is less obvious. -extern "C" fn main_signal_handler(signum: libc::c_int) { - // Although unlikely, there is always the risk the registration function holds the lock while - // the main thread is interrupted by a signal. Do not want to deadlock in interrupted context. - // Try the lock, and bail out if it cannot be acquired. - let handlers = match SIGNAL_HANDLERS.try_lock() { - Ok(handlers) => handlers, - Err(_) => return, // TODO: log an error? - }; - - if let Ok(sig) = signal::Signal::try_from(signum) { - if let Some(cb) = handlers.get_callback(sig) { - cb() - } - } -} - -/// Enable UNIX signal via sigaction. -/// -/// Gathers all libc crate and C types unsafe code here. -fn enable_signal(sig: signal::Signal) -> Result<()> { - // Always register the main signal handler - let handler = signal::SigHandler::Handler(main_signal_handler); - let mask = signal::SigSet::empty(); - let mut flags = signal::SaFlags::empty(); - flags.insert(signal::SaFlags::SA_RESTART); - flags.insert(signal::SaFlags::SA_SIGINFO); - flags.insert(signal::SaFlags::SA_NOCLDSTOP); - - let sig_action = signal::SigAction::new(handler, flags, mask); - - let result = unsafe { signal::sigaction(sig, &sig_action) }; - - match result { - Ok(_) => Ok(()), - Err(_) => Err(std::io::Error::last_os_error()), - } -} - -/// Enable signal and register associated callback. -/// -/// Signal handling is done through indirection, hidden from the caller. The master signal handler -/// is always registered to handle the signal. It is then charged with looking up and running the -/// callback provided. -/// -/// Should be called on the main thread. -/// -/// # Examples -/// -/// ```rust -/// use nix::sys::signal; -/// use clock_bound_d::signal::register_signal_callback; -/// -/// fn on_sighup() { -/// println!("Got HUP'ed!!"); -/// } -/// -/// register_signal_callback(signal::SIGHUP, on_sighup); -/// -/// ``` -pub fn register_signal_callback(sig: signal::Signal, callback: Callback) -> Result<()> { - // All signals are managed and handled on the main thread. It is safe to lock the mutex and - // block until acquired. The signal handler may hold the Mutex lock, but releases it once - // signal handling and main execution resumes. - let mut handlers = SIGNAL_HANDLERS.lock().unwrap(); - handlers.add_callback(sig, callback); - - // The new callback is registered, the signal can be handled - match enable_signal(sig) { - Ok(_) => { - info!("Registered callback for signal {}", sig); - Ok(()) - } - Err(e) => { - error!("Failed to register callback for signal {}: {}", sig, e); - Err(e) - } - } -} - -#[cfg(test)] -mod t_signal { - - use super::*; - - /// Assert that a callaback can be registered and retrieved with the same signal. - #[test] - fn test_add_and_get_callback() { - // Testing side effects is inherently unsafe - static mut VAL: i32 = 0; - unsafe { - let mut handlers = SignalHandler::new(); - VAL = 2; - fn do_double() { - unsafe { VAL *= 2 } - } - handlers.add_callback(signal::SIGHUP, do_double); - let cb = handlers.get_callback(signal::SIGHUP).unwrap(); - cb(); - assert_eq!(4, VAL); - } - } - - /// Assert that the last callback registered is retrieved and triggered upon multiple - /// registrations. - #[test] - fn test_last_callback_wins() { - // Testing side effects is inherently unsafe - static mut VAL: i32 = 2; - unsafe { - let mut handlers = SignalHandler::new(); - //VAL = 2; - fn do_double() { - unsafe { VAL *= 2 } - } - fn do_triple() { - unsafe { VAL *= 3 } - } - fn do_quadruple() { - unsafe { VAL *= 4 } - } - handlers.add_callback(signal::SIGHUP, do_double); - handlers.add_callback(signal::SIGHUP, do_triple); - handlers.add_callback(signal::SIGHUP, do_quadruple); - let cb = handlers.get_callback(signal::SIGHUP).unwrap(); - cb(); - assert_eq!(8, VAL); - } - } - - /// Assert that None is returned if no callback is registered for the signal. - #[test] - fn test_get_none_on_missing_callbacks() { - let mut handlers = SignalHandler::new(); - fn do_nothing() {} - handlers.add_callback(signal::SIGHUP, do_nothing); - let cb = handlers.get_callback(signal::SIGINT); - assert_eq!(None, cb); - } -} diff --git a/clock-bound-ffi/Cargo.toml b/clock-bound-ffi/Cargo.toml index 12443a9..fe67a42 100644 --- a/clock-bound-ffi/Cargo.toml +++ b/clock-bound-ffi/Cargo.toml @@ -18,8 +18,7 @@ crate-type = ["cdylib", "staticlib"] name = "clockbound" [dependencies] -clock-bound-shm = { version = "2.0", path = "../clock-bound-shm" } -clock-bound-vmclock = { version = "2.0", path = "../clock-bound-vmclock" } +clock-bound = { version = "2.0", path = "../clock-bound" } errno = { version = "0.3.0", default-features = false } libc = { version = "0.2", default-features = false } nix = { version = "0.26", features = ["feature", "time"] } diff --git a/clock-bound-ffi/src/lib.rs b/clock-bound-ffi/src/lib.rs index 24333e4..081cdd4 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -5,9 +5,9 @@ // Align with C naming conventions #![allow(non_camel_case_types)] -use clock_bound_shm::{ClockStatus, ShmError, ShmReader}; -use clock_bound_vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; -use clock_bound_vmclock::VMClock; +use clock_bound::shm::{ClockStatus, ShmError, ShmReader}; +use clock_bound::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; +use clock_bound::vmclock::VMClock; use core::ptr; use nix::sys::time::TimeSpec; use std::ffi::{c_char, CStr}; @@ -150,12 +150,15 @@ pub struct clockbound_now_result { /// # Safety /// Rely on the caller to pass valid pointers. /// -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_open( clockbound_shm_path: *const c_char, err: *mut clockbound_err, ) -> *mut clockbound_ctx { - let clockbound_shm_path_cstr = CStr::from_ptr(clockbound_shm_path); + // Safety: Rely on caller to pass valid pointers + let clockbound_shm_path_cstr = unsafe { + CStr::from_ptr(clockbound_shm_path) + }; let clockbound_shm_path = clockbound_shm_path_cstr .to_str() .expect("Failed to convert ClockBound shared memory path to str"); @@ -165,7 +168,8 @@ pub unsafe extern "C" fn clockbound_open( Ok(vmclock) => vmclock, Err(e) => { if !err.is_null() { - err.write(e.into()) + // Safety: rely on caller to pass valid pointers + unsafe { err.write(e.into()) } } return ptr::null_mut(); } @@ -192,17 +196,21 @@ pub unsafe extern "C" fn clockbound_open( /// /// # Safety /// Rely on the caller to pass valid pointers. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_vmclock_open( clockbound_shm_path: *const c_char, vmclock_shm_path: *const c_char, err: *mut clockbound_err, ) -> *mut clockbound_ctx { - let clockbound_shm_path_cstr = CStr::from_ptr(clockbound_shm_path); + // Safety: Rely on caller to pass valid pointers + let clockbound_shm_path_cstr = unsafe { + CStr::from_ptr(clockbound_shm_path) + }; let clockbound_shm_path = clockbound_shm_path_cstr .to_str() .expect("Failed to convert ClockBound shared memory path to str"); - let vmclock_shm_path_cstr = CStr::from_ptr(vmclock_shm_path); + // Safety: Rely on caller to pass valid pointers + let vmclock_shm_path_cstr = unsafe {CStr::from_ptr(vmclock_shm_path) }; let vmclock_shm_path = vmclock_shm_path_cstr .to_str() .expect("Failed to convert VMClock shared memory path to str"); @@ -211,7 +219,8 @@ pub unsafe extern "C" fn clockbound_vmclock_open( Ok(vmclock) => vmclock, Err(e) => { if !err.is_null() { - err.write(e.into()) + // Safety: Rely on caller to pass valid pointers + unsafe { err.write(e.into()) } } return ptr::null_mut(); } @@ -237,9 +246,10 @@ pub unsafe extern "C" fn clockbound_vmclock_open( /// # Safety /// /// Rely on the caller to pass valid pointers. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_close(ctx: *mut clockbound_ctx) -> *const clockbound_err { - std::mem::drop(Box::from_raw(ctx)); + // Safety: Rely on caller to pass valid pointers + std::mem::drop(unsafe { Box::from_raw(ctx) }); ptr::null() } @@ -253,12 +263,13 @@ pub unsafe extern "C" fn clockbound_close(ctx: *mut clockbound_ctx) -> *const cl /// /// Have no choice but rely on the caller to pass valid pointers. #[inline] -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_now( ctx: *mut clockbound_ctx, output: *mut clockbound_now_result, ) -> *const clockbound_err { - let ctx = &mut *ctx; + // Safety: Rely on caller to pass valid pointers + let ctx = unsafe { &mut *ctx }; let (earliest, latest, clock_status) = match ctx.now() { Ok(now) => now, @@ -267,19 +278,21 @@ pub unsafe extern "C" fn clockbound_now( return &ctx.err; } }; - - output.write(clockbound_now_result { - earliest: *earliest.as_ref(), - latest: *latest.as_ref(), - clock_status: clock_status.into(), - }); + // Safety: Rely on caller to pass valid pointers + unsafe { + output.write(clockbound_now_result { + earliest: *earliest.as_ref(), + latest: *latest.as_ref(), + clock_status: clock_status.into(), + }); + } ptr::null() } #[cfg(test)] mod t_ffi { use super::*; - use clock_bound_shm::ClockErrorBound; + use clock_bound::shm::ClockErrorBound; use byteorder::{LittleEndian, NativeEndian, WriteBytesExt}; use std::ffi::CString; use std::fs::OpenOptions; diff --git a/clock-bound-shm/Cargo.toml b/clock-bound-shm/Cargo.toml deleted file mode 100644 index aab0f0f..0000000 --- a/clock-bound-shm/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "clock-bound-shm" -description = "A library used to interact with shared memory in ClockBound." -license = "Apache-2.0" - -authors.workspace = true -categories.workspace = true -edition.workspace = true -exclude.workspace = true -keywords.workspace = true -publish.workspace = true -repository.workspace = true -version.workspace = true - -[features] -writer = [] - -[dependencies] -byteorder = "1" -errno = { version = "0.3.0", default-features = false } -libc = { version = "0.2", default-features = false, features = ["extra_traits"] } -nix = { version = "0.26", features = ["feature", "time"] } - -[dev-dependencies] -tempfile = { version = "3.13" } diff --git a/clock-bound-shm/LICENSE b/clock-bound-shm/LICENSE deleted file mode 100644 index 7a4a3ea..0000000 --- a/clock-bound-shm/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - 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 - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file diff --git a/clock-bound-shm/NOTICE b/clock-bound-shm/NOTICE deleted file mode 100644 index 644402f..0000000 --- a/clock-bound-shm/NOTICE +++ /dev/null @@ -1,2 +0,0 @@ -clock-bound-shm -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/clock-bound-shm/README.md b/clock-bound-shm/README.md deleted file mode 100644 index c511606..0000000 --- a/clock-bound-shm/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# ClockBound Shared Memory - -## Overview - -This crate implements the low-level IPC functionality to share ClockErrorBound data and clock status over a shared memory segment. It provides a reader and writer implementation to facilitate operating on the shared memory segment. - -## Clock status - -Clock status are retrieved directly from `chronyd` tracking data. - -- `Unknown`: the status of the clock is unknown. -- `Synchronized`: the clock is kept accurate by the synchronization daemon. -- `FreeRunning`: the clock is free running and not updated by the synchronization daemon. -- `Disrupted`: the clock has been disrupted and the accuracy of time cannot be bounded. - -## Finite State Machine (FSM) - -FSM drives a change in the clock status word stored in the ClockBound shared memory segment. Each transition in the FSM is triggered by `chrony`. See following state diagram for clock status in shared memory: - -![State Diagram for ClockStatus in SHM](../docs/assets/FSM.png) - -## Errors returned by all low-level ClockBound APIs - -- `SyscallError(Errno, &'static CStr)`: a system call failed. - - variant includes the Errno struct with error details - - an indication on the origin of the system call that error'ed. -- `SegmentNotInitialized`: the shared memory segment is not initialized. -- `SegmentMalformed`: the shared memory segment is initialized but malformed. -- `CausalityBreach`: failed causality check when comparing timestamps. -- `SegmentVersionNotSupported`: the shared memory segment version is not supported. diff --git a/clock-bound-vmclock/Cargo.toml b/clock-bound-vmclock/Cargo.toml deleted file mode 100644 index ca4adf7..0000000 --- a/clock-bound-vmclock/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "clock-bound-vmclock" -description = "A library used to interact with VMClock shared memory in ClockBound." -license = "Apache-2.0" - -authors.workspace = true -categories.workspace = true -edition.workspace = true -exclude.workspace = true -keywords.workspace = true -publish.workspace = true -repository.workspace = true -version.workspace = true - -[features] -writer = [] - -[dependencies] -clock-bound-shm = { version = "2.0", path = "../clock-bound-shm" } -byteorder = "1" -errno = { version = "0.3.0", default-features = false } -libc = { version = "0.2", default-features = false, features = ["extra_traits"] } -nix = { version = "0.26", features = ["feature", "time"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } - -[dev-dependencies] -tempfile = { version = "3.13" } diff --git a/clock-bound-vmclock/LICENSE b/clock-bound-vmclock/LICENSE deleted file mode 100644 index 7a4a3ea..0000000 --- a/clock-bound-vmclock/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - 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 - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file diff --git a/clock-bound-vmclock/NOTICE b/clock-bound-vmclock/NOTICE deleted file mode 100644 index b5d6773..0000000 --- a/clock-bound-vmclock/NOTICE +++ /dev/null @@ -1,2 +0,0 @@ -clock-bound-d -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. \ No newline at end of file diff --git a/clock-bound-vmclock/README.md b/clock-bound-vmclock/README.md deleted file mode 100644 index 38cd47e..0000000 --- a/clock-bound-vmclock/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# ClockBound VMClock - -## Overview - -This crate provides a reader and writer implementation for the VMClock. - -## References - -For more details about VMClock, see the description provided in file [vmclock-abi.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/vmclock-abi.h). diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index fe51aa9..292dd06 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -10,6 +10,19 @@ exclude.workspace = true keywords.workspace = true publish.workspace = true repository.workspace = true -version = "0.1.0-alpha" +version.workspace = true [dependencies] +byteorder = "1" +errno = { version = "0.3.0", default-features = false } +libc = { version = "0.2", default-features = false, features = ["extra_traits"] } +nix = { version = "0.26", features = ["feature", "time"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } + +[dev-dependencies] +tempfile = { version = "3.13" } + +[features] +client = [] +default = ["client"] diff --git a/clock-bound-client/src/lib.rs b/clock-bound/src/client.rs similarity index 98% rename from clock-bound-client/src/lib.rs rename to clock-bound/src/client.rs index 3156d82..2ba02e8 100644 --- a/clock-bound-client/src/lib.rs +++ b/clock-bound/src/client.rs @@ -1,9 +1,9 @@ //! A client library to communicate with ClockBound daemon. This client library is written in pure Rust. //! -pub use clock_bound_shm::ClockStatus; -use clock_bound_shm::ShmError; -pub use clock_bound_vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; -use clock_bound_vmclock::VMClock; +pub use crate::shm::ClockStatus; +use crate::shm::ShmError; +pub use crate::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; +use crate::vmclock::VMClock; use errno::Errno; use nix::sys::time::TimeSpec; use std::path::Path; @@ -153,8 +153,8 @@ pub struct ClockBoundNowResult { #[cfg(test)] mod lib_tests { use super::*; - use clock_bound_shm::{ClockErrorBound, ShmWrite, ShmWriter}; - use clock_bound_vmclock::shm::VMClockClockStatus; + use crate::shm::{ClockErrorBound, ShmWrite, ShmWriter}; + use crate::vmclock::shm::VMClockClockStatus; use byteorder::{NativeEndian, WriteBytesExt}; use std::ffi::CStr; use std::fs::{File, OpenOptions}; diff --git a/clock-bound/src/lib.rs b/clock-bound/src/lib.rs index 53abdfb..8d8fec6 100644 --- a/clock-bound/src/lib.rs +++ b/clock-bound/src/lib.rs @@ -1 +1,8 @@ //! ClockBound + +#[cfg(feature = "client")] +pub mod client; + +pub mod shm; + +pub mod vmclock; \ No newline at end of file diff --git a/clock-bound-shm/src/lib.rs b/clock-bound/src/shm.rs similarity index 99% rename from clock-bound-shm/src/lib.rs rename to clock-bound/src/shm.rs index 3c1d8af..9e2e4fd 100644 --- a/clock-bound-shm/src/lib.rs +++ b/clock-bound/src/shm.rs @@ -8,16 +8,16 @@ // if the write feature is selected. There may be a better way to do that and re-enable the lint. #![allow(dead_code)] -// Re-exports reader and writer. The writer is conditionally included under the "writer" feature. -pub use crate::reader::ShmReader; -#[cfg(feature = "writer")] -pub use crate::writer::{ShmWrite, ShmWriter}; pub mod common; mod reader; mod shm_header; mod writer; +// Re-exports reader and writer. The writer is conditionally included under the "writer" feature. +pub use reader::ShmReader; +pub use writer::{ShmWrite, ShmWriter}; + use errno::Errno; use nix::sys::time::{TimeSpec, TimeValLike}; use std::ffi::CStr; @@ -31,7 +31,7 @@ const CLOCKBOUND_RESTART_GRACE_PERIOD: TimeSpec = TimeSpec::new(5, 0); #[macro_export] macro_rules! syserror { ($origin:expr) => { - Err($crate::ShmError::SyscallError( + Err($crate::shm::ShmError::SyscallError( ::errno::errno(), ::std::ffi::CStr::from_bytes_with_nul(concat!($origin, "\0").as_bytes()).unwrap(), )) diff --git a/clock-bound-shm/src/common.rs b/clock-bound/src/shm/common.rs similarity index 98% rename from clock-bound-shm/src/common.rs rename to clock-bound/src/shm/common.rs index cfc625f..36dd1f8 100644 --- a/clock-bound-shm/src/common.rs +++ b/clock-bound/src/shm/common.rs @@ -1,4 +1,4 @@ -use crate::{syserror, ShmError}; +use crate::{syserror, shm::ShmError}; use nix::sys::time::TimeSpec; use nix::time::{clock_gettime, ClockId}; diff --git a/clock-bound-shm/src/reader.rs b/clock-bound/src/shm/reader.rs similarity index 99% rename from clock-bound-shm/src/reader.rs rename to clock-bound/src/shm/reader.rs index 6a4034e..32ec61e 100644 --- a/clock-bound-shm/src/reader.rs +++ b/clock-bound/src/shm/reader.rs @@ -4,8 +4,8 @@ use std::mem::size_of; use std::ptr; use std::sync::atomic; -use crate::shm_header::{ShmHeader, CLOCKBOUND_SHM_SUPPORTED_VERSION}; -use crate::{syserror, ClockErrorBound, ShmError}; +use crate::shm::shm_header::{ShmHeader, CLOCKBOUND_SHM_SUPPORTED_VERSION}; +use crate::{syserror, shm::{ClockErrorBound, ShmError}}; /// A guard tracking an open file descriptor. /// @@ -295,7 +295,7 @@ impl ShmReader { #[cfg(test)] mod t_reader { use super::*; - use crate::ClockStatus; + use crate::shm::ClockStatus; use byteorder::{NativeEndian, WriteBytesExt}; use nix::sys::time::TimeSpec; use std::ffi::CString; diff --git a/clock-bound-shm/src/shm_header.rs b/clock-bound/src/shm/shm_header.rs similarity index 99% rename from clock-bound-shm/src/shm_header.rs rename to clock-bound/src/shm/shm_header.rs index 105393a..6fea804 100644 --- a/clock-bound-shm/src/shm_header.rs +++ b/clock-bound/src/shm/shm_header.rs @@ -1,7 +1,7 @@ use std::mem::{size_of, MaybeUninit}; use std::sync::atomic; -use crate::{syserror, ShmError}; +use crate::{syserror, shm::ShmError}; /// The magic number that identifies a ClockErrorBound shared memory segment. pub const SHM_MAGIC: [u32; 2] = [0x414D5A4E, 0x43420200]; diff --git a/clock-bound-shm/src/writer.rs b/clock-bound/src/shm/writer.rs similarity index 96% rename from clock-bound-shm/src/writer.rs rename to clock-bound/src/shm/writer.rs index 0b91c0c..71fcc4c 100644 --- a/clock-bound-shm/src/writer.rs +++ b/clock-bound/src/shm/writer.rs @@ -10,9 +10,9 @@ use std::io::Seek; use std::io::Write; use std::os::unix::ffi::OsStrExt; -use crate::reader::ShmReader; -use crate::shm_header::{ShmHeader, CLOCKBOUND_SHM_SUPPORTED_VERSION, SHM_MAGIC}; -use crate::{ClockErrorBound, ShmError}; +use crate::shm::reader::ShmReader; +use crate::shm::shm_header::{ShmHeader, CLOCKBOUND_SHM_SUPPORTED_VERSION, SHM_MAGIC}; +use crate::shm::{ClockErrorBound, ShmError}; /// Trait that a writer to the shared memory segment has to implement. pub trait ShmWrite { @@ -260,20 +260,20 @@ impl ShmWrite for ShmWriter { unsafe { // Start by reading the generation value stored in the memory segment. let generation = &*self.generation; - let gen = generation.load(atomic::Ordering::Acquire); + let g = generation.load(atomic::Ordering::Acquire); // Mark the beginning of the update into the memory segment. // The producer process may have error'ed or died in the middle of a previous update // and left things hanging with an odd generation number. Being a bit fancy, this is // our data anti-entropy protection, and make sure we enter the updating section with // an odd number. - let gen = if gen & 0x0001 == 0 { + let g = if g & 0x0001 == 0 { // This should be the most common case - gen.wrapping_add(1) + g.wrapping_add(1) } else { - gen + g }; - generation.store(gen, atomic::Ordering::Release); + generation.store(g, atomic::Ordering::Release); self.ceb.write(*ceb); @@ -286,12 +286,12 @@ impl ShmWrite for ShmWriter { // 4. the writer updates and set version, but it is too late by now. // // Skipping over a generation equals to 0 avoid this problem. - let mut gen = gen.wrapping_add(1); - if gen == 0 { - gen = 2 + let mut g = g.wrapping_add(1); + if g == 0 { + g = 2 } - generation.store(gen, atomic::Ordering::Release); + generation.store(g, atomic::Ordering::Release); } } } @@ -320,7 +320,7 @@ mod t_writer { /// afterwards. use tempfile::NamedTempFile; - use crate::ClockStatus; + use crate::shm::ClockStatus; macro_rules! clockerrorbound { () => { @@ -421,18 +421,18 @@ mod t_writer { // Check what the writer says let generation = unsafe { &*writer.generation }; - let gen = generation.load(atomic::Ordering::Acquire); + let g = generation.load(atomic::Ordering::Acquire); std::mem::drop(writer); - assert_eq!(gen, 4); + assert_eq!(g, 4); // Raw validation in the file // A bit brittle, would be more robust not to hardcode the seek to the generation field let mut file = std::fs::File::open(clockbound_shm_path).expect("create file failed"); file.seek(std::io::SeekFrom::Start(14)) .expect("Failed to seek to generation offset"); - let gen = file + let g = file .read_u16::() .expect("Failed to read generation from file"); - assert_eq!(gen, 4); + assert_eq!(g, 4); } } diff --git a/clock-bound-vmclock/src/lib.rs b/clock-bound/src/vmclock.rs similarity index 97% rename from clock-bound-vmclock/src/lib.rs rename to clock-bound/src/vmclock.rs index 92d0be3..d674226 100644 --- a/clock-bound-vmclock/src/lib.rs +++ b/clock-bound/src/vmclock.rs @@ -1,8 +1,8 @@ use std::ffi::CString; use tracing::debug; -use crate::shm_reader::VMClockShmReader; -use clock_bound_shm::{ClockStatus, ShmError, ShmReader}; +use crate::vmclock::shm_reader::VMClockShmReader; +use crate::shm::{ClockStatus, ShmError, ShmReader}; use nix::sys::time::TimeSpec; pub mod shm; @@ -97,11 +97,11 @@ impl VMClock { mod t_lib { use super::*; - use clock_bound_shm::{ClockErrorBound, ShmWrite, ShmWriter}; + use crate::shm::{ClockErrorBound, ShmWrite, ShmWriter}; use std::path::Path; - use crate::shm::{VMClockClockStatus, VMClockShmBody}; - use crate::shm_writer::{VMClockShmWrite, VMClockShmWriter}; + use crate::vmclock::shm::{VMClockClockStatus, VMClockShmBody}; + use crate::vmclock::shm_writer::{VMClockShmWrite, VMClockShmWriter}; /// We make use of tempfile::NamedTempFile to ensure that /// local files that are created during a test get removed /// afterwards. diff --git a/clock-bound-vmclock/src/shm.rs b/clock-bound/src/vmclock/shm.rs similarity index 99% rename from clock-bound-vmclock/src/shm.rs rename to clock-bound/src/vmclock/shm.rs index bcb350f..b151e7b 100644 --- a/clock-bound-vmclock/src/shm.rs +++ b/clock-bound/src/vmclock/shm.rs @@ -12,7 +12,8 @@ use std::mem::size_of; use std::str::FromStr; use std::sync::atomic; -use clock_bound_shm::{syserror, ShmError}; +use crate::syserror; +use crate::shm::ShmError; use tracing::{debug, error}; pub const VMCLOCK_SHM_DEFAULT_PATH: &str = "/dev/vmclock0"; diff --git a/clock-bound-vmclock/src/shm_reader.rs b/clock-bound/src/vmclock/shm_reader.rs similarity index 99% rename from clock-bound-vmclock/src/shm_reader.rs rename to clock-bound/src/vmclock/shm_reader.rs index 54af19e..e6d7cc5 100644 --- a/clock-bound-vmclock/src/shm_reader.rs +++ b/clock-bound/src/vmclock/shm_reader.rs @@ -7,8 +7,9 @@ use std::ptr; use std::sync::atomic; use tracing::{debug, error}; -use crate::shm::{VMClockShmBody, VMClockShmHeader}; -use clock_bound_shm::{syserror, ShmError}; +use crate::vmclock::shm::{VMClockShmBody, VMClockShmHeader}; +use crate::syserror; +use crate::shm::ShmError; const VMCLOCK_SUPPORTED_VERSION: u16 = 1; @@ -309,7 +310,7 @@ impl VMClockShmReader { #[cfg(test)] mod t_reader { use super::*; - use crate::shm::VMClockClockStatus; + use crate::vmclock::shm::VMClockClockStatus; use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::Path; diff --git a/clock-bound-vmclock/src/shm_writer.rs b/clock-bound/src/vmclock/shm_writer.rs similarity index 98% rename from clock-bound-vmclock/src/shm_writer.rs rename to clock-bound/src/vmclock/shm_writer.rs index 3d1bc5d..b66548f 100644 --- a/clock-bound-vmclock/src/shm_writer.rs +++ b/clock-bound/src/vmclock/shm_writer.rs @@ -10,9 +10,9 @@ use std::{fs, ptr}; use tracing::debug; -use crate::shm::{VMClockShmBody, VMClockShmHeader, VMCLOCK_SHM_MAGIC}; -use crate::shm_reader::VMClockShmReader; -use clock_bound_shm::ShmError; +use crate::vmclock::shm::{VMClockShmBody, VMClockShmHeader, VMCLOCK_SHM_MAGIC}; +use crate::vmclock::shm_reader::VMClockShmReader; +use crate::shm::ShmError; /// Trait that a writer to the shared memory segment has to implement. pub trait VMClockShmWrite { @@ -307,7 +307,7 @@ mod t_writer { /// afterwards. use tempfile::NamedTempFile; - use crate::shm::VMClockClockStatus; + use crate::vmclock::shm::VMClockClockStatus; macro_rules! vmclockshmbody { () => { diff --git a/examples/client/rust/Cargo.toml b/examples/client/rust/Cargo.toml index c6025e3..1210c99 100644 --- a/examples/client/rust/Cargo.toml +++ b/examples/client/rust/Cargo.toml @@ -19,7 +19,7 @@ path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clock-bound-client = { version = "2.0", path = "../../../clock-bound-client" } +clock-bound = { version = "2.0", path = "../../../clock-bound", features = ["client"] } nix = { version = "0.26", features = ["feature", "time"] } [dev-dependencies] diff --git a/examples/client/rust/src/main.rs b/examples/client/rust/src/main.rs index ddb7835..e6a6daa 100644 --- a/examples/client/rust/src/main.rs +++ b/examples/client/rust/src/main.rs @@ -1,4 +1,4 @@ -use clock_bound_client::{ +use clock_bound::client::{ ClockBoundClient, ClockBoundError, ClockStatus, CLOCKBOUND_SHM_DEFAULT_PATH, VMCLOCK_SHM_DEFAULT_PATH, }; @@ -81,7 +81,7 @@ fn calculate_duration_seconds(start: &TimeSpec, end: &TimeSpec) -> f64 { #[cfg(test)] mod tests { use super::*; - use clock_bound_client::ClockBoundErrorKind; + use clock_bound::client::ClockBoundErrorKind; use errno::Errno; #[test] diff --git a/test/clock-bound-vmclock-client-test/Cargo.toml b/test/clock-bound-vmclock-client-test/Cargo.toml index a734dc1..8c6bbda 100644 --- a/test/clock-bound-vmclock-client-test/Cargo.toml +++ b/test/clock-bound-vmclock-client-test/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "clock-bound-vmclock-client-test" description = "A Rust test program of the ClockBound client communicating with the ClockBound daemon and VMClock." -license = "Apache-2.0" +license = "MIT OR Apache-2.0" publish = false authors.workspace = true @@ -19,7 +19,7 @@ path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clock-bound-client = { version = "2.0", path = "../../clock-bound-client" } +clock-bound = { version = "2.0", path = "../../clock-bound", features = ["client"] } nix = { version = "0.26", features = ["feature", "time"] } [dev-dependencies] diff --git a/test/clock-bound-vmclock-client-test/src/main.rs b/test/clock-bound-vmclock-client-test/src/main.rs index 2103008..446e39d 100644 --- a/test/clock-bound-vmclock-client-test/src/main.rs +++ b/test/clock-bound-vmclock-client-test/src/main.rs @@ -1,4 +1,4 @@ -use clock_bound_client::{ +use clock_bound::client::{ ClockBoundClient, ClockBoundError, ClockStatus, CLOCKBOUND_SHM_DEFAULT_PATH, VMCLOCK_SHM_DEFAULT_PATH, }; @@ -54,7 +54,7 @@ fn format_clock_status(clock_status: &ClockStatus) -> &str { #[cfg(test)] mod tests { use super::*; - use clock_bound_client::ClockBoundErrorKind; + use clock_bound::client::ClockBoundErrorKind; use errno::Errno; #[test] diff --git a/test/vmclock-updater/Cargo.toml b/test/vmclock-updater/Cargo.toml index 5e685ce..fc439ac 100644 --- a/test/vmclock-updater/Cargo.toml +++ b/test/vmclock-updater/Cargo.toml @@ -17,7 +17,7 @@ name = "vmclock-updater" path = "src/main.rs" [dependencies] -clock-bound-vmclock = { version = "2.0", path = "../../clock-bound-vmclock" } +clock-bound = { version = "2.0", path = "../../clock-bound" } byteorder = "1" clap = { version = "4", features = ["derive"] } errno = { version = "0.3.0", default-features = false } diff --git a/test/vmclock-updater/src/main.rs b/test/vmclock-updater/src/main.rs index 5b70a64..f69b240 100644 --- a/test/vmclock-updater/src/main.rs +++ b/test/vmclock-updater/src/main.rs @@ -4,8 +4,8 @@ use std::str::FromStr; use clap::Parser; -use clock_bound_vmclock::shm::{VMClockClockStatus, VMClockShmBody, VMCLOCK_SHM_DEFAULT_PATH}; -use clock_bound_vmclock::shm_writer::{VMClockShmWrite, VMClockShmWriter}; +use clock_bound::vmclock::shm::{VMClockClockStatus, VMClockShmBody, VMCLOCK_SHM_DEFAULT_PATH}; +use clock_bound::vmclock::shm_writer::{VMClockShmWrite, VMClockShmWriter}; /// CLI arguments are the possible field values that can be set in the VMClock shared memory segment. #[derive(Parser, Debug)] From 2832175274da7d2bb2359de3631c196d66b65142 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 23 Sep 2025 13:49:45 -0400 Subject: [PATCH 005/177] Add daemon module to clock-bound along with 3 daemon components as high level modules (#5) --- clock-bound/Cargo.toml | 3 ++- clock-bound/src/daemon.rs | 7 +++++++ clock-bound/src/daemon/clock_state.rs | 1 + clock-bound/src/daemon/clock_sync_algorithm.rs | 1 + clock-bound/src/daemon/io.rs | 1 + clock-bound/src/lib.rs | 5 ++++- 6 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 clock-bound/src/daemon.rs create mode 100644 clock-bound/src/daemon/clock_state.rs create mode 100644 clock-bound/src/daemon/clock_sync_algorithm.rs create mode 100644 clock-bound/src/daemon/io.rs diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 292dd06..76fe2da 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -25,4 +25,5 @@ tempfile = { version = "3.13" } [features] client = [] -default = ["client"] +daemon = [] +default = ["client", "daemon"] diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs new file mode 100644 index 0000000..dfd17db --- /dev/null +++ b/clock-bound/src/daemon.rs @@ -0,0 +1,7 @@ +//! Clock Synchronization Daemon + +pub mod io; + +pub mod clock_state; + +pub mod clock_sync_algorithm; \ No newline at end of file diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs new file mode 100644 index 0000000..151db3e --- /dev/null +++ b/clock-bound/src/daemon/clock_state.rs @@ -0,0 +1 @@ +//! Adjust system clock and clockbound shared memory \ No newline at end of file diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs new file mode 100644 index 0000000..d3d1cab --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -0,0 +1 @@ +//! Feed forward clock sync algorithm \ No newline at end of file diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs new file mode 100644 index 0000000..04029a2 --- /dev/null +++ b/clock-bound/src/daemon/io.rs @@ -0,0 +1 @@ +//! Perform IO on clock events \ No newline at end of file diff --git a/clock-bound/src/lib.rs b/clock-bound/src/lib.rs index 8d8fec6..93d7f1e 100644 --- a/clock-bound/src/lib.rs +++ b/clock-bound/src/lib.rs @@ -5,4 +5,7 @@ pub mod client; pub mod shm; -pub mod vmclock; \ No newline at end of file +pub mod vmclock; + +#[cfg(feature = "daemon")] +pub mod daemon; \ No newline at end of file From 5270fe91c6292bc168c0be603acf3b5d2dd3a1a0 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 24 Sep 2025 09:30:11 -0400 Subject: [PATCH 006/177] Add actions to send to webhooks when pr comment or review comment created (#6) * Add workflow to send to webhook when pr comment created * Also add pr review comments, since those are handled separately --- .github/workflows/pr_comment_slack.yml | 18 ++++++++++++++++++ .github/workflows/pr_review_comment_slack.yml | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .github/workflows/pr_comment_slack.yml create mode 100644 .github/workflows/pr_review_comment_slack.yml diff --git a/.github/workflows/pr_comment_slack.yml b/.github/workflows/pr_comment_slack.yml new file mode 100644 index 0000000..f8e70d7 --- /dev/null +++ b/.github/workflows/pr_comment_slack.yml @@ -0,0 +1,18 @@ +name: pr_comment_slack + +on: + issue_comment: + types: [created] + +jobs: + pr_comment_slack: + if: contains(github.event.comment.html_url, '/pull/') # only execute on pull_requests + name: Notify slack of pull request comment + runs-on: ubuntu-latest + steps: + - name: Send comment info to slack + uses: slackapi/slack-github-action@v2.1.1 + with: + payload-delimiter: "_" + webhook: ${{ secrets.PR_COMMENT_WEBHOOK_URL }} + webhook-type: webhook-trigger \ No newline at end of file diff --git a/.github/workflows/pr_review_comment_slack.yml b/.github/workflows/pr_review_comment_slack.yml new file mode 100644 index 0000000..ef94fd4 --- /dev/null +++ b/.github/workflows/pr_review_comment_slack.yml @@ -0,0 +1,17 @@ +name: pr_review_comment_slack + +on: + pull_request_review_comment: + types: [created] + +jobs: + pr_review_comment_slack: + name: Notify slack of pull request review comment + runs-on: ubuntu-latest + steps: + - name: Send pr review comment info to slack + uses: slackapi/slack-github-action@v2.1.1 + with: + payload-delimiter: "_" + webhook: ${{ secrets.PR_REVIEW_COMMENT_WEBHOOK_URL }} + webhook-type: webhook-trigger \ No newline at end of file From e6069b1e8dd9ea1f40554ebecf9bfd934a21dfd4 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 24 Sep 2025 14:58:49 -0400 Subject: [PATCH 007/177] add pull request template (#8) --- .github/pull_request_template.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..25a3941 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +### Problem and requirements + +Describe the problem this patch targets, give extra context + +### Solution and caveats + +How this patch solve the problem and any caveats with this PR + +### Revisions + +**Revision 1**: + +- Initial release + +### Unit and Integration Tests + +Check with an `x` for what applies and add details as needed: + +- [ ] Unit tests added +- [ ] Integration tests added +- [ ] Workflows modified +- [ ] Ran `cargo build` +- [ ] Ran `cargo clippy` and `cargo fmt` \ No newline at end of file From 8e579675cad8fbe3c09238e1b9b5544c96559383 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 29 Sep 2025 19:18:20 -0400 Subject: [PATCH 008/177] Add primitive Instant and TSC time types (#9) Additionally, ran cargo fmt --- Cargo.lock | 235 ++++++++++ clock-bound/Cargo.toml | 7 +- clock-bound/src/client.rs | 2 +- clock-bound/src/daemon.rs | 4 +- clock-bound/src/daemon/clock_state.rs | 2 +- .../src/daemon/clock_sync_algorithm.rs | 2 +- clock-bound/src/daemon/io.rs | 2 +- clock-bound/src/daemon/time.rs | 11 + clock-bound/src/daemon/time/inner.rs | 394 ++++++++++++++++ clock-bound/src/daemon/time/instant.rs | 431 ++++++++++++++++++ clock-bound/src/daemon/time/tsc.rs | 391 ++++++++++++++++ clock-bound/src/lib.rs | 8 +- clock-bound/src/shm.rs | 3 +- clock-bound/src/shm/common.rs | 4 +- clock-bound/src/shm/reader.rs | 16 +- clock-bound/src/shm/shm_header.rs | 11 +- clock-bound/src/shm/writer.rs | 8 +- clock-bound/src/vmclock.rs | 11 +- clock-bound/src/vmclock/shm.rs | 2 +- clock-bound/src/vmclock/shm_reader.rs | 20 +- clock-bound/src/vmclock/shm_writer.rs | 8 +- 21 files changed, 1533 insertions(+), 39 deletions(-) create mode 100644 clock-bound/src/daemon/time.rs create mode 100644 clock-bound/src/daemon/time/inner.rs create mode 100644 clock-bound/src/daemon/time/instant.rs create mode 100644 clock-bound/src/daemon/time/tsc.rs diff --git a/Cargo.lock b/Cargo.lock index a09a5b2..b1f0ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.20" @@ -52,6 +61,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -126,10 +144,13 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" name = "clock-bound" version = "2.0.3" dependencies = [ + "approx", "byteorder", "errno", "libc", "nix", + "rstest", + "serde", "tempfile", "tracing", "tracing-subscriber", @@ -173,6 +194,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.10" @@ -189,6 +216,49 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "getrandom" version = "0.3.1" @@ -201,12 +271,34 @@ dependencies = [ "windows-targets 0.52.0", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -280,6 +372,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -304,6 +405,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -322,6 +432,79 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.0.2" @@ -341,6 +524,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.226" @@ -348,6 +537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -392,6 +582,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" @@ -438,6 +634,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "toml_datetime" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.41" @@ -695,6 +921,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.33.0" diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 76fe2da..249923b 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -17,13 +17,16 @@ byteorder = "1" errno = { version = "0.3.0", default-features = false } libc = { version = "0.2", default-features = false, features = ["extra_traits"] } nix = { version = "0.26", features = ["feature", "time"] } +serde = { version = "1.0", features = ["derive"], optional = true } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } [dev-dependencies] -tempfile = { version = "3.13" } +approx = "0.5" +rstest = "0.26" +tempfile = "3.13" [features] client = [] -daemon = [] +daemon = ["dep:serde"] default = ["client", "daemon"] diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index 2ba02e8..adee916 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -2,8 +2,8 @@ //! pub use crate::shm::ClockStatus; use crate::shm::ShmError; -pub use crate::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use crate::vmclock::VMClock; +pub use crate::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use errno::Errno; use nix::sys::time::TimeSpec; use std::path::Path; diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index dfd17db..4b39415 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -4,4 +4,6 @@ pub mod io; pub mod clock_state; -pub mod clock_sync_algorithm; \ No newline at end of file +pub mod clock_sync_algorithm; + +pub mod time; diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 151db3e..43d965e 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -1 +1 @@ -//! Adjust system clock and clockbound shared memory \ No newline at end of file +//! Adjust system clock and clockbound shared memory diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index d3d1cab..4106fcf 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -1 +1 @@ -//! Feed forward clock sync algorithm \ No newline at end of file +//! Feed forward clock sync algorithm diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 04029a2..140c097 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -1 +1 @@ -//! Perform IO on clock events \ No newline at end of file +//! Perform IO on clock events diff --git a/clock-bound/src/daemon/time.rs b/clock-bound/src/daemon/time.rs new file mode 100644 index 0000000..784c9bc --- /dev/null +++ b/clock-bound/src/daemon/time.rs @@ -0,0 +1,11 @@ +//! Simple time library for the `ClockBound` time synchronization daemon +//! +//! Other time libraries do not meet our needs, as we make heavy usage of time stamp counters (TSCs) +//! for the bulk of our processing. These values are more low-level than those seen in `chrono` or other time types + +pub mod inner; +pub mod instant; +pub mod tsc; + +pub use instant::{Duration, Instant}; +pub use tsc::{TscCount, TscDiff}; diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs new file mode 100644 index 0000000..0905fe1 --- /dev/null +++ b/clock-bound/src/daemon/time/inner.rs @@ -0,0 +1,394 @@ +//! Time representation +//! +//! Simpler representation of time than `nix::time::TimeSpec`. Just abstractions over integer math + +use std::{ + marker::PhantomData, + ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Sub, SubAssign}, +}; + +/// Abstraction used to reuse basic time arithmetic, but allow for different types based on its usage +pub trait Type: crate::private::Sealed {} + +/// Abstract type for Time while keeping arithmetic consistent +/// +/// This type is not usually used directly, but rather through the [`Instant`](super::Instant) and [`Tsc`](super::TscCount) types. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +#[serde(transparent)] +#[repr(transparent)] +pub struct Time { + instant: i128, + _marker: std::marker::PhantomData, +} + +impl Time { + /// Get the inner value + pub const fn get(&self) -> i128 { + self.instant + } +} + +impl From> for i128 { + fn from(value: Time) -> Self { + value.instant + } +} + +// type guarded to be sealed construction +impl Time { + /// constructor + pub const fn new(instant: i128) -> Self { + Self { + instant, + _marker: PhantomData, + } + } +} + +impl Time { + /// Get the midpoint between 2 instants + #[must_use] + pub const fn midpoint(&self, other: Self) -> Self { + Self::new(self.instant.midpoint(other.instant)) + } +} + +impl From for Time { + fn from(value: i128) -> Self { + Self::new(value) + } +} + +impl Sub for Time { + type Output = Diff; + + fn sub(self, rhs: Self) -> Self::Output { + Self::Output::new(self.instant - rhs.instant) + } +} + +impl Add> for Time { + type Output = Time; + + fn add(self, rhs: Diff) -> Self::Output { + Self::new(self.instant + rhs.duration) + } +} + +impl Add> for Diff { + type Output = Time; + + fn add(self, rhs: Time) -> Self::Output { + rhs + self + } +} + +impl AddAssign> for Time { + fn add_assign(&mut self, rhs: Diff) { + self.instant += rhs.duration; + } +} + +impl Sub> for Time { + type Output = Self; + + fn sub(self, rhs: Diff) -> Self::Output { + Self::new(self.instant - rhs.duration) + } +} + +impl SubAssign> for Time { + fn sub_assign(&mut self, rhs: Diff) { + self.instant -= rhs.duration; + } +} + +/// Difference between 2 [`Time`] values +/// +/// It is not recommended to use this directly, but use the [`Duration`](super::Duration) or [`TscDiff`](super::TscDiff) types +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Default, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +#[repr(transparent)] +pub struct Diff { + duration: i128, + _marker: PhantomData, +} + +impl Diff { + /// Inner Count value + pub const fn get(&self) -> i128 { + self.duration + } + + /// Returns a [`Diff`] that contains the absolute value to the given duration. + #[must_use] + pub fn abs(&self) -> Self { + Self { + duration: self.duration.abs(), + _marker: PhantomData, + } + } +} + +impl From> for i128 { + fn from(value: Diff) -> Self { + value.duration + } +} + +impl Diff { + /// constructor + pub const fn new(duration: i128) -> Self { + Self { + duration, + _marker: PhantomData, + } + } +} + +impl From for Diff { + fn from(value: i128) -> Self { + Self::new(value) + } +} + +impl Add for Diff { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self { + duration: self.duration + rhs.duration, + _marker: PhantomData, + } + } +} + +impl Sub for Diff { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self { + duration: self.duration - rhs.duration, + _marker: PhantomData, + } + } +} + +impl AddAssign for Diff { + fn add_assign(&mut self, rhs: Self) { + self.duration += rhs.duration; + } +} + +impl SubAssign for Diff { + fn sub_assign(&mut self, rhs: Self) { + self.duration -= rhs.duration; + } +} + +impl Div for Diff { + type Output = Self; + + #[allow(clippy::cast_possible_wrap)] + fn div(self, rhs: usize) -> Self::Output { + Self { + duration: self.duration / rhs as i128, + _marker: PhantomData, + } + } +} + +impl DivAssign for Diff { + #[allow(clippy::cast_possible_wrap)] + fn div_assign(&mut self, rhs: usize) { + self.duration /= rhs as i128; + } +} + +impl Mul for Diff { + type Output = Self; + + #[expect( + clippy::cast_possible_wrap, + reason = "Multiplying will create this problem anyways" + )] + fn mul(self, rhs: usize) -> Self::Output { + Self { + duration: self.duration * rhs as i128, + + _marker: PhantomData, + } + } +} + +impl Mul> for usize { + type Output = Diff; + + fn mul(self, rhs: Diff) -> Self::Output { + rhs * self + } +} + +#[expect( + clippy::cast_possible_wrap, + reason = "Multiplying will create this problem anyways" +)] +impl MulAssign for Diff { + fn mul_assign(&mut self, rhs: usize) { + self.duration *= rhs as i128; + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[derive(Clone, Copy)] + struct TestType; + + impl crate::private::Sealed for TestType {} + impl Type for TestType {} + + type TestTimestamp = Time; + type TestDiff = Diff; + + #[test] + fn calc_raw_difference() { + let a = TestTimestamp::new(100); + let b = TestTimestamp::new(50); + let c = a - b; + assert_eq!(c.duration, 50); + } + + #[test] + fn add_raw_duration() { + let a = TestDiff::new(100); + let b = TestDiff::new(50); + let c = a + b; + assert_eq!(c.duration, 150); + } + + #[test] + fn add_raw_duration_to_timestamp() { + let a = TestTimestamp::new(100); + let b = TestDiff::new(50); + let c = a + b; + assert_eq!(c.instant, 150); + } + + #[test] + fn add_assign_raw_duration_to_timestamp() { + let mut a = TestTimestamp::new(100); + let b = TestDiff::new(50); + a += b; + assert_eq!(a.instant, 150); + } + + #[test] + fn sub_raw_duration_from_timestamp() { + let a = TestTimestamp::new(100); + let b = TestDiff::new(50); + let c = a - b; + assert_eq!(c.instant, 50); + } + + #[test] + fn sub_durations() { + let a = TestDiff::new(100); + let b = TestDiff::new(50); + let c = a - b; + assert_eq!(c.duration, 50); + } + + #[test] + fn sub_assign_durations() { + let mut a = TestDiff::new(100); + let b = TestDiff::new(50); + a -= b; + assert_eq!(a.duration, 50); + } + + #[test] + fn add_assign_durations() { + let mut a = TestDiff::new(100); + let b = TestDiff::new(50); + a += b; + assert_eq!(a.duration, 150); + } + + #[test] + fn sub_assign_raw_duration_from_timestamp() { + let mut a = TestTimestamp::new(100); + let b = TestDiff::new(50); + a -= b; + assert_eq!(a.instant, 50); + } + + #[test] + fn duration_multiplication() { + let duration = TestDiff::new(10); + let multiplied = duration * 5; + assert_eq!(multiplied.get(), 50); + } + + #[test] + fn duration_multiplication_reverse() { + let duration = TestDiff::new(10); + let multiplied = 5 * duration; + assert_eq!(multiplied.get(), 50); + } + + #[test] + fn duration_mul_assign() { + let mut duration = TestDiff::new(10); + duration *= 5; + assert_eq!(duration.get(), 50); + } + + #[test] + fn div_durations() { + let a = TestDiff::new(100); + let b = 50; + let c = a / b; + assert_eq!(c.duration, 2); + } + + #[test] + fn div_assign_durations() { + let mut a = TestDiff::new(100); + let b = 50; + a /= b; + assert_eq!(a.duration, 2); + } + + #[test] + fn abs_duration() { + let a = TestDiff::new(-100); + assert_eq!(a.abs().duration, a.duration.abs()); + } + + #[rstest::rstest] + #[case::positive(100, 200, 150)] + #[case::negative(300, -100, 100)] + #[case::same(100, 100, 100)] + fn midpoint(#[case] a: i128, #[case] b: i128, #[case] expected: i128) { + let a = TestTimestamp::new(a); + let b = TestTimestamp::new(b); + let midpoint = a.midpoint(b); + assert_eq!(midpoint.instant, expected); + } +} diff --git a/clock-bound/src/daemon/time/instant.rs b/clock-bound/src/daemon/time/instant.rs new file mode 100644 index 0000000..ec999c4 --- /dev/null +++ b/clock-bound/src/daemon/time/instant.rs @@ -0,0 +1,431 @@ +//! A simplified time type for `ClockBound` + +use super::inner::{Diff, Time}; + +/// Marker type to signify a time as a timestamp +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct Utc; + +impl crate::private::Sealed for Utc {} +impl super::inner::Type for Utc {} + +/// Representation of an absolute time timestamp +/// +/// This value represents the number of **femto**seconds since epoch, without leap seconds. +/// A femtosecond is 1/1,000,000,000,000,000th of a second. or 1e-15 +/// +/// This type's epoch is January 1, 1970 0:00:00 UTC (aka "UNIX timestamp") +/// +/// This type's inner value is an i128 number of femtoseconds from epoch. +pub type Instant = Time; + +/// The corresponding duration type for [`Instant`] +pub type Duration = Diff; + +impl Instant { + pub const UNIX_EPOCH: Self = Self::new(0); + pub const MAX: Self = Self::new(i128::MAX); + pub const MIN: Self = Self::new(i128::MIN); + + /// Create a new `Instant` from the number of seconds since the Unix Epoch + pub const fn from_secs(secs: i128) -> Self { + Self::new(secs * FEMTOS_PER_SEC) + } + + /// Create a new `Instant` from the number of milliseconds since the Unix Epoch + pub const fn from_millis(millis: i128) -> Self { + Self::new(millis * FEMTOS_PER_MILLI) + } + + /// Create a new `Instant` from the number of microseconds since the Unix Epoch + pub const fn from_micros(micros: i128) -> Self { + Self::new(micros * FEMTOS_PER_MICRO) + } + + /// Create a new `Instant` from the number of nanoseconds since the Unix Epoch + pub const fn from_nanos(nanos: i128) -> Self { + Self::new(nanos * FEMTOS_PER_NANO) + } + + /// Create a new `Instant` from the number of picoseconds since the Unix Epoch + pub const fn from_picos(picos: i128) -> Self { + Self::new(picos * FEMTOS_PER_PICO) + } + + /// Create a new `Instant` from the number of femtoseconds since the Unix Epoch + pub const fn from_femtos(femtos: i128) -> Self { + Self::new(femtos) + } + + /// Create a new `Instant` from the number of minutes since the Unix Epoch + pub const fn from_minutes(minutes: i128) -> Self { + Self::new(minutes * FEMTOS_PER_SEC * SECS_PER_MINUTE) + } + + /// Create a new `Instant` from the number of hours since the Unix Epoch + pub const fn from_hours(hours: i128) -> Self { + Self::new(hours * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + } + + /// Create a new `Instant` from the number of days since the Unix Epoch + pub const fn from_days(days: i128) -> Self { + Self::new(days * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + } + + /// Construct from the number of seconds and nanos since the Unix Epoch + /// + /// # Panics + /// Panics if `nanos >= 1_000_000_000`, or value does not fit within the type + pub fn from_time(secs: i128, nanos: u32) -> Self { + assert!(nanos < 1_000_000_000, "nanos must be less than 1 second"); + let secs = Self::from_secs(secs); + let nanos = Duration::from_nanos(i128::from(nanos)); + secs + nanos + } + + /// Returns the total number of femtoseconds since the Unix Epoch + pub const fn as_femtos(self) -> i128 { + self.get() + } + + /// Returns the total number of picoseconds since the Unix Epoch + pub const fn as_picos(self) -> i128 { + self.get() / FEMTOS_PER_PICO + } + + /// Returns the total number of nanoseconds since the Unix Epoch + pub const fn as_nanos(self) -> i128 { + self.get() / FEMTOS_PER_NANO + } + + /// Returns the total number of microseconds, truncated, since the Unix Epoch + pub const fn as_micros(self) -> i128 { + self.get() / FEMTOS_PER_MICRO + } + + /// Returns the total number of milliseconds, truncated, since the Unix Epoch + pub const fn as_millis(self) -> i128 { + self.get() / FEMTOS_PER_MILLI + } + + /// Returns the total number of seconds, truncated, since the Unix Epoch + pub const fn as_seconds(self) -> i128 { + self.get() / FEMTOS_PER_SEC + } + + /// Returns the total number of minutes, truncated, since the Unix Epoch + pub const fn as_minutes(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE) + } + + /// Returns the total number of hours, truncated, since the Unix Epoch + pub const fn as_hours(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + } + + /// Returns the total number of days, truncated, since the Unix Epoch + pub const fn as_days(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + } +} + +impl Duration { + /// Create a new [`Duration`] from the number of seconds + pub const fn from_secs(secs: i128) -> Self { + Self::new(secs * FEMTOS_PER_SEC) + } + + /// Create a new [`Duration`] from the number of seconds in `f64` format + /// + /// Will truncate to the nearest femtosecond + #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] + #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] + pub const fn from_seconds_f64(secs: f64) -> Self { + Self::new((secs * FEMTOS_PER_SEC as f64) as i128) + } + + /// Create a new [`Duration`] from the number of milliseconds in `f64` format + /// + /// Will truncate to the nearest femtosecond + #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] + #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] + pub const fn from_millis_f64(millis: f64) -> Self { + Self::new((millis * FEMTOS_PER_MILLI as f64) as i128) + } + + /// Create a new [`Duration`] from the number of microseconds in `f64` format + /// + /// Will truncate to the nearest femtosecond + #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] + #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] + pub const fn from_micros_f64(micros: f64) -> Self { + Self::new((micros * FEMTOS_PER_MICRO as f64) as i128) + } + + /// Create a new [`Duration`] from the number of nanoseconds in `f64` format + /// + /// Will truncate to the nearest femtosecond + #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] + pub const fn from_nanos_f64(nanos: f64) -> Self { + Self::new((nanos * FEMTOS_PER_NANO as f64) as i128) + } + + /// Create a new [`Duration`] from the number of milliseconds + pub const fn from_millis(millis: i128) -> Self { + Self::new(millis * FEMTOS_PER_MILLI) + } + + /// Create a new [`Duration`] from the number of microseconds + pub const fn from_micros(micros: i128) -> Self { + Self::new(micros * FEMTOS_PER_MICRO) + } + + /// Create a new [`Duration`] from the number of nanoseconds + pub const fn from_nanos(nanos: i128) -> Self { + Self::new(nanos * FEMTOS_PER_NANO) + } + + /// Create a new [`Duration`] from the number of picoseconds + pub const fn from_picos(picos: i128) -> Self { + Self::new(picos * FEMTOS_PER_PICO) + } + + /// Create a new [`Duration`] from the number of femtoseconds + pub const fn from_femtos(femtos: i128) -> Self { + Self::new(femtos) + } + + /// Create a new [`Duration`] from the number of minutes + pub const fn from_minutes(minutes: i128) -> Self { + Self::new(minutes * FEMTOS_PER_SEC * SECS_PER_MINUTE) + } + + /// Create a new [`Duration`] from the number of hours + pub const fn from_hours(hours: i128) -> Self { + Self::new(hours * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + } + + /// Create a new [`Duration`] from the number of days + pub const fn from_days(days: i128) -> Self { + Self::new(days * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + } + + /// Construct from seconds and nanos + /// + /// # Panics + /// Panics if `nanos >= 1_000_000_000`, or value does not fit within the type + pub fn from_time(secs: i128, nanos: u32) -> Self { + assert!(nanos < 1_000_000_000, "nanos must be less than 1 second"); + + let secs = Self::from_secs(secs); + let nanos = Self::from_nanos(i128::from(nanos)); + secs + nanos + } + + /// Returns the total number of femtoseconds + pub const fn as_femtos(self) -> i128 { + self.get() + } + + /// Returns the total number of picoseconds, truncated + pub const fn as_picos(self) -> i128 { + self.get() / FEMTOS_PER_PICO + } + + /// Returns the total number of nanoseconds, truncated + pub const fn as_nanos(self) -> i128 { + self.get() / FEMTOS_PER_NANO + } + + /// Returns the total number of microseconds, truncated + pub const fn as_micros(self) -> i128 { + self.get() / FEMTOS_PER_MICRO + } + + /// Returns the total number of milliseconds, truncated + pub const fn as_millis(self) -> i128 { + self.get() / FEMTOS_PER_MILLI + } + + /// Returns the total number of seconds, truncated + pub const fn as_seconds(self) -> i128 { + self.get() / FEMTOS_PER_SEC + } + + /// Returns the total number of seconds as a f64 + #[expect(clippy::cast_precision_loss, reason = "division mitigates")] + pub const fn as_seconds_f64(self) -> f64 { + self.get() as f64 / FEMTOS_PER_SEC as f64 + } + + /// Returns the total number of minutes, truncated + pub const fn as_minutes(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE) + } + + /// Returns the total number of hours, truncated + pub const fn as_hours(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + } + + /// Returns the total number of days, truncated + pub const fn as_days(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + } +} + +pub(crate) const FEMTOS_PER_SEC: i128 = 1_000_000_000_000_000; +pub(crate) const FEMTOS_PER_MILLI: i128 = 1_000_000_000_000; +pub(crate) const FEMTOS_PER_MICRO: i128 = 1_000_000_000; +pub(crate) const FEMTOS_PER_NANO: i128 = 1_000_000; +pub(crate) const FEMTOS_PER_PICO: i128 = 1_000; +pub(crate) const SECS_PER_MINUTE: i128 = 60; +pub(crate) const MINS_PER_HOUR: i128 = 60; +pub(crate) const HOURS_PER_DAY: i128 = 24; + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn secs() { + let time = Instant::from_secs(1); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_femtos(), 1_000_000_000_000_000); + } + + #[test] + fn nanos() { + let time = Instant::from_nanos(1); + assert_eq!(time.as_nanos(), 1); + } + + #[test] + fn millis() { + let time = Instant::from_millis(1); + assert_eq!(time.as_millis(), 1); + assert_eq!(time.as_nanos(), 1_000_000); + } + + #[test] + fn micros() { + let time = Instant::from_micros(1); + assert_eq!(time.as_micros(), 1); + assert_eq!(time.as_nanos(), 1_000); + } + + #[test] + fn rounding() { + let time = Instant::from_time(1, 500_000_000); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_nanos(), 1_500_000_000); + } + + #[test] + fn minutes() { + let time = Instant::from_minutes(1); + assert_eq!(time.as_minutes(), 1); + assert_eq!(time.as_nanos(), 60_000_000_000); + } + + #[test] + fn hours() { + let time = Instant::from_hours(1); + assert_eq!(time.as_hours(), 1); + assert_eq!(time.as_nanos(), 3_600_000_000_000); + } + + #[test] + fn days() { + let time = Instant::from_days(1); + assert_eq!(time.as_days(), 1); + assert_eq!(time.as_nanos(), 86_400_000_000_000); + } + + #[test] + fn duration_secs() { + let time = Instant::from_secs(1); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_nanos(), 1_000_000_000); + } + + #[test] + fn duration_nanos() { + let time = Instant::from_nanos(1); + assert_eq!(time.as_nanos(), 1); + } + + #[test] + fn duration_millis() { + let time = Duration::from_millis(1); + assert_eq!(time.as_millis(), 1); + assert_eq!(time.as_nanos(), 1_000_000); + } + + #[test] + fn duration_micros() { + let time = Duration::from_micros(1); + assert_eq!(time.as_micros(), 1); + assert_eq!(time.as_nanos(), 1_000); + } + + #[test] + fn duration_truncating() { + let time = Duration::from_nanos(1_500_000_000); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_nanos(), 1_500_000_000); + } + + #[test] + fn duration_minutes() { + let time = Duration::from_minutes(1); + assert_eq!(time.as_minutes(), 1); + assert_eq!(time.as_nanos(), 60_000_000_000); + } + + #[test] + fn duration_hours() { + let time = Duration::from_hours(1); + assert_eq!(time.as_hours(), 1); + assert_eq!(time.as_nanos(), 3_600_000_000_000); + } + + #[test] + fn duration_days() { + let time = Duration::from_days(1); + assert_eq!(time.as_days(), 1); + assert_eq!(time.as_nanos(), 86_400_000_000_000); + } + + #[test] + fn duration_constructor() { + let time = Duration::from_time(1, 500_000_000); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_nanos(), 1_500_000_000); + } + + #[test] + fn duration_seconds_f64_conversion() { + let duration = Duration::from_seconds_f64(1.5); + assert_eq!(duration.as_nanos(), 1_500_000_000); + approx::assert_abs_diff_eq!(duration.as_seconds_f64(), 1.5); + } + + #[test] + fn duration_millis_f64_conversion() { + let duration = Duration::from_millis_f64(1.5); + assert_eq!(duration.as_nanos(), 1_500_000); + } + + #[test] + fn duration_micros_f64_conversion() { + let duration = Duration::from_micros_f64(1.5); + assert_eq!(duration.as_nanos(), 1_500); + } + + #[test] + fn duration_nanos_f64_conversion() { + let duration = Duration::from_nanos_f64(1.5); + assert_eq!(duration.as_nanos(), 1); + } +} diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs new file mode 100644 index 0000000..c767522 --- /dev/null +++ b/clock-bound/src/daemon/time/tsc.rs @@ -0,0 +1,391 @@ +//! Time stamp counter (TSC) values +#![expect(clippy::cast_possible_truncation)] +#![expect(clippy::cast_precision_loss)] + +use super::Duration; +use super::inner::{Diff, Time}; +use std::{ + fmt::Display, + ops::{Div, Mul, MulAssign}, +}; + +use serde::{Deserialize, Serialize}; + +/// Marker type to crate a raw timestamp with [`super::inner::Time`] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Tsc; + +impl crate::private::Sealed for Tsc {} +impl super::inner::Type for Tsc {} + +/// Abstract representation of a time stamp counter. +/// +/// The way this value used is that the difference between 2 [`TscCount`] values +/// is some number of ticks. And then `a priori` or derived knowledge of the time source can be +/// used to convert this difference into a span of time +/// +/// This value could come from various different forms, for example +/// from a `CLOCK_MONOTONIC_RAW` `clock_gettime` read, or reading a TSC via `rdtsc` on x86 platforms. +/// +/// This value has no unit aside from `Count`. It is the job of clock sync algorithms to convert this meaningfully into time. +pub type TscCount = Time; + +/// Corresponding duration type for [`TscCount`] +pub type TscDiff = Diff; + +/// A frequency in Hz +/// +/// ## Note on lossy-ness +/// All time durations are stored internally as `i128` values, +/// and this includes period values of ticks . This means it is possible +/// to store frequency values that will have precision loss when converted into +/// a `period` type and vice versa. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Frequency(f64); + +impl Frequency { + /// Get inner value in hz + pub fn get(self) -> f64 { + self.0 + } + + /// Construct from Ghz + /// + /// # Panics + /// Panics if the input is not positive + pub fn from_ghz(ghz: f64) -> Self { + Self::from_hz(ghz * 1_000_000_000.0) + } + + /// Construct from Mhz + /// + /// # Panics + /// Panics if the input is not positive + pub fn from_mhz(mhz: f64) -> Self { + Self::from_hz(mhz * 1_000_000.0) + } + + /// Construct From Khz + /// + /// # Panics + /// Panics if the input is not positive + pub fn from_khz(khz: f64) -> Self { + Self::from_hz(khz * 1_000.0) + } + + /// Construct from Hz + /// + /// # Panics + /// Panics if the input is not positive + pub fn from_hz(hz: f64) -> Self { + assert!(hz > 0.0); + Self(hz) + } + + /// Convert into a [`Period`] + pub fn period(self) -> Period { + Period::from_frequency(self) + } +} + +impl Display for Frequency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} Hz", self.0) + } +} + +impl TryFrom for Frequency { + type Error = &'static str; + + fn try_from(value: f64) -> Result { + if value <= 0.0 { + Err("Frequency must be positive") + } else { + Ok(Self(value)) + } + } +} + +impl Div for TscDiff { + type Output = Duration; + + fn div(self, rhs: Frequency) -> Self::Output { + let raw = self.get() as f64; + let duration_femtos = raw / rhs.0 * 1.0e15; + Duration::from_femtos(duration_femtos as i128) + } +} + +impl Mul for Duration { + type Output = TscDiff; + + fn mul(self, rhs: Frequency) -> Self::Output { + let duration_femtos = self.as_femtos() as f64; + let raw = duration_femtos * rhs.0 / 1.0e15; + TscDiff::new(raw as i128) + } +} + +impl Mul for Frequency { + type Output = Self; + + fn mul(self, rhs: f64) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl Mul for f64 { + type Output = Frequency; + + fn mul(self, rhs: Frequency) -> Self::Output { + rhs * self + } +} + +impl MulAssign for Frequency { + fn mul_assign(&mut self, rhs: f64) { + self.0 *= rhs; + } +} + +/// A convenience type to denoting skew +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)] +pub struct Skew(f64); + +impl Skew { + const PPM: f64 = 1.0e-6; + const PERCENT: f64 = 0.01; + + /// Construct a new skew from parts per million (ppm) + pub fn from_ppm(skew: f64) -> Self { + Self(skew * Self::PPM) + } + + /// Construct a new skew from percentage + pub fn from_percent(skew: f64) -> Self { + Self(skew * Self::PERCENT) + } + + /// Get the inner value + pub fn get(self) -> f64 { + self.0 + } +} + +impl Display for Skew { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ppm", self.0 / Self::PPM) + } +} + +/// A representation of a TSC clock period +/// +/// Logically, this value is the mathematical inverse of [`Frequency`]. In other words, +/// `[Period] = 1 / [Frequency]` +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Period(Duration); + +impl Period { + /// Construct from a duration + /// + /// # Panics + /// Panics if `duration <= 0` + pub fn from_duration(duration: Duration) -> Self { + assert!(duration.get() > 0); + Self(duration) + } + + /// Get the inner duration + pub fn get(self) -> Duration { + self.0 + } + + /// Construct from a [`Frequency`] + /// + /// # Precision loss + /// Given that there is a floating point to integer conversion, precision loss can be + /// seen from either large (> 1 PHz) or small (< 1 Hz) frequency values. + pub fn from_frequency(frequency: Frequency) -> Self { + // no assert statement. Frequency can't be negative + let period_femtos = 1.0e15 / frequency.0; + let period_femtos = period_femtos.round() as i128; + let period = Duration::from_femtos(period_femtos); + Self::from_duration(period) + } +} + +impl Mul for TscDiff { + type Output = Duration; + + fn mul(self, rhs: Period) -> Self::Output { + let dur = self.get() * rhs.get().as_femtos(); + Duration::from_femtos(dur) + } +} + +impl Mul for Period { + type Output = Duration; + + fn mul(self, rhs: TscDiff) -> Self::Output { + rhs * self + } +} + +impl Div for Duration { + type Output = Period; + + fn div(self, rhs: TscDiff) -> Self::Output { + let period = self.as_femtos() / rhs.get(); + Period::from_duration(Duration::from_femtos(period)) + } +} + +impl Div for Duration { + type Output = TscDiff; + + fn div(self, rhs: Period) -> Self::Output { + let diff = self.as_femtos() / rhs.get().as_femtos(); + TscDiff::new(diff) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_abs_diff_eq; + use rstest::rstest; + + #[test] + #[expect(clippy::similar_names)] + fn frequency_conversions() { + let f_ghz = Frequency::from_ghz(1.0); + let f_mhz = Frequency::from_mhz(1000.0); + let f_khz = Frequency::from_khz(1_000_000.0); + let f_hz = Frequency::from_hz(1_000_000_000.0); + + assert_abs_diff_eq!(f_ghz.0, 1_000_000_000.0); + assert_abs_diff_eq!(f_mhz.0, 1_000_000_000.0); + assert_abs_diff_eq!(f_khz.0, 1_000_000_000.0); + assert_abs_diff_eq!(f_hz.0, 1_000_000_000.0); + } + + #[test] + fn frequency_period() { + let f = Frequency::from_hz(10.0); // 10 Hz = 0.1 seconds period + let period = f.period(); + assert_eq!(period.get(), Duration::from_millis(100)); + } + + #[test] + fn tsc_diff_div_frequency() { + let diff = TscDiff::new(1000); + let freq = Frequency::from_hz(100.0); + let result = diff / freq; + + // 1000 ticks at 100Hz = 10 seconds + assert_eq!(result, Duration::from_secs(10)); + } + + #[test] + fn duration_mul_frequency() { + let duration = Duration::from_secs(1); + let freq = Frequency::from_hz(100.0); + let result = duration * freq; + + // 1 second at 100Hz = 100 ticks + assert_eq!(result.get(), 100); + } + + #[rstest] + #[case(1.0, true)] + #[case(0.0, false)] + #[case(-1.0, false)] + fn frequency_validation(#[case] frequency: f64, #[case] is_ok: bool) { + assert_eq!(Frequency::try_from(frequency).is_ok(), is_ok); + } + + #[test] + fn skew_from_ppm() { + let skew = Skew::from_ppm(100.0); + assert_abs_diff_eq!(skew.get(), 100.0 * 1.0e-6); + } + + #[test] + fn skew_from_percent() { + let skew = Skew::from_percent(5.0); + assert_abs_diff_eq!(skew.get(), 0.05); + } + + #[test] + fn skew_display() { + let skew = Skew::from_ppm(100.0); + assert_eq!(skew.to_string(), "100 ppm"); + } + + #[test] + fn tsc_diff_mul_period() { + let tsc_diff = TscDiff::new(1000); + let period = Period::from_duration(Duration::from_millis(10)); + let result = tsc_diff * period; + + assert_eq!(result, Duration::from_secs(10)); + } + + #[test] + fn duration_div_period() { + let tsc_diff = Duration::from_secs(1); + let period = Period::from_duration(Duration::from_millis(10)); + let result = tsc_diff / period; + + assert_eq!(result.get(), 100); + } + + #[test] + fn frequency_multiplication() { + let freq = Frequency::from_hz(100.0); + let result = freq * 2.0; + assert_abs_diff_eq!(result.get(), 200.0); + + let result = 2.0 * freq; + assert_abs_diff_eq!(result.get(), 200.0); + } + + #[test] + fn frequency_mul_assign() { + let mut freq = Frequency::from_hz(100.0); + freq *= 2.0; + assert_abs_diff_eq!(freq.get(), 200.0); + } + + #[test] + fn period_from_frequency() { + let freq = Frequency::from_hz(1000.0); + let period = Period::from_frequency(freq); + assert_eq!(period.get(), Duration::from_micros(1000)); + } + + #[test] + fn test_duration_period_operations() { + let duration = Duration::from_secs(2); + let period = Period::from_duration(Duration::from_millis(500)); + + // Test duration / period + let tsc_diff = duration / period; + assert_eq!(tsc_diff.get(), 4); // 2 seconds / 500ms = 4 ticks + + // Test reverse operation + let result_duration = tsc_diff * period; + assert_eq!(result_duration, duration); + } + + #[test] + fn skew_calculations() { + let ppm_skew = Skew::from_ppm(100.0); + let percent_skew = Skew::from_percent(1.0); + + assert_abs_diff_eq!(ppm_skew.get(), 100.0e-6); + assert_abs_diff_eq!(percent_skew.get(), 0.01); + } +} diff --git a/clock-bound/src/lib.rs b/clock-bound/src/lib.rs index 93d7f1e..dd5bb61 100644 --- a/clock-bound/src/lib.rs +++ b/clock-bound/src/lib.rs @@ -8,4 +8,10 @@ pub mod shm; pub mod vmclock; #[cfg(feature = "daemon")] -pub mod daemon; \ No newline at end of file +pub mod daemon; + +mod private { + // define a crate sealed trait + // https://rust-lang.github.io/api-guidelines/future-proofing.html#sealed-traits-protect-against-downstream-implementations-c-sealed + pub trait Sealed {} +} diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 9e2e4fd..2dbb5a9 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -8,7 +8,6 @@ // if the write feature is selected. There may be a better way to do that and re-enable the lint. #![allow(dead_code)] - pub mod common; mod reader; mod shm_header; @@ -22,7 +21,7 @@ use errno::Errno; use nix::sys::time::{TimeSpec, TimeValLike}; use std::ffi::CStr; -use common::{clock_gettime_safe, CLOCK_MONOTONIC, CLOCK_REALTIME}; +use common::{CLOCK_MONOTONIC, CLOCK_REALTIME, clock_gettime_safe}; const CLOCKBOUND_RESTART_GRACE_PERIOD: TimeSpec = TimeSpec::new(5, 0); diff --git a/clock-bound/src/shm/common.rs b/clock-bound/src/shm/common.rs index 36dd1f8..3add015 100644 --- a/clock-bound/src/shm/common.rs +++ b/clock-bound/src/shm/common.rs @@ -1,6 +1,6 @@ -use crate::{syserror, shm::ShmError}; +use crate::{shm::ShmError, syserror}; use nix::sys::time::TimeSpec; -use nix::time::{clock_gettime, ClockId}; +use nix::time::{ClockId, clock_gettime}; pub const CLOCK_REALTIME: ClockId = ClockId::CLOCK_REALTIME; diff --git a/clock-bound/src/shm/reader.rs b/clock-bound/src/shm/reader.rs index 32ec61e..4276905 100644 --- a/clock-bound/src/shm/reader.rs +++ b/clock-bound/src/shm/reader.rs @@ -1,11 +1,14 @@ -use errno::{errno, Errno}; -use std::ffi::{c_void, CStr}; +use errno::{Errno, errno}; +use std::ffi::{CStr, c_void}; use std::mem::size_of; use std::ptr; use std::sync::atomic; -use crate::shm::shm_header::{ShmHeader, CLOCKBOUND_SHM_SUPPORTED_VERSION}; -use crate::{syserror, shm::{ClockErrorBound, ShmError}}; +use crate::shm::shm_header::{CLOCKBOUND_SHM_SUPPORTED_VERSION, ShmHeader}; +use crate::{ + shm::{ClockErrorBound, ShmError}, + syserror, +}; /// A guard tracking an open file descriptor. /// @@ -214,7 +217,10 @@ impl ShmReader { if version == 0 { return Ok(&self.snapshot_ceb); } else if version != CLOCKBOUND_SHM_SUPPORTED_VERSION { - eprintln!("ClockBound shared memory segment has version {:?} which is not supported by this software.", version); + eprintln!( + "ClockBound shared memory segment has version {:?} which is not supported by this software.", + version + ); return Err(ShmError::SegmentVersionNotSupported); } diff --git a/clock-bound/src/shm/shm_header.rs b/clock-bound/src/shm/shm_header.rs index 6fea804..8cf54e2 100644 --- a/clock-bound/src/shm/shm_header.rs +++ b/clock-bound/src/shm/shm_header.rs @@ -1,7 +1,7 @@ -use std::mem::{size_of, MaybeUninit}; +use std::mem::{MaybeUninit, size_of}; use std::sync::atomic; -use crate::{syserror, shm::ShmError}; +use crate::{shm::ShmError, syserror}; /// The magic number that identifies a ClockErrorBound shared memory segment. pub const SHM_MAGIC: [u32; 2] = [0x414D5A4E, 0x43420200]; @@ -46,7 +46,7 @@ impl ShmHeader { } { ret if ret < 0 => return syserror!("read SHM segment"), ret if (ret as usize) < size_of::() => { - return Err(ShmError::SegmentNotInitialized) + return Err(ShmError::SegmentNotInitialized); } _ => (), }; @@ -100,7 +100,10 @@ impl ShmHeader { // supported by this implementation of ClockBound. let version = self.version.load(atomic::Ordering::Relaxed); if version != CLOCKBOUND_SHM_SUPPORTED_VERSION { - eprintln!("ClockBound shared memory segment has version {:?} which is not supported by this software.", version); + eprintln!( + "ClockBound shared memory segment has version {:?} which is not supported by this software.", + version + ); return Err(ShmError::SegmentVersionNotSupported); } diff --git a/clock-bound/src/shm/writer.rs b/clock-bound/src/shm/writer.rs index 71fcc4c..89c9fae 100644 --- a/clock-bound/src/shm/writer.rs +++ b/clock-bound/src/shm/writer.rs @@ -1,5 +1,5 @@ use byteorder::{NativeEndian, WriteBytesExt}; -use std::ffi::{c_void, CString}; +use std::ffi::{CString, c_void}; use std::io::{Error, ErrorKind}; use std::mem::size_of; use std::path::Path; @@ -11,7 +11,7 @@ use std::io::Write; use std::os::unix::ffi::OsStrExt; use crate::shm::reader::ShmReader; -use crate::shm::shm_header::{ShmHeader, CLOCKBOUND_SHM_SUPPORTED_VERSION, SHM_MAGIC}; +use crate::shm::shm_header::{CLOCKBOUND_SHM_SUPPORTED_VERSION, SHM_MAGIC, ShmHeader}; use crate::shm::{ClockErrorBound, ShmError}; /// Trait that a writer to the shared memory segment has to implement. @@ -163,7 +163,7 @@ impl ShmWriter { return Err(Error::new( ErrorKind::Other, "Failed to extract parent dir name", - )) + )); } } } @@ -182,7 +182,7 @@ impl ShmWriter { "Failed to convert segment size {:?} into u32 {:?}", segsize, e ), - )) + )); } }; diff --git a/clock-bound/src/vmclock.rs b/clock-bound/src/vmclock.rs index d674226..31ddda3 100644 --- a/clock-bound/src/vmclock.rs +++ b/clock-bound/src/vmclock.rs @@ -1,8 +1,8 @@ use std::ffi::CString; use tracing::debug; -use crate::vmclock::shm_reader::VMClockShmReader; use crate::shm::{ClockStatus, ShmError, ShmReader}; +use crate::vmclock::shm_reader::VMClockShmReader; use nix::sys::time::TimeSpec; pub mod shm; @@ -68,9 +68,12 @@ impl VMClock { // Comparing the disruption marker between the VMClock snapshot and the // ClockBound snapshot will tell us if the clock status provided by the // ClockBound daemon is trustworthy. - debug!("clock_status: {:?}, vmclock_snapshot.disruption_marker: {:?}, clockbound_snapshot.disruption_marker: {:?}", - clock_status, vmclock_snapshot.disruption_marker, - clockbound_snapshot.disruption_marker); + debug!( + "clock_status: {:?}, vmclock_snapshot.disruption_marker: {:?}, clockbound_snapshot.disruption_marker: {:?}", + clock_status, + vmclock_snapshot.disruption_marker, + clockbound_snapshot.disruption_marker + ); if vmclock_snapshot.disruption_marker == clockbound_snapshot.disruption_marker { // ClockBound's shared memory segment has the latest clock disruption status from diff --git a/clock-bound/src/vmclock/shm.rs b/clock-bound/src/vmclock/shm.rs index b151e7b..2bb67e7 100644 --- a/clock-bound/src/vmclock/shm.rs +++ b/clock-bound/src/vmclock/shm.rs @@ -12,8 +12,8 @@ use std::mem::size_of; use std::str::FromStr; use std::sync::atomic; -use crate::syserror; use crate::shm::ShmError; +use crate::syserror; use tracing::{debug, error}; pub const VMCLOCK_SHM_DEFAULT_PATH: &str = "/dev/vmclock0"; diff --git a/clock-bound/src/vmclock/shm_reader.rs b/clock-bound/src/vmclock/shm_reader.rs index e6d7cc5..0dba469 100644 --- a/clock-bound/src/vmclock/shm_reader.rs +++ b/clock-bound/src/vmclock/shm_reader.rs @@ -7,9 +7,9 @@ use std::ptr; use std::sync::atomic; use tracing::{debug, error}; -use crate::vmclock::shm::{VMClockShmBody, VMClockShmHeader}; -use crate::syserror; use crate::shm::ShmError; +use crate::syserror; +use crate::vmclock::shm::{VMClockShmBody, VMClockShmHeader}; const VMCLOCK_SUPPORTED_VERSION: u16 = 1; @@ -44,7 +44,11 @@ impl MmapGuard { error!("MmapGuard: Read zero bytes."); return Err(ShmError::SegmentNotInitialized); } else if bytes_read < size_of::() { - error!("MmapGuard: Number of bytes read ({:?}) is less than the size of VMClockShmHeader ({:?}).", bytes_read, size_of::()); + error!( + "MmapGuard: Number of bytes read ({:?}) is less than the size of VMClockShmHeader ({:?}).", + bytes_read, + size_of::() + ); return Err(ShmError::SegmentMalformed); } @@ -187,7 +191,10 @@ impl VMClockShmReader { let version = unsafe { &*version_ptr }; let version_number = version.load(atomic::Ordering::Acquire); if version_number != VMCLOCK_SUPPORTED_VERSION { - error!("VMClock shared memory segment has version {:?} which is not supported by this version of the VMClockShmReader.", version_number); + error!( + "VMClock shared memory segment has version {:?} which is not supported by this version of the VMClockShmReader.", + version_number + ); return Err(ShmError::SegmentVersionNotSupported); } @@ -248,7 +255,10 @@ impl VMClockShmReader { // We are validating the version prior to each snapshot to protect // against a Hypervisor which has implemented an unsupported VMClock version. if version != VMCLOCK_SUPPORTED_VERSION { - error!("VMClock shared memory segment has version {:?} which is not supported by this version of the VMClockShmReader.", version); + error!( + "VMClock shared memory segment has version {:?} which is not supported by this version of the VMClockShmReader.", + version + ); return Err(ShmError::SegmentVersionNotSupported); } diff --git a/clock-bound/src/vmclock/shm_writer.rs b/clock-bound/src/vmclock/shm_writer.rs index b66548f..e67d5e1 100644 --- a/clock-bound/src/vmclock/shm_writer.rs +++ b/clock-bound/src/vmclock/shm_writer.rs @@ -10,9 +10,9 @@ use std::{fs, ptr}; use tracing::debug; -use crate::vmclock::shm::{VMClockShmBody, VMClockShmHeader, VMCLOCK_SHM_MAGIC}; -use crate::vmclock::shm_reader::VMClockShmReader; use crate::shm::ShmError; +use crate::vmclock::shm::{VMCLOCK_SHM_MAGIC, VMClockShmBody, VMClockShmHeader}; +use crate::vmclock::shm_reader::VMClockShmReader; /// Trait that a writer to the shared memory segment has to implement. pub trait VMClockShmWrite { @@ -160,7 +160,7 @@ impl VMClockShmWriter { return Err(Error::new( ErrorKind::Other, "Failed to extract parent dir name", - )) + )); } } } @@ -179,7 +179,7 @@ impl VMClockShmWriter { "Failed to convert segment size {:?} into u32 {:?}", segsize, e ), - )) + )); } }; From 08d87eba4759f48a481dace22e5c2e26a14d6684 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 30 Sep 2025 15:34:20 -0400 Subject: [PATCH 009/177] Add hello world test on using the PHC (#13) --- .github/workflows/hello_phc.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/hello_phc.yml diff --git a/.github/workflows/hello_phc.yml b/.github/workflows/hello_phc.yml new file mode 100644 index 0000000..2945cbf --- /dev/null +++ b/.github/workflows/hello_phc.yml @@ -0,0 +1,17 @@ +name: Hello Phc + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + Hello-Phc: + runs-on: + - codebuild-StagingClockBound-${{ github.run_id }}-${{ github.run_attempt }} + buildspec-override:true + steps: + - run: echo "Hello World!" + - run: ls /dev/ + - run: ls /dev/ptp_ena \ No newline at end of file From 122eee39c367b0fd2f24824a6b529ca4b6b7e33d Mon Sep 17 00:00:00 2001 From: Tom Phan Date: Wed, 1 Oct 2025 15:10:07 +0000 Subject: [PATCH 010/177] Implement Clock Adjustment in ClockState component The ClockState component will manage both adjusting the underlying kernel clocks (like any other time sync daemon), plus updating the ClockBound SHM segment. This commit implements that functionality on a unit struct for ClockState for now, which supports a method `adjust_clock`. It takes a `phase_correction` as input (to avoid the ambiguous "offset" phrasing that confuses us all), and a `skew`, representing the frequency error that the underlying oscillator exhibits. We pass the phase correction to the kernel PLL to correct, and simply write the `skew` value into the `freq` value used by the kernel timekeeping utilities, all in a single call. --- Cargo.lock | 289 ++++++++++++++++++-------- clock-bound/Cargo.toml | 4 + clock-bound/src/daemon/clock_state.rs | 222 ++++++++++++++++++++ clock-bound/src/daemon/time/tsc.rs | 11 + 4 files changed, 445 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1f0ee7..acffa7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -61,6 +61,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "approx" version = "0.5.1" @@ -72,9 +78,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.1.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" @@ -84,9 +90,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.2" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "byteorder" @@ -96,9 +102,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "clap" @@ -144,14 +150,18 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" name = "clock-bound" version = "2.0.3" dependencies = [ + "anyhow", "approx", "byteorder", + "clap", "errno", "libc", + "mockall", "nix", "rstest", "serde", "tempfile", + "thiserror", "tracing", "tracing-subscriber", ] @@ -194,6 +204,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "equivalent" version = "1.0.2" @@ -202,12 +218,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.1", ] [[package]] @@ -216,6 +232,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "futures-core" version = "0.3.31" @@ -261,14 +283,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", + "r-efi", "wasi", - "windows-targets 0.52.0", ] [[package]] @@ -319,15 +341,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.170" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "linux-raw-sys" -version = "0.9.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "log" @@ -337,9 +359,9 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" @@ -350,6 +372,32 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "nix" version = "0.26.4" @@ -383,9 +431,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" @@ -405,6 +453,32 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -425,18 +499,24 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "regex" -version = "1.11.2" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", @@ -446,9 +526,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", @@ -507,15 +587,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.2" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.1", ] [[package]] @@ -532,9 +612,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -542,18 +622,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -613,16 +693,41 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.18.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ - "cfg-if", "fastrand", "getrandom", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.1", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -768,18 +873,27 @@ dependencies = [ [[package]] name = "wasi" -version = "0.13.3+wasi-0.2.2" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen-rt", + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", ] [[package]] name = "windows-link" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-sys" @@ -787,7 +901,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.6", ] [[package]] @@ -796,35 +910,45 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.4", +] + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link", ] [[package]] name = "windows-targets" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.0", - "windows_aarch64_msvc 0.52.0", - "windows_i686_gnu 0.52.0", - "windows_i686_msvc 0.52.0", - "windows_x86_64_gnu 0.52.0", - "windows_x86_64_gnullvm 0.52.0", - "windows_x86_64_msvc 0.52.0", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.53.0", "windows_i686_msvc 0.53.0", "windows_x86_64_gnu 0.53.0", "windows_x86_64_gnullvm 0.53.0", @@ -833,9 +957,9 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" @@ -845,9 +969,9 @@ checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" [[package]] name = "windows_aarch64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" @@ -857,9 +981,9 @@ checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" [[package]] name = "windows_i686_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" @@ -867,6 +991,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.0" @@ -875,9 +1005,9 @@ checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" [[package]] name = "windows_i686_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" @@ -887,9 +1017,9 @@ checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" [[package]] name = "windows_x86_64_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" @@ -899,9 +1029,9 @@ checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" @@ -911,9 +1041,9 @@ checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" @@ -931,10 +1061,7 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.33.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" -dependencies = [ - "bitflags 2.4.2", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 249923b..620b628 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -13,16 +13,20 @@ repository.workspace = true version.workspace = true [dependencies] +anyhow = "1" byteorder = "1" +clap = { version = "4.5.31", features = ["derive"] } errno = { version = "0.3.0", default-features = false } libc = { version = "0.2", default-features = false, features = ["extra_traits"] } nix = { version = "0.26", features = ["feature", "time"] } serde = { version = "1.0", features = ["derive"], optional = true } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } +thiserror = "2.0" [dev-dependencies] approx = "0.5" +mockall = "0.13.1" rstest = "0.26" tempfile = "3.13" diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 43d965e..2a872e3 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -1 +1,223 @@ //! Adjust system clock and clockbound shared memory +use errno::Errno; +use libc::{ + ADJ_FREQUENCY, ADJ_NANO, ADJ_OFFSET, ADJ_STATUS, ADJ_TIMECONST, STA_FREQHOLD, STA_PLL, + TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT, ntp_adjtime, timex, +}; +use thiserror::Error; +use tracing::debug; + +use crate::daemon::time::{Duration, tsc::Skew}; + +/// Lightweight trait around `ntp_adjtime` function (formerly `adjtimex`). +/// Useful for mocking, or potentially as an abstraction around modifying +/// other clocks' parameters in the future. +#[cfg_attr(test, mockall::automock)] +pub trait ClockAdjuster { + unsafe fn ntp_adjtime(&self, timex: *mut timex) -> i32; +} + +/// Concrete struct implementing `ntp_adjtime` by delegating to the `libc` +/// implementation. Should be the only actual concrete implementation. +pub struct ClockAdjusterImpl; +impl ClockAdjuster for ClockAdjusterImpl { + unsafe fn ntp_adjtime(&self, timex: *mut timex) -> i32 { + // Safety: `timex` should be initialized by the caller + unsafe { ntp_adjtime(timex) } + } +} + +/// Error type returned when dealing with underlying `adjtimex` or `ntp_adjtime` +/// results. +#[derive(Debug, Error)] +pub enum AdjTimexError { + #[error("Failed to adjust the clock")] + Failure(Errno), + #[error("Unexpected bad state return value from ntp_adjtime")] + BadState(i32), + #[error("Invalid return value from ntp_adjtime")] + InvalidState(i32), +} + +/// Struct which handles adjusting the system clock using `ntp_adjtime`, and +/// modifications of the SHM segment together (for now). The two should likely be decoupled +/// once the ClockBound Client implementation no longer depends on the kernel +/// clocks. +pub struct ClockState; + +impl ClockState { + /// Performs an adjustment of the clock, to apply the given phase correction + /// and skew values, in a single system call. + /// + /// The phase correction will be passed directly to the kernel PLL to correct the system + /// clock via a slew with exponential decaying effect (the proportion % corrected per second + /// is controlled by `tx.constant`). + /// + /// The skew, or frequency error relative to a baseline at 0, is applied directly + /// to the `freq` in the timekeeping utilities, e.g. passing +1ppm will "speed up" the clock + /// by 1ppm. Thus, if the clock is slow by 1 microsecond every second, we should pass in +1ppm. + pub fn adjust_clock( + &self, + clock_adjuster: impl ClockAdjuster, + phase_correction: Duration, + skew: Skew, + ) -> Result<(), AdjTimexError> { + let mut tx: timex = unsafe { std::mem::MaybeUninit::zeroed().assume_init() }; + tx.freq = skew.to_timex_freq(); + tx.offset = phase_correction.as_nanos() as i64; + + // PLL clock adjustment proportion is dependent on this time constant. + // The clock adjustment factor over the length of a second + // is calculated as `shift_right(offset, SHIFT_PLL + ntpdata->time_constant)`, where const `SHIFT_PLL` = 2 + // So, if we want to correct the clock quickly, we use a lower time constant. + // The value is clamped between 0 and 10. + // For now, we use 0, to aggressively correct the clock, which means we'd expect for + // offset to be corrected by `offset >> 2` every second. + tx.constant = 0; + + // Set `modes` bits for all fields we modify, plus ADJ_NANO to use nanosecond units + // and ADJ_STATUS to set status bits below. + tx.modes |= ADJ_FREQUENCY; + tx.modes |= ADJ_OFFSET; + tx.modes |= ADJ_TIMECONST; + tx.modes |= ADJ_NANO; + tx.modes |= ADJ_STATUS; + + // Hold the frequency that we prescribe, if this is omitted the PLL would modify `freq` + // which we do not want since ClockBound's clock sync algorithm should determine the proper + // frequency setting. + tx.status |= STA_FREQHOLD; + // Only rely on PLL to perform phase adjustments + tx.status |= STA_PLL; + + // Make the system call + unsafe { + debug!("calling ntp_adjtime with {:?}", tx); + match clock_adjuster.ntp_adjtime(&mut tx) { + TIME_OK => Ok(()), + cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { + Err(AdjTimexError::BadState(cs)) + } + -1 => Err(AdjTimexError::Failure(errno::errno())), + unexpected => Err(AdjTimexError::InvalidState(unexpected)), + } + } + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::positives(Duration::from_nanos(500), Skew::from_ppm(1.0))] + #[case::negatives(Duration::from_nanos(-500), Skew::from_ppm(-1.0))] + #[case::zeroes(Duration::from_nanos(0), Skew::from_ppm(0.0))] + #[case::positive_offset_negative_skew(Duration::from_nanos(500), Skew::from_ppm(-1.0))] + #[case::negative_offset_positive_skew(Duration::from_nanos(-500), Skew::from_ppm(1.0))] + fn adjust_clock_happy_paths( + #[case] input_phase_correction: Duration, + #[case] input_skew: Skew, + ) { + let mut mock_clock_adjuster = MockClockAdjuster::new(); + let clock_state = ClockState; + + // Set up mock expectations + mock_clock_adjuster + .expect_ntp_adjtime() + .withf(move |tx: &*mut timex| { + let tx = unsafe { **tx }; + debug!("{tx:?}"); + // Verify the timex struct is configured correctly + tx.freq == input_skew.to_timex_freq() + && tx.offset as i128 == input_phase_correction.as_nanos() + && tx.constant == 0 + && tx.modes & ADJ_FREQUENCY != 0 + && tx.modes & ADJ_OFFSET != 0 + && tx.modes & ADJ_TIMECONST != 0 + && tx.modes & ADJ_NANO != 0 + && tx.modes & ADJ_STATUS != 0 + && tx.status & STA_FREQHOLD != 0 + && tx.status & STA_PLL != 0 + }) + .times(1) + .return_const(TIME_OK); + + // Call adjust_clock with test values + let result = + clock_state.adjust_clock(mock_clock_adjuster, input_phase_correction, input_skew); + + assert!(result.is_ok()); + } + + #[test] + fn adjust_clock_failure() { + let mut mock_clock_adjuster = MockClockAdjuster::new(); + let clock_state = ClockState; + + // Set up mock expectations + mock_clock_adjuster + .expect_ntp_adjtime() + .times(1) + .return_const(-1); + + // Call adjust_clock with test values + let result = clock_state.adjust_clock( + mock_clock_adjuster, + Duration::from_nanos(500), + Skew::from_ppm(1.0), + ); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), AdjTimexError::Failure(_))); + } + + #[test] + fn adjust_clock_bad_state() { + let mut mock_clock_adjuster = MockClockAdjuster::new(); + let clock_state = ClockState; + + // Set up mock expectations + mock_clock_adjuster + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_ERROR); + + // Call adjust_clock with test values + let result = clock_state.adjust_clock( + mock_clock_adjuster, + Duration::from_nanos(500), + Skew::from_ppm(1.0), + ); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), AdjTimexError::BadState(_))); + } + + #[test] + fn adjust_clock_unexpected_value() { + let mut mock_clock_adjuster = MockClockAdjuster::new(); + let clock_state = ClockState; + + // Set up mock expectations + mock_clock_adjuster + .expect_ntp_adjtime() + .times(1) + .return_const(12345); + + // Call adjust_clock with test values + let result = clock_state.adjust_clock( + mock_clock_adjuster, + Duration::from_nanos(500), + Skew::from_ppm(1.0), + ); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + AdjTimexError::InvalidState(_) + )); + } +} diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index c767522..f5843b8 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -11,6 +11,8 @@ use std::{ use serde::{Deserialize, Serialize}; +const FREQUENCY_TO_TIMEX_SCALE: f64 = (1 << 16) as f64; + /// Marker type to crate a raw timestamp with [`super::inner::Time`] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Tsc; @@ -171,6 +173,15 @@ impl Skew { pub fn get(self) -> f64 { self.0 } + + /// In struct timex, freq, ppsfreq, and stabil are ppm (parts per + /// million) with a 16-bit fractional part, which means that a value + /// of 1 in one of those fields actually means 2^-16 ppm, and + /// 2^16=65536 is 1 ppm. This is the case for both input values (in + /// the case of freq) and output values. + pub fn to_timex_freq(self) -> i64 { + (FREQUENCY_TO_TIMEX_SCALE * self.0 / Self::PPM) as i64 + } } impl Display for Skew { From c9456acf36623adfa1131e57b5c4f9e9cfcdfde5 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Wed, 1 Oct 2025 12:06:48 -0400 Subject: [PATCH 011/177] Revert "Implement Clock Adjustment in ClockState component" (#16) This reverts commit 122eee39c367b0fd2f24824a6b529ca4b6b7e33d. Unintentional push done by `gh` CLI when local branch was on `main` and Admin role on repo apparently bypasses branch protections. Co-authored-by: Tom Phan --- Cargo.lock | 289 ++++++++------------------ clock-bound/Cargo.toml | 4 - clock-bound/src/daemon/clock_state.rs | 222 -------------------- clock-bound/src/daemon/time/tsc.rs | 11 - 4 files changed, 81 insertions(+), 445 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acffa7d..b1f0ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" @@ -61,12 +61,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - [[package]] name = "approx" version = "0.5.1" @@ -78,9 +72,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "bitflags" @@ -90,9 +84,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" [[package]] name = "byteorder" @@ -102,9 +96,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" @@ -150,18 +144,14 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" name = "clock-bound" version = "2.0.3" dependencies = [ - "anyhow", "approx", "byteorder", - "clap", "errno", "libc", - "mockall", "nix", "rstest", "serde", "tempfile", - "thiserror", "tracing", "tracing-subscriber", ] @@ -204,12 +194,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "downcast" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" - [[package]] name = "equivalent" version = "1.0.2" @@ -218,12 +202,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.14" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.61.1", + "windows-sys 0.52.0", ] [[package]] @@ -232,12 +216,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fragile" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" - [[package]] name = "futures-core" version = "0.3.31" @@ -283,14 +261,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" dependencies = [ "cfg-if", "libc", - "r-efi", "wasi", + "windows-targets 0.52.0", ] [[package]] @@ -341,15 +319,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.176" +version = "0.2.170" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" [[package]] name = "log" @@ -359,9 +337,9 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "memchr" -version = "2.7.6" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "memoffset" @@ -372,32 +350,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mockall" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" -dependencies = [ - "cfg-if", - "downcast", - "fragile", - "mockall_derive", - "predicates", - "predicates-tree", -] - -[[package]] -name = "mockall_derive" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "nix" version = "0.26.4" @@ -431,9 +383,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "once_cell_polyfill" @@ -453,32 +405,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "predicates" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" -dependencies = [ - "anstyle", - "predicates-core", -] - -[[package]] -name = "predicates-core" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" - -[[package]] -name = "predicates-tree" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" -dependencies = [ - "predicates-core", - "termtree", -] - [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -499,24 +425,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "regex" -version = "1.11.3" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -526,9 +446,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", @@ -587,15 +507,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.4.2", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.1", + "windows-sys 0.52.0", ] [[package]] @@ -612,9 +532,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" dependencies = [ "serde_core", "serde_derive", @@ -622,18 +542,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", @@ -693,41 +613,16 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" dependencies = [ + "cfg-if", "fastrand", "getrandom", "once_cell", "rustix", - "windows-sys 0.61.1", -] - -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - -[[package]] -name = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-sys 0.52.0", ] [[package]] @@ -873,27 +768,18 @@ dependencies = [ [[package]] name = "wasi" -version = "0.14.7+wasi-0.2.4" +version = "0.13.3+wasi-0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" dependencies = [ - "wasip2", -] - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", + "wit-bindgen-rt", ] [[package]] name = "windows-link" -version = "0.2.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-sys" @@ -901,7 +787,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.52.0", ] [[package]] @@ -910,45 +796,35 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.4", -] - -[[package]] -name = "windows-sys" -version = "0.61.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" -dependencies = [ - "windows-link", + "windows-targets 0.53.3", ] [[package]] name = "windows-targets" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", ] [[package]] name = "windows-targets" -version = "0.53.4" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", + "windows_i686_gnullvm", "windows_i686_msvc 0.53.0", "windows_x86_64_gnu 0.53.0", "windows_x86_64_gnullvm 0.53.0", @@ -957,9 +833,9 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" [[package]] name = "windows_aarch64_gnullvm" @@ -969,9 +845,9 @@ checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" [[package]] name = "windows_aarch64_msvc" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" [[package]] name = "windows_aarch64_msvc" @@ -981,9 +857,9 @@ checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" [[package]] name = "windows_i686_gnu" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" [[package]] name = "windows_i686_gnu" @@ -991,12 +867,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - [[package]] name = "windows_i686_gnullvm" version = "0.53.0" @@ -1005,9 +875,9 @@ checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" [[package]] name = "windows_i686_msvc" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" [[package]] name = "windows_i686_msvc" @@ -1017,9 +887,9 @@ checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" [[package]] name = "windows_x86_64_gnu" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" [[package]] name = "windows_x86_64_gnu" @@ -1029,9 +899,9 @@ checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" [[package]] name = "windows_x86_64_gnullvm" @@ -1041,9 +911,9 @@ checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" [[package]] name = "windows_x86_64_msvc" -version = "0.52.6" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] name = "windows_x86_64_msvc" @@ -1061,7 +931,10 @@ dependencies = [ ] [[package]] -name = "wit-bindgen" -version = "0.46.0" +name = "wit-bindgen-rt" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags 2.4.2", +] diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 620b628..249923b 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -13,20 +13,16 @@ repository.workspace = true version.workspace = true [dependencies] -anyhow = "1" byteorder = "1" -clap = { version = "4.5.31", features = ["derive"] } errno = { version = "0.3.0", default-features = false } libc = { version = "0.2", default-features = false, features = ["extra_traits"] } nix = { version = "0.26", features = ["feature", "time"] } serde = { version = "1.0", features = ["derive"], optional = true } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } -thiserror = "2.0" [dev-dependencies] approx = "0.5" -mockall = "0.13.1" rstest = "0.26" tempfile = "3.13" diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 2a872e3..43d965e 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -1,223 +1 @@ //! Adjust system clock and clockbound shared memory -use errno::Errno; -use libc::{ - ADJ_FREQUENCY, ADJ_NANO, ADJ_OFFSET, ADJ_STATUS, ADJ_TIMECONST, STA_FREQHOLD, STA_PLL, - TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT, ntp_adjtime, timex, -}; -use thiserror::Error; -use tracing::debug; - -use crate::daemon::time::{Duration, tsc::Skew}; - -/// Lightweight trait around `ntp_adjtime` function (formerly `adjtimex`). -/// Useful for mocking, or potentially as an abstraction around modifying -/// other clocks' parameters in the future. -#[cfg_attr(test, mockall::automock)] -pub trait ClockAdjuster { - unsafe fn ntp_adjtime(&self, timex: *mut timex) -> i32; -} - -/// Concrete struct implementing `ntp_adjtime` by delegating to the `libc` -/// implementation. Should be the only actual concrete implementation. -pub struct ClockAdjusterImpl; -impl ClockAdjuster for ClockAdjusterImpl { - unsafe fn ntp_adjtime(&self, timex: *mut timex) -> i32 { - // Safety: `timex` should be initialized by the caller - unsafe { ntp_adjtime(timex) } - } -} - -/// Error type returned when dealing with underlying `adjtimex` or `ntp_adjtime` -/// results. -#[derive(Debug, Error)] -pub enum AdjTimexError { - #[error("Failed to adjust the clock")] - Failure(Errno), - #[error("Unexpected bad state return value from ntp_adjtime")] - BadState(i32), - #[error("Invalid return value from ntp_adjtime")] - InvalidState(i32), -} - -/// Struct which handles adjusting the system clock using `ntp_adjtime`, and -/// modifications of the SHM segment together (for now). The two should likely be decoupled -/// once the ClockBound Client implementation no longer depends on the kernel -/// clocks. -pub struct ClockState; - -impl ClockState { - /// Performs an adjustment of the clock, to apply the given phase correction - /// and skew values, in a single system call. - /// - /// The phase correction will be passed directly to the kernel PLL to correct the system - /// clock via a slew with exponential decaying effect (the proportion % corrected per second - /// is controlled by `tx.constant`). - /// - /// The skew, or frequency error relative to a baseline at 0, is applied directly - /// to the `freq` in the timekeeping utilities, e.g. passing +1ppm will "speed up" the clock - /// by 1ppm. Thus, if the clock is slow by 1 microsecond every second, we should pass in +1ppm. - pub fn adjust_clock( - &self, - clock_adjuster: impl ClockAdjuster, - phase_correction: Duration, - skew: Skew, - ) -> Result<(), AdjTimexError> { - let mut tx: timex = unsafe { std::mem::MaybeUninit::zeroed().assume_init() }; - tx.freq = skew.to_timex_freq(); - tx.offset = phase_correction.as_nanos() as i64; - - // PLL clock adjustment proportion is dependent on this time constant. - // The clock adjustment factor over the length of a second - // is calculated as `shift_right(offset, SHIFT_PLL + ntpdata->time_constant)`, where const `SHIFT_PLL` = 2 - // So, if we want to correct the clock quickly, we use a lower time constant. - // The value is clamped between 0 and 10. - // For now, we use 0, to aggressively correct the clock, which means we'd expect for - // offset to be corrected by `offset >> 2` every second. - tx.constant = 0; - - // Set `modes` bits for all fields we modify, plus ADJ_NANO to use nanosecond units - // and ADJ_STATUS to set status bits below. - tx.modes |= ADJ_FREQUENCY; - tx.modes |= ADJ_OFFSET; - tx.modes |= ADJ_TIMECONST; - tx.modes |= ADJ_NANO; - tx.modes |= ADJ_STATUS; - - // Hold the frequency that we prescribe, if this is omitted the PLL would modify `freq` - // which we do not want since ClockBound's clock sync algorithm should determine the proper - // frequency setting. - tx.status |= STA_FREQHOLD; - // Only rely on PLL to perform phase adjustments - tx.status |= STA_PLL; - - // Make the system call - unsafe { - debug!("calling ntp_adjtime with {:?}", tx); - match clock_adjuster.ntp_adjtime(&mut tx) { - TIME_OK => Ok(()), - cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { - Err(AdjTimexError::BadState(cs)) - } - -1 => Err(AdjTimexError::Failure(errno::errno())), - unexpected => Err(AdjTimexError::InvalidState(unexpected)), - } - } - } -} - -#[cfg(test)] -mod test { - use rstest::rstest; - - use super::*; - - #[rstest] - #[case::positives(Duration::from_nanos(500), Skew::from_ppm(1.0))] - #[case::negatives(Duration::from_nanos(-500), Skew::from_ppm(-1.0))] - #[case::zeroes(Duration::from_nanos(0), Skew::from_ppm(0.0))] - #[case::positive_offset_negative_skew(Duration::from_nanos(500), Skew::from_ppm(-1.0))] - #[case::negative_offset_positive_skew(Duration::from_nanos(-500), Skew::from_ppm(1.0))] - fn adjust_clock_happy_paths( - #[case] input_phase_correction: Duration, - #[case] input_skew: Skew, - ) { - let mut mock_clock_adjuster = MockClockAdjuster::new(); - let clock_state = ClockState; - - // Set up mock expectations - mock_clock_adjuster - .expect_ntp_adjtime() - .withf(move |tx: &*mut timex| { - let tx = unsafe { **tx }; - debug!("{tx:?}"); - // Verify the timex struct is configured correctly - tx.freq == input_skew.to_timex_freq() - && tx.offset as i128 == input_phase_correction.as_nanos() - && tx.constant == 0 - && tx.modes & ADJ_FREQUENCY != 0 - && tx.modes & ADJ_OFFSET != 0 - && tx.modes & ADJ_TIMECONST != 0 - && tx.modes & ADJ_NANO != 0 - && tx.modes & ADJ_STATUS != 0 - && tx.status & STA_FREQHOLD != 0 - && tx.status & STA_PLL != 0 - }) - .times(1) - .return_const(TIME_OK); - - // Call adjust_clock with test values - let result = - clock_state.adjust_clock(mock_clock_adjuster, input_phase_correction, input_skew); - - assert!(result.is_ok()); - } - - #[test] - fn adjust_clock_failure() { - let mut mock_clock_adjuster = MockClockAdjuster::new(); - let clock_state = ClockState; - - // Set up mock expectations - mock_clock_adjuster - .expect_ntp_adjtime() - .times(1) - .return_const(-1); - - // Call adjust_clock with test values - let result = clock_state.adjust_clock( - mock_clock_adjuster, - Duration::from_nanos(500), - Skew::from_ppm(1.0), - ); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), AdjTimexError::Failure(_))); - } - - #[test] - fn adjust_clock_bad_state() { - let mut mock_clock_adjuster = MockClockAdjuster::new(); - let clock_state = ClockState; - - // Set up mock expectations - mock_clock_adjuster - .expect_ntp_adjtime() - .times(1) - .return_const(TIME_ERROR); - - // Call adjust_clock with test values - let result = clock_state.adjust_clock( - mock_clock_adjuster, - Duration::from_nanos(500), - Skew::from_ppm(1.0), - ); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), AdjTimexError::BadState(_))); - } - - #[test] - fn adjust_clock_unexpected_value() { - let mut mock_clock_adjuster = MockClockAdjuster::new(); - let clock_state = ClockState; - - // Set up mock expectations - mock_clock_adjuster - .expect_ntp_adjtime() - .times(1) - .return_const(12345); - - // Call adjust_clock with test values - let result = clock_state.adjust_clock( - mock_clock_adjuster, - Duration::from_nanos(500), - Skew::from_ppm(1.0), - ); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - AdjTimexError::InvalidState(_) - )); - } -} diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index f5843b8..c767522 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -11,8 +11,6 @@ use std::{ use serde::{Deserialize, Serialize}; -const FREQUENCY_TO_TIMEX_SCALE: f64 = (1 << 16) as f64; - /// Marker type to crate a raw timestamp with [`super::inner::Time`] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Tsc; @@ -173,15 +171,6 @@ impl Skew { pub fn get(self) -> f64 { self.0 } - - /// In struct timex, freq, ppsfreq, and stabil are ppm (parts per - /// million) with a 16-bit fractional part, which means that a value - /// of 1 in one of those fields actually means 2^-16 ppm, and - /// 2^16=65536 is 1 ppm. This is the case for both input values (in - /// the case of freq) and output values. - pub fn to_timex_freq(self) -> i64 { - (FREQUENCY_TO_TIMEX_SCALE * self.0 / Self::PPM) as i64 - } } impl Display for Skew { From 320a6f9b642c9b450fa4e98c7de43a647eea7820 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 1 Oct 2025 12:35:14 -0400 Subject: [PATCH 012/177] Add pull_request_target github action to send notifications to slack (#12) * Add pull_request_target github action to send notifications to slack * Potential fix for code scanning alert no. 4: Workflow does not contain permissions --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/pr_target_slack.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/pr_target_slack.yml diff --git a/.github/workflows/pr_target_slack.yml b/.github/workflows/pr_target_slack.yml new file mode 100644 index 0000000..83d6cd1 --- /dev/null +++ b/.github/workflows/pr_target_slack.yml @@ -0,0 +1,21 @@ +name: pr_target_slack + +permissions: + contents: read + +on: + pull_request_target: # Runs on main branch as opposed to `pull_request`, so it gets secrets + branches: + - main + +jobs: + pr_target_slack: + name: Notify slack of pull request actions + runs-on: ubuntu-latest + steps: + - name: Send pull request action info to slack + uses: slackapi/slack-github-action@v2.1.1 + with: + payload-delimiter: "_" + webhook: ${{ secrets.PR_TARGET_WEBHOOK_URL }} + webhook-type: webhook-trigger \ No newline at end of file From 706fb0e452af0fd91b53242c0c2c54a015a44695 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Wed, 1 Oct 2025 17:54:22 -0400 Subject: [PATCH 013/177] Updated github actions permissions (#10) (#11) Github action workflows were updated to limit permissions. Now the `Rust`, `pr_comment_slack` and `pr_review_comment_slack` actions are limited to to read the repository's contents. Co-authored-by: Thoth Gunter --- .github/workflows/pr_comment_slack.yml | 4 +++- .github/workflows/pr_review_comment_slack.yml | 4 +++- .github/workflows/rust.yml | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr_comment_slack.yml b/.github/workflows/pr_comment_slack.yml index f8e70d7..bda2853 100644 --- a/.github/workflows/pr_comment_slack.yml +++ b/.github/workflows/pr_comment_slack.yml @@ -1,4 +1,6 @@ name: pr_comment_slack +permissions: + contents: read on: issue_comment: @@ -15,4 +17,4 @@ jobs: with: payload-delimiter: "_" webhook: ${{ secrets.PR_COMMENT_WEBHOOK_URL }} - webhook-type: webhook-trigger \ No newline at end of file + webhook-type: webhook-trigger diff --git a/.github/workflows/pr_review_comment_slack.yml b/.github/workflows/pr_review_comment_slack.yml index ef94fd4..5c6ab01 100644 --- a/.github/workflows/pr_review_comment_slack.yml +++ b/.github/workflows/pr_review_comment_slack.yml @@ -1,4 +1,6 @@ name: pr_review_comment_slack +permissions: + contents: read on: pull_request_review_comment: @@ -14,4 +16,4 @@ jobs: with: payload-delimiter: "_" webhook: ${{ secrets.PR_REVIEW_COMMENT_WEBHOOK_URL }} - webhook-type: webhook-trigger \ No newline at end of file + webhook-type: webhook-trigger diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9fd45e0..6850ed5 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,5 +1,8 @@ name: Rust +permissions: + contents: read + on: push: branches: [ "main" ] From 9a1dcf71ff017f6c8145248525dd32dead94c86e Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 3 Oct 2025 14:57:57 -0400 Subject: [PATCH 014/177] build system (#22) * cargo-make build system * all the fmt fixes * don't lint on ClockBound or VMClock * cargo clippy --fix -- -D clippy::doc_markdown * expect some lints * expect missing error docs lints and fix uninlined format args * get cargo-make working with lints and formatting across multiple rust features * updated template to cargo make --- .clippy.toml | 1 + .github/pull_request_template.md | 3 +- .github/workflows/ci.yml | 41 ++++++++++ .github/workflows/coverage.yml | 61 +++++++++++++++ Makefile.toml | 78 +++++++++++++++++++ clock-bound-ffi/src/lib.rs | 42 +++++----- clock-bound/Cargo.toml | 4 +- clock-bound/src/client.rs | 11 ++- clock-bound/src/daemon/time/inner.rs | 8 -- clock-bound/src/daemon/time/instant.rs | 1 + clock-bound/src/lib.rs | 1 + clock-bound/src/shm.rs | 30 ++++--- clock-bound/src/shm/common.rs | 3 +- clock-bound/src/shm/reader.rs | 40 +++++----- clock-bound/src/shm/shm_header.rs | 22 +++--- clock-bound/src/shm/writer.rs | 52 ++++++------- clock-bound/src/vmclock.rs | 60 +++++++------- clock-bound/src/vmclock/shm.rs | 75 +++++++++--------- clock-bound/src/vmclock/shm_reader.rs | 32 ++++---- clock-bound/src/vmclock/shm_writer.rs | 53 ++++++------- examples/client/rust/Cargo.toml | 4 +- examples/client/rust/Makefile.toml | 8 ++ examples/client/rust/src/main.rs | 20 +++-- .../Cargo.toml | 4 +- .../Makefile.toml | 8 ++ .../src/main.rs | 16 ++-- test/vmclock-updater/Cargo.toml | 9 ++- test/vmclock-updater/Makefile.toml | 8 ++ test/vmclock-updater/src/main.rs | 10 ++- 29 files changed, 466 insertions(+), 239 deletions(-) create mode 100644 .clippy.toml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/coverage.yml create mode 100644 Makefile.toml create mode 100644 examples/client/rust/Makefile.toml create mode 100644 test/clock-bound-vmclock-client-test/Makefile.toml create mode 100644 test/vmclock-updater/Makefile.toml diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..16f5824 --- /dev/null +++ b/.clippy.toml @@ -0,0 +1 @@ +doc-valid-idents = ["VMClock", "ClockBound", "PHz", "FHz", ".."] \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 25a3941..1b82a46 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,5 +19,4 @@ Check with an `x` for what applies and add details as needed: - [ ] Unit tests added - [ ] Integration tests added - [ ] Workflows modified -- [ ] Ran `cargo build` -- [ ] Ran `cargo clippy` and `cargo fmt` \ No newline at end of file +- [ ] Ran `cargo make` \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9720305 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: ci + +permissions: + security-events: write + actions: read + contents: read + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + ci: + name: ci + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cargo cache + uses: swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Install ci tooling + uses: taiki-e/install-action@v2 + with: + tool: cargo-make,cargo-hack + + - name: Run cargo make in ci profile + run: cargo make custom-ci-flow --profile custom-ci \ No newline at end of file diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..3f5197d --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,61 @@ +name: coverage + +permissions: + pull-requests: write + contents: read + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + + +env: + CARGO_TERM_COLOR: always + +jobs: + coverage: + name: coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install coverage-tooling + uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov,nextest + + - name: Generate coverage report + run: cargo llvm-cov --all-features --cobertura --output-path coverage.cobertura.xml nextest + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.cobertura.xml + + report_coverage: + needs: coverage + if: github.event_name == 'pull_request' # Only run on PRs + runs-on: ubuntu-latest + steps: + - name: Download coverage artifact + uses: actions/download-artifact@v5 + with: + name: coverage-report + + - name: Post coverage summary to PR + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: coverage.cobertura.xml + badge: true + format: markdown + output: both + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + recreate: true + path: code-coverage-results.md diff --git a/Makefile.toml b/Makefile.toml new file mode 100644 index 0000000..73c3368 --- /dev/null +++ b/Makefile.toml @@ -0,0 +1,78 @@ +[tasks.hack-check] +description = "Check that each feature builds as expected" +category = "Build" +condition = { env_set = ["CARGO_MAKE_RUN_HACK_CHECK"] } +command = "cargo" +args = ["hack", "check", "--each-feature", "--no-dev-deps"] + +[tasks.custom-docs-flow] +description = "Check docs for broken links" +category = "Documentation" +install_crate = false +command = "cargo" +args = ["rustdoc", "--lib", "--", "-D", "rustdoc::broken-intra-doc-links"] + +[tasks.format] +description = "Runs the cargo rustfmt plugin." +category = "Development" +dependencies = ["install-rustfmt"] +command = "cargo" +args = ["fmt"] + +[tasks.custom-default-flow] +dependencies = [ + "format-flow", + "format-toml-conditioned-flow", + "pre-build", + "build", + "build-release", + "hack-check", + "test-flow", + "post-build", + "clippy-flow", + "custom-docs-flow", +] + +[tasks.custom-ci-flow] +dependencies = [ + "pre-build", + "check-format-flow", + "clippy-flow", + "build", + "build-release", + "hack-check", + "post-build", + "test-flow", + "examples-ci-flow", + "bench-ci-flow", + "post-ci-flow", + "custom-docs-flow", +] + +[tasks.default] +alias = "custom-default-flow" + +[tasks.clippy] +args = ["hack", "clippy", "@@split(CARGO_MAKE_CLIPPY_ARGS, )"] + +[env] +CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true +CARGO_MAKE_RUN_TOML_FORMAT = true +CARGO_MAKE_CLIPPY_ARGS = """\ +--each-feature --no-deps -- \ +-W clippy::cargo \ +-W clippy::all \ +-W clippy::pedantic \ +-A clippy::multiple-crate-versions \ +-A clippy::must_use_candidate \ +-A clippy::cargo_common_metadata \ +-D warnings \ +""" +CARGO_MAKE_RUN_CLIPPY = true +CARGO_MAKE_RUN_CHECK_FORMAT = true +CARGO_MAKE_RUN_HACK_CHECK = true +CARGO_HACK_CHECK_ARGS = "--feature-powerset --no-dev-deps" + +[env.custom-ci] +CARGO_MAKE_CLIPPY_ALLOW_FAIL = false +CARGO_MAKE_FORMAT_TOML_ARGS = "--check" diff --git a/clock-bound-ffi/src/lib.rs b/clock-bound-ffi/src/lib.rs index 081cdd4..439fe70 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -6,11 +6,11 @@ #![allow(non_camel_case_types)] use clock_bound::shm::{ClockStatus, ShmError, ShmReader}; -use clock_bound::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use clock_bound::vmclock::VMClock; +use clock_bound::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use core::ptr; use nix::sys::time::TimeSpec; -use std::ffi::{c_char, CStr}; +use std::ffi::{CStr, c_char}; /// Error kind exposed over the FFI. /// @@ -89,12 +89,12 @@ pub struct clockbound_ctx { } impl clockbound_ctx { - /// Obtain error-bounded timestamps and the ClockStatus. + /// Obtain error-bounded timestamps and the `ClockStatus`. /// /// The result on success is a tuple of: - /// - TimeSpec: earliest timestamp. - /// - TimeSpec: latest timestamp. - /// - ClockStatus: Status of the clock. + /// - `TimeSpec`: earliest timestamp. + /// - `TimeSpec`: latest timestamp. + /// - `ClockStatus`: Status of the clock. fn now(&mut self) -> Result<(TimeSpec, TimeSpec, ClockStatus), ShmError> { if let Some(ref mut clockbound_shm_reader) = self.clockbound_shm_reader { match clockbound_shm_reader.snapshot() { @@ -143,22 +143,20 @@ pub struct clockbound_now_result { /// Open and create a reader to the Clockbound shared memory segment. /// -/// Create a ShmReader pointing at the path passed to this call, and package it (and any other side +/// Create a `ShmReader` pointing at the path passed to this call, and package it (and any other side /// information) into a `clockbound_ctx`. A reference to the context is passed back to the C /// caller, and needs to live beyond the scope of this function. /// /// # Safety /// Rely on the caller to pass valid pointers. -/// +#[expect(clippy::missing_panics_doc, reason = "todo")] #[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_open( clockbound_shm_path: *const c_char, err: *mut clockbound_err, ) -> *mut clockbound_ctx { // Safety: Rely on caller to pass valid pointers - let clockbound_shm_path_cstr = unsafe { - CStr::from_ptr(clockbound_shm_path) - }; + let clockbound_shm_path_cstr = unsafe { CStr::from_ptr(clockbound_shm_path) }; let clockbound_shm_path = clockbound_shm_path_cstr .to_str() .expect("Failed to convert ClockBound shared memory path to str"); @@ -176,7 +174,7 @@ pub unsafe extern "C" fn clockbound_open( }; let ctx = clockbound_ctx { - err: Default::default(), + err: clockbound_err::default(), clockbound_shm_reader: None, vmclock: Some(vmclock), }; @@ -185,7 +183,7 @@ pub unsafe extern "C" fn clockbound_open( // // The caller is responsible for calling clockbound_close() with this context which will // perform memory clean-up. - return Box::leak(Box::new(ctx)); + Box::leak(Box::new(ctx)) } /// Open and create a reader to the Clockbound shared memory segment and the VMClock shared memory segment. @@ -196,6 +194,7 @@ pub unsafe extern "C" fn clockbound_open( /// /// # Safety /// Rely on the caller to pass valid pointers. +#[expect(clippy::missing_panics_doc, reason = "todo")] #[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_vmclock_open( clockbound_shm_path: *const c_char, @@ -203,14 +202,12 @@ pub unsafe extern "C" fn clockbound_vmclock_open( err: *mut clockbound_err, ) -> *mut clockbound_ctx { // Safety: Rely on caller to pass valid pointers - let clockbound_shm_path_cstr = unsafe { - CStr::from_ptr(clockbound_shm_path) - }; + let clockbound_shm_path_cstr = unsafe { CStr::from_ptr(clockbound_shm_path) }; let clockbound_shm_path = clockbound_shm_path_cstr .to_str() .expect("Failed to convert ClockBound shared memory path to str"); // Safety: Rely on caller to pass valid pointers - let vmclock_shm_path_cstr = unsafe {CStr::from_ptr(vmclock_shm_path) }; + let vmclock_shm_path_cstr = unsafe { CStr::from_ptr(vmclock_shm_path) }; let vmclock_shm_path = vmclock_shm_path_cstr .to_str() .expect("Failed to convert VMClock shared memory path to str"); @@ -227,7 +224,7 @@ pub unsafe extern "C" fn clockbound_vmclock_open( }; let ctx = clockbound_ctx { - err: Default::default(), + err: clockbound_err::default(), clockbound_shm_reader: None, vmclock: Some(vmclock), }; @@ -236,12 +233,12 @@ pub unsafe extern "C" fn clockbound_vmclock_open( // // The caller is responsible for calling clockbound_close() with this context which will // perform memory clean-up. - return Box::leak(Box::new(ctx)); + Box::leak(Box::new(ctx)) } /// Close the clockbound context. /// -/// Effectively unmap the shared memory segment and drop the ShmReader. +/// Effectively unmap the shared memory segment and drop the `ShmReader`. /// /// # Safety /// @@ -262,7 +259,6 @@ pub unsafe extern "C" fn clockbound_close(ctx: *mut clockbound_ctx) -> *const cl /// # Safety /// /// Have no choice but rely on the caller to pass valid pointers. -#[inline] #[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_now( ctx: *mut clockbound_ctx, @@ -275,7 +271,7 @@ pub unsafe extern "C" fn clockbound_now( Ok(now) => now, Err(e) => { ctx.err = e.into(); - return &ctx.err; + return &raw const ctx.err; } }; // Safety: Rely on caller to pass valid pointers @@ -292,8 +288,8 @@ pub unsafe extern "C" fn clockbound_now( #[cfg(test)] mod t_ffi { use super::*; - use clock_bound::shm::ClockErrorBound; use byteorder::{LittleEndian, NativeEndian, WriteBytesExt}; + use clock_bound::shm::ClockErrorBound; use std::ffi::CString; use std::fs::OpenOptions; use std::io::Write; diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 249923b..1eab1dc 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -15,7 +15,9 @@ version.workspace = true [dependencies] byteorder = "1" errno = { version = "0.3.0", default-features = false } -libc = { version = "0.2", default-features = false, features = ["extra_traits"] } +libc = { version = "0.2", default-features = false, features = [ + "extra_traits", +] } nix = { version = "0.26", features = ["feature", "time"] } serde = { version = "1.0", features = ["derive"], optional = true } tracing = "0.1" diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index adee916..8148572 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -15,12 +15,12 @@ pub struct ClockBoundClient { } impl ClockBoundClient { - /// Creates and returns a new ClockBoundClient. + /// Creates and returns a new `ClockBoundClient`. /// /// The creation process also initializes a shared memory reader /// with the shared memory default path that is used by /// the ClockBound daemon. - /// + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn new() -> Result { // Validate that the default ClockBound shared memory path exists. if !Path::new(CLOCKBOUND_SHM_DEFAULT_PATH).exists() { @@ -41,10 +41,11 @@ impl ClockBoundClient { Ok(ClockBoundClient { vmclock }) } - /// Creates and returns a new ClockBoundClient, specifying a shared + /// Creates and returns a new `ClockBoundClient`, specifying a shared /// memory path that is being used by the ClockBound daemon. /// The VMClock will be accessed by reading the default VMClock /// shared memory path. + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn new_with_path(clockbound_shm_path: &str) -> Result { // Validate that the provided ClockBound shared memory path exists. if !Path::new(clockbound_shm_path).exists() { @@ -63,9 +64,10 @@ impl ClockBoundClient { Ok(ClockBoundClient { vmclock }) } - /// Creates and returns a new ClockBoundClient, specifying a shared + /// Creates and returns a new `ClockBoundClient`, specifying a shared /// memory paths that are being used by the ClockBound daemon and by the VMClock, /// respectively. + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn new_with_paths( clockbound_shm_path: &str, vmclock_shm_path: &str, @@ -84,6 +86,7 @@ impl ClockBoundClient { } /// Obtains the clock error bound and clock status at the current moment. + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn now(&mut self) -> Result { let (earliest, latest, clock_status) = self.vmclock.now()?; diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 0905fe1..17ed288 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -222,10 +222,6 @@ impl DivAssign for Diff { impl Mul for Diff { type Output = Self; - #[expect( - clippy::cast_possible_wrap, - reason = "Multiplying will create this problem anyways" - )] fn mul(self, rhs: usize) -> Self::Output { Self { duration: self.duration * rhs as i128, @@ -243,10 +239,6 @@ impl Mul> for usize { } } -#[expect( - clippy::cast_possible_wrap, - reason = "Multiplying will create this problem anyways" -)] impl MulAssign for Diff { fn mul_assign(&mut self, rhs: usize) { self.duration *= rhs as i128; diff --git a/clock-bound/src/daemon/time/instant.rs b/clock-bound/src/daemon/time/instant.rs index ec999c4..e034a5f 100644 --- a/clock-bound/src/daemon/time/instant.rs +++ b/clock-bound/src/daemon/time/instant.rs @@ -166,6 +166,7 @@ impl Duration { /// /// Will truncate to the nearest femtosecond #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] + #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] pub const fn from_nanos_f64(nanos: f64) -> Self { Self::new((nanos * FEMTOS_PER_NANO as f64) as i128) } diff --git a/clock-bound/src/lib.rs b/clock-bound/src/lib.rs index dd5bb61..caf1d76 100644 --- a/clock-bound/src/lib.rs +++ b/clock-bound/src/lib.rs @@ -10,6 +10,7 @@ pub mod vmclock; #[cfg(feature = "daemon")] pub mod daemon; +#[cfg(feature = "daemon")] // can open this up if we ever use this in the client mod private { // define a crate sealed trait // https://rust-lang.github.io/api-guidelines/future-proofing.html#sealed-traits-protect-against-downstream-implementations-c-sealed diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 2dbb5a9..98fd166 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -1,6 +1,6 @@ //! ClockBound Shared Memory //! -//! This crate implements the low-level IPC functionality to share ClockErrorBound data and clock +//! This crate implements the low-level IPC functionality to share `ClockErrorBound` data and clock //! status over a shared memory segment. This crate is meant to be used by the C and Rust versions //! of the ClockBound client library. @@ -25,7 +25,7 @@ use common::{CLOCK_MONOTONIC, CLOCK_REALTIME, clock_gettime_safe}; const CLOCKBOUND_RESTART_GRACE_PERIOD: TimeSpec = TimeSpec::new(5, 0); -/// Convenience macro to build a ShmError::SyscallError with extra info from errno and custom +/// Convenience macro to build a `ShmError::SyscallError` with extra info from errno and custom /// origin information. #[macro_export] macro_rules! syserror { @@ -79,14 +79,14 @@ pub enum ClockStatus { Disrupted = 3, } -/// Structure that holds the ClockErrorBound data captured at a specific point in time and valid +/// Structure that holds the `ClockErrorBound` data captured at a specific point in time and valid /// until a subsequent point in time. /// -/// The ClockErrorBound structure supports calculating the actual bound on clock error at any time, +/// The `ClockErrorBound` structure supports calculating the actual bound on clock error at any time, /// using its `now()` method. The internal fields are not meant to be accessed directly. /// -/// Note that the timestamps in between which this ClockErrorBound data is valid are captured using -/// a CLOCK_MONOTONIC_COARSE clock. The monotonic clock id is required to correctly measure the +/// Note that the timestamps in between which this `ClockErrorBound` data is valid are captured using +/// a `CLOCK_MONOTONIC_COARSE` clock. The monotonic clock id is required to correctly measure the /// duration during which clock drift possibly accrues, and avoid events when the clock is set, /// smeared or affected by leap seconds. /// @@ -95,12 +95,12 @@ pub enum ClockStatus { #[repr(C)] #[derive(Debug, Copy, Clone, PartialEq)] pub struct ClockErrorBound { - /// The CLOCK_MONOTONIC_COARSE timestamp recorded when the bound on clock error was + /// The `CLOCK_MONOTONIC_COARSE` timestamp recorded when the bound on clock error was /// calculated. The current implementation relies on Chrony tracking data, which accounts for /// the dispersion between the last clock processing event, and the reading of tracking data. as_of: TimeSpec, - /// The CLOCK_MONOTONIC_COARSE timestamp beyond which the bound on clock error should not be + /// The `CLOCK_MONOTONIC_COARSE` timestamp beyond which the bound on clock error should not be /// trusted. This is a useful signal that the communication with the synchronization daemon is /// has failed, for example. void_after: TimeSpec, @@ -136,7 +136,7 @@ pub struct ClockErrorBound { } impl Default for ClockErrorBound { - /// Get a default ClockErrorBound struct + /// Get a default `ClockErrorBound` struct /// Equivalent to zero'ing this bit of memory fn default() -> Self { ClockErrorBound { @@ -153,7 +153,7 @@ impl Default for ClockErrorBound { } impl ClockErrorBound { - /// Create a new ClockErrorBound struct. + /// Create a new `ClockErrorBound` struct. pub fn new( as_of: TimeSpec, void_after: TimeSpec, @@ -175,13 +175,14 @@ impl ClockErrorBound { } } - /// The ClockErrorBound equivalent of clock_gettime(), but with bound on accuracy. + /// The `ClockErrorBound` equivalent of `clock_gettime()`, but with bound on accuracy. /// /// Returns a pair of (earliest, latest) timespec between which current time exists. The /// interval width is twice the clock error bound (ceb) such that: /// (earliest, latest) = ((now - ceb), (now + ceb)) /// The function also returns a clock status to assert that the clock is being synchronized, or /// free-running, or ... + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn now(&self) -> Result<(TimeSpec, TimeSpec, ClockStatus), ShmError> { // Read the clock, start with the REALTIME one to be as close as possible to the event the // caller is interested in. The monotonic clock should be read after. It is correct for the @@ -200,6 +201,11 @@ impl ClockErrorBound { /// details to correctly work around the "rough" alignment of the timestamps is not something /// we want to leave to the user of ClockBound, hence this method is private. Although `now()` /// may be it only caller, decoupling the two make writing unit tests a bit easier. + #[expect( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + reason = "todo, come back and evaluate impact" + )] fn compute_bound_at( &self, real: TimeSpec, @@ -295,7 +301,7 @@ impl ClockErrorBound { // between the snapshot being read and ~now. let duration_sec = duration.num_nanoseconds() as f64 / 1_000_000_000_f64; let updated_bound = TimeSpec::nanoseconds( - self.bound_nsec + (duration_sec * self.max_drift_ppb as f64) as i64, + self.bound_nsec + (duration_sec * f64::from(self.max_drift_ppb)) as i64, ); // Build the (earliest, latest) interval within which true time exists. diff --git a/clock-bound/src/shm/common.rs b/clock-bound/src/shm/common.rs index 3add015..54c9ba6 100644 --- a/clock-bound/src/shm/common.rs +++ b/clock-bound/src/shm/common.rs @@ -15,7 +15,8 @@ pub const CLOCK_MONOTONIC: ClockId = ClockId::CLOCK_MONOTONIC_COARSE; /// This function wraps the `clock_gettime()` system call to conveniently return the current time /// tracked by a specific clock. /// -/// The clock_id is one of ClockId::CLOCK_REALTIME, ClockId::CLOCK_MONOTONIC, etc. +/// The `clock_id` is one of `ClockId::CLOCK_REALTIME`, `ClockId::CLOCK_MONOTONIC`, etc. +#[expect(clippy::missing_errors_doc, reason = "todo")] pub fn clock_gettime_safe(clock_id: ClockId) -> Result { match clock_gettime(clock_id) { Ok(ts) => Ok(ts), diff --git a/clock-bound/src/shm/reader.rs b/clock-bound/src/shm/reader.rs index 4276905..2b99afc 100644 --- a/clock-bound/src/shm/reader.rs +++ b/clock-bound/src/shm/reader.rs @@ -1,3 +1,5 @@ +#![expect(clippy::cast_ptr_alignment, reason = "TODO COME BACK TO THIS")] + use errno::{Errno, errno}; use std::ffi::{CStr, c_void}; use std::mem::size_of; @@ -12,12 +14,12 @@ use crate::{ /// A guard tracking an open file descriptor. /// -/// Creating the FdGuard opens the file with read-only permission. +/// Creating the `FdGuard` opens the file with read-only permission. /// The file descriptor is closed when the guard is dropped. struct FdGuard(i32); impl FdGuard { - /// Create a new FdGuard. + /// Create a new `FdGuard`. /// /// Open a file at `path` and store the open file descriptor fn new(path: &CStr) -> Result { @@ -32,7 +34,7 @@ impl FdGuard { } impl Drop for FdGuard { - /// Drop the FdGuard and close the file descriptor it holds. + /// Drop the `FdGuard` and close the file descriptor it holds. fn drop(&mut self) { // SAFETY: Unsafe because this is a call into a C API, but this particular // call is always safe. @@ -45,7 +47,7 @@ impl Drop for FdGuard { /// A guard tracking an memory mapped file. /// -/// Creating the MmapGuard maps an open file descriptor. +/// Creating the `MmapGuard` maps an open file descriptor. /// The file is unmap'ed when the guard is dropped. #[derive(Debug)] struct MmapGuard { @@ -57,9 +59,9 @@ struct MmapGuard { } impl MmapGuard { - /// Create a new MmapGuard. + /// Create a new `MmapGuard`. /// - /// Map the open file descriptor held in the FdGuard. + /// Map the open file descriptor held in the `FdGuard`. fn new(fdguard: &FdGuard) -> Result { // Read the header so we know how much to map in memory. let header = ShmHeader::read(fdguard.0)?; @@ -89,7 +91,7 @@ impl MmapGuard { } impl Drop for MmapGuard { - /// Drop the MmapGuard and unmap the file it tracks. + /// Drop the `MmapGuard` and unmap the file it tracks. fn drop(&mut self) { // SAFETY: `segment` was previously returned from `mmap`, and therefore // when this destructor runs there are no more live references into @@ -103,8 +105,8 @@ impl Drop for MmapGuard { /// Reader for ClockBound daemon shared memory segment. /// -/// The Clockbound daemon shared memory segment consists of a ShmHeader followed by a -/// ClockBoundError struct. The segment is updated by a single producer (the clockbound daemon), +/// The Clockbound daemon shared memory segment consists of a `ShmHeader` followed by a +/// `ClockBoundError` struct. The segment is updated by a single producer (the clockbound daemon), /// but may be read by many clients. The shared memory segment does not implement a semaphore or /// equivalent to synchronize the single-producer / many-consumers processes. Instead, the /// mechanism is lock-free and relies on a `generation` number to ensure consistent reads (over @@ -157,6 +159,7 @@ impl ShmReader { /// On error, returns an appropriate `Errno`. If the content of the segment /// is uninitialized, unparseable, or otherwise malformed, EPROTO will be /// returned. + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn new(path: &CStr) -> Result { let fdguard = FdGuard::new(path)?; let mmap_guard = MmapGuard::new(&fdguard)?; @@ -179,7 +182,7 @@ impl ShmReader { // SAFETY: segment size has been checked to ensure `cursor` move leads to a valid cast cursor = unsafe { cursor.add(size_of::()) }; - let ceb_shm = unsafe { ptr::addr_of!(*cursor.cast::()) }; + let ceb_shm = ptr::addr_of!(*cursor.cast::()); Ok(ShmReader { _marker: std::marker::PhantomData, @@ -198,11 +201,12 @@ impl ShmReader { /// number in the header has not changed (which would indicate an update from the writer /// occurred while reading). If an update is detected, the read is retried. /// - /// This function returns a reference to the ClockErrorBound snapshot stored by the reader, and - /// not an owned value. This make the ShmReader NOT thread-safe: the data pointed to could be + /// This function returns a reference to the `ClockErrorBound` snapshot stored by the reader, and + /// not an owned value. This make the `ShmReader` NOT thread-safe: the data pointed to could be /// updated without one of the thread knowing, leading to a incorrect clock error bond. The /// advantage are in terms of performance: less data copied, but also no locking, yielding or /// excessive retries. + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn snapshot(&mut self) -> Result<&ClockErrorBound, ShmError> { // Atomically read the current version in the shared memory segment // SAFETY: `self.version` has been validated when creating the reader @@ -218,8 +222,7 @@ impl ShmReader { return Ok(&self.snapshot_ceb); } else if version != CLOCKBOUND_SHM_SUPPORTED_VERSION { eprintln!( - "ClockBound shared memory segment has version {:?} which is not supported by this software.", - version + "ClockBound shared memory segment has version {version:?} which is not supported by this software." ); return Err(ShmError::SegmentVersionNotSupported); } @@ -284,11 +287,10 @@ impl ShmReader { self.snapshot_gen = first_gen; self.snapshot_ceb = snapshot; return Ok(&self.snapshot_ceb); - } else { - // Only track complete updates indicated by an even generation number. - if second_gen & 0x0001 == 0 { - first_gen = second_gen; - } + } + // Only track complete updates indicated by an even generation number. + if second_gen & 0x0001 == 0 { + first_gen = second_gen; } retries -= 1; } diff --git a/clock-bound/src/shm/shm_header.rs b/clock-bound/src/shm/shm_header.rs index 8cf54e2..b94eec5 100644 --- a/clock-bound/src/shm/shm_header.rs +++ b/clock-bound/src/shm/shm_header.rs @@ -3,14 +3,14 @@ use std::sync::atomic; use crate::{shm::ShmError, syserror}; -/// The magic number that identifies a ClockErrorBound shared memory segment. -pub const SHM_MAGIC: [u32; 2] = [0x414D5A4E, 0x43420200]; +/// The magic number that identifies a `ClockErrorBound` shared memory segment. +pub const SHM_MAGIC: [u32; 2] = [0x414D_5A4E, 0x4342_0200]; /// Version of the ClockBound shared memory segment layout that is supported by this /// implementation of ClockBound. pub const CLOCKBOUND_SHM_SUPPORTED_VERSION: u16 = 2_u16; -/// Header structure to the Shared Memory segment where the ClockErrorBound data is kept. +/// Header structure to the Shared Memory segment where the `ClockErrorBound` data is kept. /// /// Most members are atomic types as they are subject to be updated by the ClockBound daemon. #[repr(C, align(8))] @@ -30,10 +30,11 @@ pub struct ShmHeader { } impl ShmHeader { - /// Initialize a ShmHeader from a file descriptor + /// Initialize a `ShmHeader` from a file descriptor /// - /// Read the content of a file, ensures it is meant to contain ClockErrorBound data by + /// Read the content of a file, ensures it is meant to contain `ClockErrorBound` data by /// validating the magic number and return a valid header. + #[expect(clippy::cast_sign_loss, reason = "guarded")] pub fn read(fdesc: i32) -> Result { let mut header_buf: MaybeUninit = MaybeUninit::uninit(); // SAFETY: `buf` points to `count` bytes of valid memory. @@ -49,7 +50,7 @@ impl ShmHeader { return Err(ShmError::SegmentNotInitialized); } _ => (), - }; + } // SAFETY: we've checked the above return value to ensure header_buf // has been completely initialized by the previous read. @@ -60,6 +61,10 @@ impl ShmHeader { } /// Check whether the magic number matches the expected one. + #[expect( + clippy::trivially_copy_pass_by_ref, + reason = "bulk expect lints. Can fix later" + )] fn matches_magic(&self, magic: &[u32; 2]) -> bool { self.magic == *magic } @@ -82,7 +87,7 @@ impl ShmHeader { segsize as usize >= size_of::() } - /// Check whether a ShmHeader is valid + /// Check whether a `ShmHeader` is valid fn is_valid(&self) -> Result<(), ShmError> { if !self.matches_magic(&SHM_MAGIC) { return Err(ShmError::SegmentNotInitialized); @@ -101,8 +106,7 @@ impl ShmHeader { let version = self.version.load(atomic::Ordering::Relaxed); if version != CLOCKBOUND_SHM_SUPPORTED_VERSION { eprintln!( - "ClockBound shared memory segment has version {:?} which is not supported by this software.", - version + "ClockBound shared memory segment has version {version:?} which is not supported by this software." ); return Err(ShmError::SegmentVersionNotSupported); } diff --git a/clock-bound/src/shm/writer.rs b/clock-bound/src/shm/writer.rs index 89c9fae..61c9d9e 100644 --- a/clock-bound/src/shm/writer.rs +++ b/clock-bound/src/shm/writer.rs @@ -1,6 +1,8 @@ +#![expect(clippy::cast_ptr_alignment, reason = "TODO COME BACK TO THIS")] + use byteorder::{NativeEndian, WriteBytesExt}; use std::ffi::{CString, c_void}; -use std::io::{Error, ErrorKind}; +use std::io::Error; use std::mem::size_of; use std::path::Path; use std::sync::atomic; @@ -34,22 +36,22 @@ pub struct ShmWriter { /// A raw pointer keeping the address of the segment mapped in memory addr: *mut c_void, - /// A raw pointer to the version member of the ShmHeader mapped in memory. The version number + /// A raw pointer to the version member of the `ShmHeader` mapped in memory. The version number /// identifies the layout of the rest of the segment. A value of 0 indicates the memory segment /// is not initialized / not usable. version: *mut atomic::AtomicU16, - /// A raw pointer to the generation member of the ShmHeader mapped in memory. The generation + /// A raw pointer to the generation member of the `ShmHeader` mapped in memory. The generation /// number is updated by the writer before and after updating the content mapped in memory. generation: *mut atomic::AtomicU16, - /// A raw pointer to the ClockBoundError data mapped in memory. This structure follows the - /// ShmHeader and contains the information required to compute a bound on clock error. + /// A raw pointer to the `ClockBoundError` data mapped in memory. This structure follows the + /// `ShmHeader` and contains the information required to compute a bound on clock error. ceb: *mut ClockErrorBound, } impl ShmWriter { - /// Create a new ShmWriter referencing the memory segment to write ClockErrorBound data to. + /// Create a new `ShmWriter` referencing the memory segment to write `ClockErrorBound` data to. /// /// There are several cases to consider: /// 1. The file backing the memory segment does not exist, or the content is corrupted/wrong. @@ -62,6 +64,7 @@ impl ShmWriter { /// analog to a cold boot. /// /// TODO: implement scenario 3 once the readers support a version bump. + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn new(path: &Path) -> std::io::Result { // Determine the size of the segment. let segsize = ShmWriter::segment_size(); @@ -74,7 +77,7 @@ impl ShmWriter { if ShmWriter::is_usable_segment(path).is_err() { // Note that wiping the file sets the version to 0, which is used to indicate the // readers that the memory segment is not usable yet. - ShmWriter::wipe(path, segsize)? + ShmWriter::wipe(path, segsize)?; } // Memory map the file. @@ -120,7 +123,7 @@ impl ShmWriter { /// Check whether the memory segment already exist and is usable. /// - /// The segment is usable if it can be opened at `path` and it can be read by a ShmReader. + /// The segment is usable if it can be opened at `path` and it can be read by a `ShmReader`. fn is_usable_segment(path: &Path) -> Result<(), ShmError> { let path_cstring = CString::new(path.as_os_str().as_bytes()) .map_err(|_| ShmError::SegmentNotInitialized)?; @@ -140,7 +143,7 @@ impl ShmWriter { // the size of the data shared is almost two order of magnitude smaller than the minimum // system page size (4096), so taking a quick shortcut and ignoring paging alignment // questions for now. - if size % 8 == 0 { + if size.is_multiple_of(8) { size } else { size + (8 - size % 8) @@ -160,10 +163,7 @@ impl ShmWriter { Some("") => (), // This would be a relative path without parent Some(_) => fs::create_dir_all(parent)?, None => { - return Err(Error::new( - ErrorKind::Other, - "Failed to extract parent dir name", - )); + return Err(Error::other("Failed to extract parent dir name")); } } } @@ -176,13 +176,9 @@ impl ShmWriter { let size: u32 = match segsize.try_into() { Ok(size) => size, // it did fit Err(e) => { - return Err(std::io::Error::new( - ErrorKind::Other, - format!( - "Failed to convert segment size {:?} into u32 {:?}", - segsize, e - ), - )); + return Err(std::io::Error::other(format!( + "Failed to convert segment size {segsize:?} into u32 {e:?}" + ))); } }; @@ -201,13 +197,9 @@ impl ShmWriter { // Make sure the amount of bytes written matches the segment size let pos = file.stream_position()?; if pos > size.into() { - return Err(std::io::Error::new( - ErrorKind::Other, - format!( - "SHM Writer implementation error: wrote {:?} bytes but segsize is {:?} bytes", - pos, size - ), - )); + return Err(std::io::Error::other(format!( + "SHM Writer implementation error: wrote {pos:?} bytes but segsize is {size:?} bytes" + ))); } // Sync all and drop (close) the descriptor @@ -288,7 +280,7 @@ impl ShmWrite for ShmWriter { // Skipping over a generation equals to 0 avoid this problem. let mut g = g.wrapping_add(1); if g == 0 { - g = 2 + g = 2; } generation.store(g, atomic::Ordering::Release); @@ -299,8 +291,8 @@ impl ShmWrite for ShmWriter { impl Drop for ShmWriter { /// Unmap the memory segment /// - /// TODO: revisit to see if this can be refactored into the MmapGuard logic implemented on the - /// ShmReader. + /// TODO: revisit to see if this can be refactored into the `MmapGuard` logic implemented on the + /// `ShmReader`. fn drop(&mut self) { unsafe { nix::sys::mman::munmap(self.addr, self.segsize).expect("munmap"); diff --git a/clock-bound/src/vmclock.rs b/clock-bound/src/vmclock.rs index 31ddda3..8085968 100644 --- a/clock-bound/src/vmclock.rs +++ b/clock-bound/src/vmclock.rs @@ -25,6 +25,8 @@ impl VMClock { /// On error, returns an appropriate `Errno`. If the content of the segment /// is uninitialized, unparseable, or otherwise malformed, EPROTO will be /// returned. + #[expect(clippy::missing_errors_doc, reason = "todo")] + #[expect(clippy::missing_panics_doc, reason = "todo")] pub fn new(clockbound_shm_path: &str, vmclock_shm_path: &str) -> Result { let clockbound_shm_path = CString::new(clockbound_shm_path).expect("CString::new failed"); let mut clockbound_shm_reader = ShmReader::new(clockbound_shm_path.as_c_str())?; @@ -42,13 +44,14 @@ impl VMClock { }) } - /// The VMClock equivalent of clock_gettime(), but with bound on accuracy. + /// The VMClock equivalent of `clock_gettime()`, but with bound on accuracy. /// /// Returns a pair of (earliest, latest) timespec between which current time exists. The /// interval width is twice the clock error bound (ceb) such that: /// (earliest, latest) = ((now - ceb), (now + ceb)) /// The function also returns a clock status to assert that the clock is being synchronized, or /// free-running, or ... + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn now(&mut self) -> Result<(TimeSpec, TimeSpec, ClockStatus), ShmError> { // Read from the ClockBound shared memory segment. let clockbound_snapshot = self.clockbound_shm_reader.snapshot()?; @@ -60,35 +63,34 @@ impl VMClock { let (earliest, latest, clock_status) = clockbound_snapshot.now()?; - if clockbound_snapshot.clock_disruption_support_enabled { - if let Some(ref mut vmclock_shm_reader) = self.vmclock_shm_reader { - // Read from the VMClock shared memory segment. - let vmclock_snapshot = vmclock_shm_reader.snapshot()?; - - // Comparing the disruption marker between the VMClock snapshot and the - // ClockBound snapshot will tell us if the clock status provided by the - // ClockBound daemon is trustworthy. - debug!( - "clock_status: {:?}, vmclock_snapshot.disruption_marker: {:?}, clockbound_snapshot.disruption_marker: {:?}", - clock_status, - vmclock_snapshot.disruption_marker, - clockbound_snapshot.disruption_marker - ); - - if vmclock_snapshot.disruption_marker == clockbound_snapshot.disruption_marker { - // ClockBound's shared memory segment has the latest clock disruption status from - // VMClock and this means the clock status here can be trusted. - return Ok((earliest, latest, clock_status)); - } else { - // ClockBound has stale clock disruption status and it is not up-to-date with - // VMClock. - - // Override the clock disruption status with ClockStatus::Unknown until - // ClockBound daemon is able to pick up the latest clock disruption status - // from VMClock. - return Ok((earliest, latest, ClockStatus::Unknown)); - } + if clockbound_snapshot.clock_disruption_support_enabled + && let Some(ref mut vmclock_shm_reader) = self.vmclock_shm_reader + { + // Read from the VMClock shared memory segment. + let vmclock_snapshot = vmclock_shm_reader.snapshot()?; + + // Comparing the disruption marker between the VMClock snapshot and the + // ClockBound snapshot will tell us if the clock status provided by the + // ClockBound daemon is trustworthy. + debug!( + "clock_status: {:?}, vmclock_snapshot.disruption_marker: {:?}, clockbound_snapshot.disruption_marker: {:?}", + clock_status, + vmclock_snapshot.disruption_marker, + clockbound_snapshot.disruption_marker + ); + + if vmclock_snapshot.disruption_marker == clockbound_snapshot.disruption_marker { + // ClockBound's shared memory segment has the latest clock disruption status from + // VMClock and this means the clock status here can be trusted. + return Ok((earliest, latest, clock_status)); } + // ClockBound has stale clock disruption status and it is not up-to-date with + // VMClock. + + // Override the clock disruption status with ClockStatus::Unknown until + // ClockBound daemon is able to pick up the latest clock disruption status + // from VMClock. + return Ok((earliest, latest, ClockStatus::Unknown)); } debug!("clock_status: {:?}", clock_status); diff --git a/clock-bound/src/vmclock/shm.rs b/clock-bound/src/vmclock/shm.rs index 2bb67e7..1f9f146 100644 --- a/clock-bound/src/vmclock/shm.rs +++ b/clock-bound/src/vmclock/shm.rs @@ -19,7 +19,7 @@ use tracing::{debug, error}; pub const VMCLOCK_SHM_DEFAULT_PATH: &str = "/dev/vmclock0"; /// The magic number that identifies a VMClock shared memory segment. -pub const VMCLOCK_SHM_MAGIC: u32 = 0x4B4C4356; +pub const VMCLOCK_SHM_MAGIC: u32 = 0x4B4C_4356; /// Header structure to the Shared Memory segment where the VMClock data is kept. /// @@ -43,11 +43,11 @@ pub struct VMClockShmHeader { /// /// Possible values are: /// - /// VMCLOCK_TIME_UTC 0 // Since 1970-01-01 00:00:00z - /// VMCLOCK_TIME_TAI 1 // Since 1970-01-01 00:00:00z - /// VMCLOCK_TIME_MONOTONIC 2 // Since undefined epoch - /// VMCLOCK_TIME_INVALID_SMEARED 3 // Not supported - /// VMCLOCK_TIME_INVALID_MAYBE_SMEARED 4 // Not supported + /// `VMCLOCK_TIME_UTC` 0 // Since 1970-01-01 00:00:00z + /// `VMCLOCK_TIME_TAI` 1 // Since 1970-01-01 00:00:00z + /// `VMCLOCK_TIME_MONOTONIC` 2 // Since undefined epoch + /// `VMCLOCK_TIME_INVALID_SMEARED` 3 // Not supported + /// `VMCLOCK_TIME_INVALID_MAYBE_SMEARED` 4 // Not supported /// pub time_type: atomic::AtomicU8, @@ -59,9 +59,11 @@ pub struct VMClockShmHeader { } impl VMClockShmHeader { - /// Initialize a VMClockShmHeader from a vector of bytes. + /// Initialize a `VMClockShmHeader` from a vector of bytes. /// /// It is assumed that the vecxtor has already been validated to have enough bytes to hold. + #[expect(clippy::missing_errors_doc, reason = "todo")] + #[expect(clippy::missing_panics_doc, reason = "slices appropriately sized")] pub fn read(vector: &Vec) -> Result { if vector.len() < size_of::() { return syserror!("Insufficient bytes to create a VMClockShmHeader."); @@ -110,7 +112,7 @@ impl VMClockShmHeader { size as usize >= size_of::() } - /// Check whether a VMClockShmHeader is valid + /// Check whether a `VMClockShmHeader` is valid fn is_valid(&self) -> Result<(), ShmError> { if !self.matches_magic() { error!("VMClockShmHeader does not have a matching magic number."); @@ -152,7 +154,7 @@ pub enum VMClockClockStatus { } /// Custom struct used for indicating a parsing error when parsing a -/// VMClockClockStatus from str. +/// `VMClockClockStatus` from str. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct ParseError; @@ -176,6 +178,7 @@ impl FromStr for VMClockClockStatus { /// this specific layout. #[repr(C)] #[derive(Debug, Copy, Clone, PartialEq)] +#[expect(clippy::pub_underscore_fields, reason = "C FFI struct")] pub struct VMClockShmBody { /// Disruption Marker. /// @@ -187,19 +190,19 @@ pub struct VMClockShmBody { /// /// Bit flags representing the following: /// - /// Bit (1 << 0): VMCLOCK_FLAG_TAI_OFFSET_VALID: Indicates that the tai_offset_sec field is valid. + /// Bit (1 << 0): `VMCLOCK_FLAG_TAI_OFFSET_VALID`: Indicates that the `tai_offset_sec` field is valid. /// /// The below bits are optionally used to notify guests of pending /// maintenance events. A guest which provides latency-sensitive /// services may wish to remove itself from service if an event is coming up. /// Two flags indicate the approximate imminence of the event. /// - /// Bit (1 << 1): VMCLOCK_FLAG_DISRUPTION_SOON: About a day. - /// Bit (1 << 2): VMCLOCK_FLAG_DISRUPTION_IMMINENT: About an hour. - /// Bit (1 << 3): VMCLOCK_FLAG_PERIOD_ESTERROR_VALID - /// Bit (1 << 4): VMCLOCK_FLAG_PERIOD_MAXERROR_VALID - /// Bit (1 << 5): VMCLOCK_FLAG_TIME_ESTERROR_VALID - /// Bit (1 << 6): VMCLOCK_FLAG_TIME_MAXERROR_VALID + /// Bit (1 << 1): `VMCLOCK_FLAG_DISRUPTION_SOON`: About a day. + /// Bit (1 << 2): `VMCLOCK_FLAG_DISRUPTION_IMMINENT`: About an hour. + /// Bit (1 << 3): `VMCLOCK_FLAG_PERIOD_ESTERROR_VALID` + /// Bit (1 << 4): `VMCLOCK_FLAG_PERIOD_MAXERROR_VALID` + /// Bit (1 << 5): `VMCLOCK_FLAG_TIME_ESTERROR_VALID` + /// Bit (1 << 6): `VMCLOCK_FLAG_TIME_MAXERROR_VALID` /// /// The below bit is the MONOTONIC flag. /// If the MONOTONIC flag is set then (other than leap seconds) it is @@ -208,13 +211,13 @@ pub struct VMClockShmBody { /// calculated via the structure at any *later* moment. /// /// In particular, a timestamp based on a counter reading taken - /// immediately after setting the low bit of seq_count (and the + /// immediately after setting the low bit of `seq_count` (and the /// associated memory barrier), using the previously-valid time and /// period fields, shall never be later than a timestamp based on /// a counter reading taken immediately before *clearing* the low /// bit again after the update, using the about-to-be-valid fields. /// - /// Bit (1 << 7): VMCLOCK_FLAG_TIME_MONOTONIC + /// Bit (1 << 7): `VMCLOCK_FLAG_TIME_MONOTONIC` /// pub flags: u64, @@ -239,9 +242,9 @@ pub struct VMClockShmBody { /// /// Possible values are: /// - /// VMCLOCK_SMEARING_STRICT: 0 - /// VMCLOCK_SMEARING_NOON_LINEAR: 1 - /// VMCLOCK_SMEARING_UTC_SLS: 2 + /// `VMCLOCK_SMEARING_STRICT`: 0 + /// `VMCLOCK_SMEARING_NOON_LINEAR`: 1 + /// `VMCLOCK_SMEARING_UTC_SLS`: 2 /// pub leap_second_smearing_hint: u8, @@ -250,29 +253,29 @@ pub struct VMClockShmBody { /// Leap indicator. /// - /// This field is based on the the VIRTIO_RTC_LEAP_xxx values as + /// This field is based on the the `VIRTIO_RTC_LEAP_xxx` values as /// defined in the current draft of virtio-rtc, but since smearing /// cannot be used with the shared memory device, some values are /// not used. /// - /// The _POST_POS and _POST_NEG values allow the guest to perform + /// The _`POST_POS` and _`POST_NEG` values allow the guest to perform /// its own smearing during the day or so after a leap second when /// such smearing may need to continue being applied for a leap /// second which is now theoretically "historical". /// /// Possible values are: - /// VMCLOCK_LEAP_NONE 0x00 // No known nearby leap second - /// VMCLOCK_LEAP_PRE_POS 0x01 // Positive leap second at EOM - /// VMCLOCK_LEAP_PRE_NEG 0x02 // Negative leap second at EOM - /// VMCLOCK_LEAP_POS 0x03 // Set during 23:59:60 second - /// VMCLOCK_LEAP_POST_POS 0x04 - /// VMCLOCK_LEAP_POST_NEG 0x05 + /// `VMCLOCK_LEAP_NONE` 0x00 // No known nearby leap second + /// `VMCLOCK_LEAP_PRE_POS` 0x01 // Positive leap second at EOM + /// `VMCLOCK_LEAP_PRE_NEG` 0x02 // Negative leap second at EOM + /// `VMCLOCK_LEAP_POS` 0x03 // Set during 23:59:60 second + /// `VMCLOCK_LEAP_POST_POS` 0x04 + /// `VMCLOCK_LEAP_POST_NEG` 0x05 /// pub leap_indicator: u8, /// Counter period shift. /// - /// Bit shift for the counter_period_frac_sec and its error rate. + /// Bit shift for the `counter_period_frac_sec` and its error rate. pub counter_period_shift: u8, /// Counter value. @@ -281,24 +284,24 @@ pub struct VMClockShmBody { /// Counter period. /// /// This is the estimated period of the counter, in binary fractional seconds. - /// The unit of this field is: 1 / (2 ^ (64 + counter_period_shift)) of a second. + /// The unit of this field is: 1 / (2 ^ (64 + `counter_period_shift`)) of a second. pub counter_period_frac_sec: u64, /// Counter period estimated error rate. /// /// This is the estimated error rate of the counter period, in binary fractional seconds per second. - /// The unit of this field is: 1 / (2 ^ (64 + counter_period_shift)) of a second per second. + /// The unit of this field is: 1 / (2 ^ (64 + `counter_period_shift`)) of a second per second. pub counter_period_esterror_rate_frac_sec: u64, /// Counter period maximum error rate. /// /// This is the maximum error rate of the counter period, in binary fractional seconds per second. - /// The unit of this field is: 1 / (2 ^ (64 + counter_period_shift)) of a second per second. + /// The unit of this field is: 1 / (2 ^ (64 + `counter_period_shift`)) of a second per second. pub counter_period_maxerror_rate_frac_sec: u64, - /// Time according to the time_type field. + /// Time according to the `time_type` field. - /// Time: Seconds since time_type epoch. + /// Time: Seconds since `time_type` epoch. pub time_sec: u64, /// Time: Fractional seconds, in units of 1 / (2 ^ 64) of a second. @@ -312,7 +315,7 @@ pub struct VMClockShmBody { } impl Default for VMClockShmBody { - /// Get a default VMClockShmBody struct + /// Get a default `VMClockShmBody` struct /// Equivalent to zero'ing this bit of memory fn default() -> Self { VMClockShmBody { diff --git a/clock-bound/src/vmclock/shm_reader.rs b/clock-bound/src/vmclock/shm_reader.rs index 0dba469..0c7dea0 100644 --- a/clock-bound/src/vmclock/shm_reader.rs +++ b/clock-bound/src/vmclock/shm_reader.rs @@ -1,3 +1,5 @@ +#![expect(clippy::cast_ptr_alignment, reason = "TODO COME BACK TO THIS")] + use std::ffi::c_void; use std::fs::File; use std::io::Read; @@ -15,7 +17,7 @@ const VMCLOCK_SUPPORTED_VERSION: u16 = 1; /// A guard tracking an memory mapped file. /// -/// Creating the MmapGuard maps an open file descriptor. +/// Creating the `MmapGuard` maps an open file descriptor. /// The file is unmap'ed when the guard is dropped. struct MmapGuard { /// A pointer to the head of the segment @@ -29,15 +31,14 @@ struct MmapGuard { } impl MmapGuard { - /// Create a new MmapGuard. + /// Create a new `MmapGuard`. /// /// Memory map the provided open File. fn new(mut file: File) -> Result { let mut buffer = vec![]; - let bytes_read = match file.read_to_end(&mut buffer) { - Ok(bytes_read) => bytes_read, - Err(_) => return syserror!("Failed to read SHM segment"), + let Ok(bytes_read) = file.read_to_end(&mut buffer) else { + return syserror!("Failed to read SHM segment"); }; if bytes_read == 0_usize { @@ -88,7 +89,7 @@ impl MmapGuard { } impl Drop for MmapGuard { - /// Drop the MmapGuard and unmap the file it tracks. + /// Drop the `MmapGuard` and unmap the file it tracks. fn drop(&mut self) { // SAFETY: `segment` was previously returned from `mmap`, and therefore // when this destructor runs there are no more live references into @@ -102,14 +103,14 @@ impl Drop for MmapGuard { /// Reader for VMClock shared memory segment. /// -/// The VMClock shared memory segment consists of a VMClockShmHeader followed by a -/// VMClockShmBody struct. The segment is updated by a single producer (the Hypervisor), +/// The VMClock shared memory segment consists of a `VMClockShmHeader` followed by a +/// `VMClockShmBody` struct. The segment is updated by a single producer (the Hypervisor), /// but may be read by many clients. The shared memory segment does not implement a semaphore or /// equivalent to synchronize the single-producer / many-consumers processes. Instead, the /// mechanism is lock-free and relies on a `seq_count` number to ensure consistent reads (over /// retries). /// -/// The writer increments the seq_count field from even to odd before each update. It also +/// The writer increments the `seq_count` field from even to odd before each update. It also /// increment it again, from odd to even, after finishing the update. Readers must check the /// `seq_count` field before and after each read, and verify that they obtain the same, even, /// value. Otherwise, the read was dirty and must be retried. @@ -154,6 +155,7 @@ impl VMClockShmReader { /// On error, returns an appropriate `Errno`. If the content of the segment /// is uninitialized, unparseable, or otherwise malformed, EPROTO will be /// returned. + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn new(path: &str) -> Result { debug!("VMClockShmReader::new(): path is: {:?}", path); let file = match File::open(path) { @@ -220,7 +222,7 @@ impl VMClockShmReader { } // SAFETY: segment size has been checked to ensure `cursor` move leads to a valid cast cursor = unsafe { cursor.add(size_of::()) }; - let vmclock_shm_body_ptr = unsafe { ptr::addr_of!(*cursor.cast::()) }; + let vmclock_shm_body_ptr = ptr::addr_of!(*cursor.cast::()); Ok(VMClockShmReader { _marker: std::marker::PhantomData, @@ -235,15 +237,16 @@ impl VMClockShmReader { /// Return a consistent snapshot of the shared memory segment. /// - /// Taking a snapshot consists in reading the memory segment while confirming the seq_count + /// Taking a snapshot consists in reading the memory segment while confirming the `seq_count` /// number in the header has not changed (which would indicate an update from the writer /// occurred while reading). If an update is detected, the read is retried. /// - /// This function returns a reference to the VMClockShmBody snapshot stored by the reader, and - /// not an owned value. This make the VMClockShmReader NOT thread-safe: the data pointed to could be + /// This function returns a reference to the `VMClockShmBody` snapshot stored by the reader, and + /// not an owned value. This make the `VMClockShmReader` NOT thread-safe: the data pointed to could be /// updated without one of the thread knowing, leading to a incorrect clock error bond. The /// advantage are in terms of performance: less data copied, but also no locking, yielding or /// excessive retries. + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn snapshot(&mut self) -> Result<&VMClockShmBody, ShmError> { // Atomically read the current version in the shared memory segment // SAFETY: `self.version` has been validated when creating the reader @@ -305,9 +308,8 @@ impl VMClockShmReader { self.seq_count_snapshot = seq_count_first; self.vmclock_shm_body_snapshot = snapshot; return Ok(&self.vmclock_shm_body_snapshot); - } else { - seq_count_first = seq_count_second; } + seq_count_first = seq_count_second; } retries -= 1; } diff --git a/clock-bound/src/vmclock/shm_writer.rs b/clock-bound/src/vmclock/shm_writer.rs index e67d5e1..bb16da8 100644 --- a/clock-bound/src/vmclock/shm_writer.rs +++ b/clock-bound/src/vmclock/shm_writer.rs @@ -1,8 +1,9 @@ +#![expect(clippy::cast_ptr_alignment, reason = "TODO COME BACK TO THIS")] use byteorder::{LittleEndian, WriteBytesExt}; use std::ffi::c_void; +use std::io::Error; use std::io::Seek; use std::io::Write; -use std::io::{Error, ErrorKind}; use std::mem::size_of; use std::path::Path; use std::sync::atomic; @@ -24,7 +25,7 @@ pub trait VMClockShmWrite { /// /// This writer is expected to be used by a single process writing to a given path. The file /// written to is memory mapped by the writer and many (read-only) readers. Updates to the memory -/// segment are applied in a lock-free manner, using a rolling seq_count number to protect the +/// segment are applied in a lock-free manner, using a rolling `seq_count` number to protect the /// update section. #[derive(Debug)] pub struct VMClockShmWriter { @@ -34,23 +35,23 @@ pub struct VMClockShmWriter { /// A raw pointer keeping the address of the segment mapped in memory addr: *mut c_void, - /// A raw pointer to the version member of the VMClockShmHeader mapped in memory. The version number + /// A raw pointer to the version member of the `VMClockShmHeader` mapped in memory. The version number /// identifies the layout of the rest of the segment. A value of 0 indicates the memory segment /// is not initialized / not usable. version_ptr: *mut atomic::AtomicU16, /// A raw pointer to the sequence count member of the - /// VMClockShmHeader mapped in memory. The sequence count number is updated by the writer + /// `VMClockShmHeader` mapped in memory. The sequence count number is updated by the writer /// before and after updating the content mapped in memory. seq_count_ptr: *mut atomic::AtomicU32, - /// A raw pointer to the VMClockShmBody data mapped in memory. This structure follows the - /// VMClockShmHeader and contains the information required to compute a bound on clock error. + /// A raw pointer to the `VMClockShmBody` data mapped in memory. This structure follows the + /// `VMClockShmHeader` and contains the information required to compute a bound on clock error. vmclock_shm_body: *mut VMClockShmBody, } impl VMClockShmWriter { - /// Create a new VMClockShmWriter referencing the memory segment to write VMClockShmBody data to. + /// Create a new `VMClockShmWriter` referencing the memory segment to write `VMClockShmBody` data to. /// /// There are several cases to consider: /// 1. The file backing the memory segment does not exist, or the content is corrupted/wrong. @@ -63,6 +64,7 @@ impl VMClockShmWriter { /// analog to a cold boot. /// /// TODO: implement scenario 3 once the readers support a version bump. + #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn new(path: &Path) -> std::io::Result { // Determine the size of the segment. let segsize = VMClockShmWriter::segment_size(); @@ -75,7 +77,7 @@ impl VMClockShmWriter { if VMClockShmWriter::is_usable_segment(path).is_err() { // Note that wiping the file sets the version to 0, which is used to indicate the // readers that the memory segment is not usable yet. - VMClockShmWriter::wipe(path, segsize)? + VMClockShmWriter::wipe(path, segsize)?; } // Memory map the file. @@ -124,7 +126,7 @@ impl VMClockShmWriter { /// Check whether the memory segment already exist and is usable. /// - /// The segment is usable if it can be opened at `path` and it can be read by a VMClockShmReader. + /// The segment is usable if it can be opened at `path` and it can be read by a `VMClockShmReader`. fn is_usable_segment(path: &Path) -> Result<(), ShmError> { if let Some(path_str) = path.to_str() { match VMClockShmReader::new(path_str) { @@ -147,7 +149,7 @@ impl VMClockShmWriter { /// Initialize the file backing the memory segment. /// /// Zero out the file up to segsize, but write out header information such the readers can - /// access it. Note that both the layout version number and the seq_count number are set to 0, + /// access it. Note that both the layout version number and the `seq_count` number are set to 0, /// which makes this file not usable to retrieve clock error bound data yet. fn wipe(path: &Path, segsize: usize) -> std::io::Result<()> { // Attempt at creating intermediate directories, but do expect that the base permissions @@ -157,10 +159,7 @@ impl VMClockShmWriter { Some("") => (), // This would be a relative path without parent Some(_) => fs::create_dir_all(parent)?, None => { - return Err(Error::new( - ErrorKind::Other, - "Failed to extract parent dir name", - )); + return Err(Error::other("Failed to extract parent dir name")); } } } @@ -173,13 +172,9 @@ impl VMClockShmWriter { let size: u32 = match segsize.try_into() { Ok(size) => size, // it did fit Err(e) => { - return Err(std::io::Error::new( - ErrorKind::Other, - format!( - "Failed to convert segment size {:?} into u32 {:?}", - segsize, e - ), - )); + return Err(std::io::Error::other(format!( + "Failed to convert segment size {segsize:?} into u32 {e:?}" + ))); } }; @@ -206,13 +201,9 @@ impl VMClockShmWriter { // Make sure the amount of bytes written matches the segment size let pos = file.stream_position()?; if pos > size.into() { - return Err(std::io::Error::new( - ErrorKind::Other, - format!( - "SHM Writer implementation error: wrote {:?} bytes but segsize is {:?} bytes", - pos, size - ), - )); + return Err(std::io::Error::other(format!( + "SHM Writer implementation error: wrote {pos:?} bytes but segsize is {size:?} bytes" + ))); } // Sync all and drop (close) the descriptor @@ -255,7 +246,7 @@ impl VMClockShmWrite for VMClockShmWriter { /// Update the clock error bound data in the memory segment. /// /// This function implements the lock-free mechanism that lets the writer update the memory - /// segment shared with many readers. The seq_count number is set to an odd number before the + /// segment shared with many readers. The `seq_count` number is set to an odd number before the /// update and an even number when successfully completed. /// fn write(&mut self, vmclock_shm_body: &VMClockShmBody) { @@ -288,8 +279,8 @@ impl VMClockShmWrite for VMClockShmWriter { impl Drop for VMClockShmWriter { /// Unmap the memory segment /// - /// TODO: revisit to see if this can be refactored into the MmapGuard logic implemented on the - /// VMClockShmReader. + /// TODO: revisit to see if this can be refactored into the `MmapGuard` logic implemented on the + /// `VMClockShmReader`. fn drop(&mut self) { unsafe { nix::sys::mman::munmap(self.addr, self.segsize).expect("munmap"); diff --git a/examples/client/rust/Cargo.toml b/examples/client/rust/Cargo.toml index 1210c99..15ab077 100644 --- a/examples/client/rust/Cargo.toml +++ b/examples/client/rust/Cargo.toml @@ -19,7 +19,9 @@ path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clock-bound = { version = "2.0", path = "../../../clock-bound", features = ["client"] } +clock-bound = { version = "2.0", path = "../../../clock-bound", features = [ + "client", +] } nix = { version = "0.26", features = ["feature", "time"] } [dev-dependencies] diff --git a/examples/client/rust/Makefile.toml b/examples/client/rust/Makefile.toml new file mode 100644 index 0000000..6abf07b --- /dev/null +++ b/examples/client/rust/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in examples/client/rust" +''' diff --git a/examples/client/rust/src/main.rs b/examples/client/rust/src/main.rs index e6a6daa..74632a7 100644 --- a/examples/client/rust/src/main.rs +++ b/examples/client/rust/src/main.rs @@ -1,5 +1,11 @@ +#![expect( + clippy::cast_possible_truncation, + clippy::cast_lossless, + clippy::uninlined_format_args, + clippy::cast_precision_loss +)] use clock_bound::client::{ - ClockBoundClient, ClockBoundError, ClockStatus, CLOCKBOUND_SHM_DEFAULT_PATH, + CLOCKBOUND_SHM_DEFAULT_PATH, ClockBoundClient, ClockBoundError, ClockStatus, VMCLOCK_SHM_DEFAULT_PATH, }; use nix::sys::time::TimeSpec; @@ -25,10 +31,14 @@ fn main() { } }; - println!("When clockbound_now was called true time was somewhere within {}.{:0>9} and {}.{:0>9} seconds since Jan 1 1970. The clock status is {:?}.", - &now_result_first.earliest.tv_sec(), &now_result_first.earliest.tv_nsec(), - &now_result_first.latest.tv_sec(), &now_result_first.latest.tv_nsec(), - format_clock_status(&now_result_first.clock_status)); + println!( + "When clockbound_now was called true time was somewhere within {}.{:0>9} and {}.{:0>9} seconds since Jan 1 1970. The clock status is {:?}.", + &now_result_first.earliest.tv_sec(), + &now_result_first.earliest.tv_nsec(), + &now_result_first.latest.tv_sec(), + &now_result_first.latest.tv_nsec(), + format_clock_status(&now_result_first.clock_status) + ); // Very naive performance benchmark. let call_count = 100_000_000; diff --git a/test/clock-bound-vmclock-client-test/Cargo.toml b/test/clock-bound-vmclock-client-test/Cargo.toml index 8c6bbda..8a6ac49 100644 --- a/test/clock-bound-vmclock-client-test/Cargo.toml +++ b/test/clock-bound-vmclock-client-test/Cargo.toml @@ -19,7 +19,9 @@ path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clock-bound = { version = "2.0", path = "../../clock-bound", features = ["client"] } +clock-bound = { version = "2.0", path = "../../clock-bound", features = [ + "client", +] } nix = { version = "0.26", features = ["feature", "time"] } [dev-dependencies] diff --git a/test/clock-bound-vmclock-client-test/Makefile.toml b/test/clock-bound-vmclock-client-test/Makefile.toml new file mode 100644 index 0000000..1ab86e5 --- /dev/null +++ b/test/clock-bound-vmclock-client-test/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in examples/client/rust" +''' diff --git a/test/clock-bound-vmclock-client-test/src/main.rs b/test/clock-bound-vmclock-client-test/src/main.rs index 446e39d..3dd5095 100644 --- a/test/clock-bound-vmclock-client-test/src/main.rs +++ b/test/clock-bound-vmclock-client-test/src/main.rs @@ -1,5 +1,5 @@ use clock_bound::client::{ - ClockBoundClient, ClockBoundError, ClockStatus, CLOCKBOUND_SHM_DEFAULT_PATH, + CLOCKBOUND_SHM_DEFAULT_PATH, ClockBoundClient, ClockBoundError, ClockStatus, VMCLOCK_SHM_DEFAULT_PATH, }; use std::process; @@ -29,17 +29,21 @@ fn main() { } }; - println!("When clockbound_now was called true time was somewhere within {}.{:0>9} and {}.{:0>9} seconds since Jan 1 1970. The clock status is {:?}.", - &now_result.earliest.tv_sec(), &now_result.earliest.tv_nsec(), - &now_result.latest.tv_sec(), &now_result.latest.tv_nsec(), - format_clock_status(&now_result.clock_status)); + println!( + "When clockbound_now was called true time was somewhere within {}.{:0>9} and {}.{:0>9} seconds since Jan 1 1970. The clock status is {:?}.", + &now_result.earliest.tv_sec(), + &now_result.earliest.tv_nsec(), + &now_result.latest.tv_sec(), + &now_result.latest.tv_nsec(), + format_clock_status(&now_result.clock_status) + ); thread::sleep(Duration::from_millis(1000)); } } fn print_error(detail: &str, error: &ClockBoundError) { - eprintln!("{detail} {:?}", error); + eprintln!("{detail} {error:?}"); } fn format_clock_status(clock_status: &ClockStatus) -> &str { diff --git a/test/vmclock-updater/Cargo.toml b/test/vmclock-updater/Cargo.toml index fc439ac..6966bee 100644 --- a/test/vmclock-updater/Cargo.toml +++ b/test/vmclock-updater/Cargo.toml @@ -21,9 +21,14 @@ clock-bound = { version = "2.0", path = "../../clock-bound" } byteorder = "1" clap = { version = "4", features = ["derive"] } errno = { version = "0.3.0", default-features = false } -libc = { version = "0.2", default-features = false, features = ["extra_traits"] } +libc = { version = "0.2", default-features = false, features = [ + "extra_traits", +] } nix = { version = "0.26", features = ["feature", "time"] } -tracing = { version = "0.1", features = ["max_level_debug", "release_max_level_info"]} +tracing = { version = "0.1", features = [ + "max_level_debug", + "release_max_level_info", +] } tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } [dev-dependencies] diff --git a/test/vmclock-updater/Makefile.toml b/test/vmclock-updater/Makefile.toml new file mode 100644 index 0000000..1ab86e5 --- /dev/null +++ b/test/vmclock-updater/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in examples/client/rust" +''' diff --git a/test/vmclock-updater/src/main.rs b/test/vmclock-updater/src/main.rs index f69b240..4dc512c 100644 --- a/test/vmclock-updater/src/main.rs +++ b/test/vmclock-updater/src/main.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use clap::Parser; -use clock_bound::vmclock::shm::{VMClockClockStatus, VMClockShmBody, VMCLOCK_SHM_DEFAULT_PATH}; +use clock_bound::vmclock::shm::{VMCLOCK_SHM_DEFAULT_PATH, VMClockClockStatus, VMClockShmBody}; use clock_bound::vmclock::shm_writer::{VMClockShmWrite, VMClockShmWriter}; /// CLI arguments are the possible field values that can be set in the VMClock shared memory segment. @@ -27,7 +27,7 @@ struct Cli { /// The clock status indicates whether the clock is synchronized, /// free-running, etc. /// - /// Maps to enum VMClockClockStatus. + /// Maps to enum `VMClockClockStatus`. #[arg(long)] clock_status: Option, @@ -59,7 +59,7 @@ struct Cli { #[arg(long)] counter_period_maxerror_rate_frac_sec: Option, - /// Time: Seconds since time_type epoch. + /// Time: Seconds since `time_type` epoch. #[arg(long)] time_sec: Option, @@ -152,5 +152,7 @@ fn main() { // Write to the VMClock shared memory segment. vmclock_shm_writer.write(&vmclock_shm_body); - println!("Successfully wrote the following VMClockShmBody to the VMClock shared memory segment: {:?}", vmclock_shm_body); + println!( + "Successfully wrote the following VMClockShmBody to the VMClock shared memory segment: {vmclock_shm_body:?}" + ); } From b10bcdf5e8ad53138170adc571cf9724e5f95f55 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Tue, 7 Oct 2025 12:09:42 -0400 Subject: [PATCH 015/177] Implement SourceIO component and link-local runner (#15) * Adds scaffolding for the IO front end. Adds the `SourceIO` front end and initial function for link local tasks. This pull requests intends to set the pattern which all other IO tasks are expected to follow. * Implemented NTP packet struct This patch adding NTP packet struct and implements parsing required when building from a networking packet. * Implements time-stamp counter reader for intel and arm cpus. * Completed initial implementation of the NTP link local runner. This implementation does not include logic for clock disruption, or ctrl messages. * Added link local test executable. --- Cargo.lock | 477 ++++++++++++++++-- Cargo.toml | 3 +- clock-bound/Cargo.toml | 14 +- clock-bound/src/daemon/io.rs | 112 ++++ clock-bound/src/daemon/io/ntp.rs | 160 ++++++ clock-bound/src/daemon/io/ntp/packet.rs | 310 ++++++++++++ .../src/daemon/io/ntp/packet/header.rs | 223 ++++++++ clock-bound/src/daemon/io/ntp/packet/short.rs | 201 ++++++++ .../src/daemon/io/ntp/packet/timestamp.rs | 204 ++++++++ clock-bound/src/daemon/io/tsc.rs | 47 ++ test/link-local/Cargo.toml | 23 + test/link-local/Makefile.toml | 8 + test/link-local/src/main.rs | 45 ++ 13 files changed, 1792 insertions(+), 35 deletions(-) create mode 100644 clock-bound/src/daemon/io/ntp.rs create mode 100644 clock-bound/src/daemon/io/ntp/packet.rs create mode 100644 clock-bound/src/daemon/io/ntp/packet/header.rs create mode 100644 clock-bound/src/daemon/io/ntp/packet/short.rs create mode 100644 clock-bound/src/daemon/io/ntp/packet/timestamp.rs create mode 100644 clock-bound/src/daemon/io/tsc.rs create mode 100644 test/link-local/Cargo.toml create mode 100644 test/link-local/Makefile.toml create mode 100644 test/link-local/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index b1f0ee7..b3a8656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -11,6 +26,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.20" @@ -76,6 +100,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -88,18 +127,53 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.0", +] + [[package]] name = "clap" version = "4.5.48" @@ -146,12 +220,18 @@ version = "2.0.3" dependencies = [ "approx", "byteorder", + "bytes", + "chrono", "errno", + "hex-literal", "libc", "nix", + "nom", "rstest", "serde", "tempfile", + "thiserror", + "tokio", "tracing", "tracing-subscriber", ] @@ -194,6 +274,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "equivalent" version = "1.0.2" @@ -207,7 +293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -216,6 +302,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + [[package]] name = "futures-core" version = "0.3.31" @@ -267,10 +359,16 @@ checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" dependencies = [ "cfg-if", "libc", - "wasi", - "windows-targets 0.52.0", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets 0.52.6", ] +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + [[package]] name = "glob" version = "0.3.3" @@ -289,6 +387,36 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -299,6 +427,17 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "libc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -311,6 +450,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -319,9 +468,17 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.170" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" + +[[package]] +name = "link-local" +version = "2.0.3" +dependencies = [ + "clock-bound", + "tokio", +] [[package]] name = "linux-raw-sys" @@ -350,6 +507,26 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + [[package]] name = "nix" version = "0.26.4" @@ -363,6 +540,15 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -381,6 +567,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -496,6 +691,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + [[package]] name = "rustc_version" version = "0.4.1" @@ -515,9 +716,15 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" @@ -582,6 +789,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.11" @@ -594,6 +807,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "strsim" version = "0.11.1" @@ -622,7 +845,27 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -634,6 +877,34 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml_datetime" version = "0.7.2" @@ -766,6 +1037,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasi" version = "0.13.3+wasi-0.2.2" @@ -775,19 +1052,146 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.0", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -801,17 +1205,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.0", - "windows_aarch64_msvc 0.52.0", - "windows_i686_gnu 0.52.0", - "windows_i686_msvc 0.52.0", - "windows_x86_64_gnu 0.52.0", - "windows_x86_64_gnullvm 0.52.0", - "windows_x86_64_msvc 0.52.0", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -820,11 +1225,11 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.53.0", "windows_i686_msvc 0.53.0", "windows_x86_64_gnu 0.53.0", "windows_x86_64_gnullvm 0.53.0", @@ -833,9 +1238,9 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" @@ -845,9 +1250,9 @@ checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" [[package]] name = "windows_aarch64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" @@ -857,9 +1262,9 @@ checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" [[package]] name = "windows_i686_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" @@ -867,6 +1272,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.0" @@ -875,9 +1286,9 @@ checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" [[package]] name = "windows_i686_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" @@ -887,9 +1298,9 @@ checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" [[package]] name = "windows_x86_64_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" @@ -899,9 +1310,9 @@ checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" @@ -911,9 +1322,9 @@ checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" diff --git a/Cargo.toml b/Cargo.toml index 9b3f983..3086232 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "clock-bound-ffi", "examples/client/rust", "test/clock-bound-vmclock-client-test", + "test/link-local", "test/vmclock-updater", ] resolver = "3" @@ -24,4 +25,4 @@ exclude = [] keywords = ["aws", "ntp", "ec2", "time"] publish = false repository = "https://github.com/aws/clock-bound" -version = "2.0.3" \ No newline at end of file +version = "2.0.3" diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 1eab1dc..dd18e71 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -14,21 +14,33 @@ version.workspace = true [dependencies] byteorder = "1" +bytes = { version = "1", optional = true } +chrono = { version = "0.4", optional = true } errno = { version = "0.3.0", default-features = false } libc = { version = "0.2", default-features = false, features = [ "extra_traits", ] } nix = { version = "0.26", features = ["feature", "time"] } +nom = { version = "8", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } +thiserror = "2" +tokio = { version = "1.47.1", features = [ + "net", + "macros", + "rt", + "sync", + "time", +], optional = true } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } [dev-dependencies] approx = "0.5" +hex-literal = "0.4" rstest = "0.26" tempfile = "3.13" [features] client = [] -daemon = ["dep:serde"] +daemon = ["dep:serde", "dep:tokio", "dep:chrono", "dep:bytes", "dep:nom"] default = ["client", "daemon"] diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 140c097..34a3a48 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -1 +1,113 @@ //! Perform IO on clock events +//! +//! This module implements the logic needed to sample reference clocks, be it from NTP sources from +//! over the internet, the PHC via Linux's `ioctl` interface or some other source. + +#![allow(dead_code)] + +use std::collections::HashMap; + +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, watch}; +use tokio::task::spawn; +use tracing::{debug, info}; + +pub mod ntp; +use ntp::{Event as NtpEvent, LinkLocal}; + +mod tsc; + +/// `SourceIO` acts as the front end for IO tasks. +/// +/// `SourceIO` contains the interface from which new IO tasks can be spawned, as well as an interface +/// to send control commands to the specific IO tasks. +pub struct SourceIO { + /// A mapping between the time source type and the task handle. + sources: HashMap>, + /// Contains the channel used to communicate clock disruption events. + clock_disruption_channels: ClockDisruptionChannels, +} + +impl SourceIO { + /// Constructs a new `SourceIO` object and constructs the necessary resources. + pub fn construct() -> Self { + let (sender, receiver) = watch::channel::(ClockDisruptionEvent {}); + SourceIO { + sources: HashMap::new(), + clock_disruption_channels: ClockDisruptionChannels { sender, receiver }, + } + } + + /// Spawns the IO task for sampling the Link Local NTP source. + /// + /// # Panics + /// - If not called within the `tokio` runtime. + /// - If socket binding fails. + pub fn create_link_local(&mut self, event_sender: mpsc::Sender) { + info!("Creating link local source."); + + let entry = self.sources.entry(TimeSource::LinkLocal); + debug!(?entry, "Current source entry status"); + entry.or_insert_with(|| { + let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); + let communication_channels = CommunicationChannels { + event_sender, + ctrl_receiver, + clock_disruption_receiver: Some(self.clock_disruption_channels.sender.subscribe()), + }; + + spawn(async move { + let socket = UdpSocket::bind(ntp::BIND_ADDRESS).await.unwrap(); + let mut linklocal = LinkLocal::construct(socket, communication_channels); + linklocal.run().await; + }); + ctrl_sender + }); + + info!("Source update complete."); + } + + /// Starts the control flow task. + #[allow( + clippy::unused_async, + reason = "This is a stubbed function. The async component will be implemented at a later date." + )] + pub async fn run(&self) {} +} + +/// Communication channels for sending and receiving clock disruption events. +struct ClockDisruptionChannels { + sender: watch::Sender, + receiver: watch::Receiver, +} + +/// Communication channels for IO tasks. +#[derive(Debug)] +pub struct CommunicationChannels { + /// The channel which the IO task passes NTP events. + event_sender: mpsc::Sender, + + /// The channel which the IO task receives control events. + ctrl_receiver: mpsc::Receiver, + + /// The channel which the IO task receives clock disruption events. + /// + /// If the IO task is a VMClock task the no receiver is needed. + clock_disruption_receiver: Option>, +} + +// TODO: This is a stub for future clock disruption events. +#[derive(Clone, Debug)] +struct ClockDisruptionEvent {} + +// TODO: This is a stub for future control events. +#[derive(Debug)] +struct ControlRequest {} + +/// `TimeSource` is a type representing the possible time sources the daemon can collect samples +/// from. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum TimeSource { + /// The internal AWS EC2 link local source `169.254.169.123`. + LinkLocal, +} diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs new file mode 100644 index 0000000..0ff57b7 --- /dev/null +++ b/clock-bound/src/daemon/io/ntp.rs @@ -0,0 +1,160 @@ +//! Ntp IO Sources + +use std::net::{Ipv4Addr, SocketAddrV4}; + +use thiserror::Error; +use tokio::{ + io, + net::UdpSocket, + sync::mpsc, + time::{self, Duration, Interval, interval, timeout}, +}; +use tracing::{debug, info}; + +use super::tsc::read_timestamp_counter; +use super::{CommunicationChannels, NtpEvent}; +use crate::daemon::time::{ + Duration as ClockBoundDuration, Instant as ClockBoundInstant, tsc::TscCount, +}; + +pub mod packet; +pub use packet::Packet; + +pub const BIND_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); +const LINK_LOCAL_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(169, 254, 169, 123), 123); +const INTERVAL_DURATION: Duration = Duration::from_secs(1); +const LINK_LOCAL_TIMEOUT: Duration = Duration::from_millis(100); + +/// Contains the NTP and time stamp counter samples to be used by synchronization algorithm. +#[derive(Clone, Debug)] +pub struct Event { + /// TSC value before requesting a NTP packet + pub tsc_pre: TscCount, + /// TSC value after receiving a NTP packet + pub tsc_post: TscCount, + /// NTP Packet data + pub ntp_data: NtpData, +} + +#[derive(Debug, Error)] +pub enum LinkLocalError { + #[error("IO failure.")] + Io(#[from] io::Error), + #[error("Failed to parse NTP packet.")] + PacketParsing(String), + #[error("Send NtpEvent message failed.")] + SendEventMessage(#[from] mpsc::error::SendError), + #[error("Operation timed out.")] + Timeout(#[from] time::error::Elapsed), +} + +#[derive(Clone, Debug)] +pub struct NtpData { + /// NTP Server recv time + server_recv_time: ClockBoundInstant, + /// NTP Server send time + server_send_time: ClockBoundInstant, + + /// Root Delay of NTP packet + root_delay: ClockBoundDuration, + /// Root Dispersion of NTP packet + root_dispersion: ClockBoundDuration, + + /// NTP Stratum. Used in reporting, not used in ff-sync + stratum: Stratum, +} + +type Stratum = u8; + +/// Contains the data needed to run the link local runner. +#[derive(Debug)] +pub struct LinkLocal { + socket: UdpSocket, + communication_channels: CommunicationChannels, + ntp_buffer: [u8; Packet::SIZE], + interval: Interval, +} + +impl LinkLocal { + /// Constructs a new `LinkLocal` with using given parameters. + pub fn construct(socket: UdpSocket, communication_channels: CommunicationChannels) -> Self { + LinkLocal { + socket, + communication_channels, + ntp_buffer: [0u8; Packet::SIZE], + interval: interval(INTERVAL_DURATION), + } + } + + /// Samples the Link local source. + /// + /// When sampling from a NTP source we first collect the current time stamp counter + /// value. We then send a NTP request and await for a response. Once we receive a + /// response we again collect the current time stamp counter value. After we've + /// collected the NTP sample we construct the `Event` and push that event through + /// to the ring buffer. + async fn sample(&mut self) -> Result<(), LinkLocalError> { + let packet = Packet::new_request(0); + packet.emit_bytes(&mut self.ntp_buffer); + + // TODO: tsc reads and ntp samples need to be fenced. + // We are currently investigating how to implement this appropriately. + let sent_timestamp = read_timestamp_counter(); + + // Request and Receive NTP sample. + let recv_packet_result = timeout(LINK_LOCAL_TIMEOUT, { + self.socket + .send_to(&self.ntp_buffer, LINK_LOCAL_ADDRESS) + .await?; + self.socket.recv_from(&mut self.ntp_buffer) + }) + .await?; + let received_timestamp = read_timestamp_counter(); + + let (_, ntp_packet) = Packet::parse_from_bytes(&self.ntp_buffer) + .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; + + let ntp_event: Event = Event { + tsc_pre: TscCount::new(sent_timestamp.into()), + tsc_post: TscCount::new(received_timestamp.into()), + ntp_data: NtpData::from(ntp_packet), + }; + + debug!(?recv_packet_result, "Received packet."); + self.communication_channels + .event_sender + .send(ntp_event.clone()) + .await?; + debug!(?ntp_event, "Successfully send link local event."); + Ok(()) + } + + /// NTP Link Local task runner. + /// + /// Sampling NTP packets from the AWS EC2 internal Link Local address. + /// + /// # Panics + /// Function will panic if not called within the `tokio` runtime. + pub async fn run(&mut self) { + // Sampling loop + info!("Starting link local sampling loop."); + loop { + tokio::select! { + _ = self.interval.tick() => { + if let Err(e) = self.sample().await { + debug!(?e, "Failed to sample link local source."); + } + } + _ = self.communication_channels.ctrl_receiver.recv() => { + // Ctrl logic here. + // Currently we breakout of the loop if we receive a control event. + break; + } + _ = self.communication_channels.clock_disruption_receiver.as_mut().unwrap().changed() => { + // Clock Disruption logic here + } + } + } + info!("Link local runner exiting."); + } +} diff --git a/clock-bound/src/daemon/io/ntp/packet.rs b/clock-bound/src/daemon/io/ntp/packet.rs new file mode 100644 index 0000000..0a477b6 --- /dev/null +++ b/clock-bound/src/daemon/io/ntp/packet.rs @@ -0,0 +1,310 @@ +//! Parsing and emitting NTP packets +#![allow( + clippy::missing_errors_doc, + clippy::missing_panics_doc, + reason = "TODO: Shamik please add documentation." +)] +#![allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_possible_wrap, + clippy::cast_sign_loss, + reason = "TODO: Shamik please address various warning associated with casting." +)] +use std::fmt::Display; + +use nom::Parser; +use nom::number::{be_i8, be_u8}; + +mod header; +mod short; +mod timestamp; + +pub use header::{LeapIndicator, Mode, Version}; +pub use short::Short; +pub use timestamp::Timestamp; + +use super::NtpData; + +use crate::daemon::time::{Duration as ClockBoundDuration, Instant as ClockBoundInstant}; + +/// An NTP packet, as defined in RFC 5905 +/// +/// See each of the fields for more information +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Packet { + /// warning of impending leap second + pub leap_indicator: LeapIndicator, + /// NTP version + pub version: Version, + /// Association mode + pub mode: Mode, + /// Stratum + pub stratum: u8, + /// Maximum interval between successive messages, in log2 seconds + pub poll: u8, + /// Precision of system clock in log2 seconds + pub precision: i8, + /// Total round trip delay to the reference clock + pub root_delay: Short, + /// Total dispersion to the reference clock + pub root_dispersion: Short, + /// 32 bit code identifying the particular server/reference clock + pub reference_id: [u8; 4], + /// Time when the system clock was last corrected + pub reference_timestamp: Timestamp, + /// Time at the client when the request departed for the server + pub origin_timestamp: Timestamp, + /// Time at the server when the request arrived from the client + pub receive_timestamp: Timestamp, + /// Time at the server when the response left for the client + pub transmit_timestamp: Timestamp, +} + +impl Display for Packet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Packet")?; + writeln!(f, "leap indicator: {:?}", self.leap_indicator)?; + writeln!(f, "version: {:?}", self.version)?; + writeln!(f, "mode: {:?}", self.mode)?; + writeln!(f, "stratum: {}", self.stratum)?; + writeln!(f, "poll: {}", self.poll)?; + writeln!(f, "precision: {}", self.precision)?; + writeln!(f, "root_delay: {}", self.root_delay.to_time_delta())?; + writeln!( + f, + "root_dispersion: {}", + self.root_dispersion.to_time_delta() + )?; + writeln!(f, "reference_id: {:?}", self.reference_id)?; + writeln!( + f, + "reference_timestamp: {}", + self.reference_timestamp.to_utc_datetime() + )?; + writeln!( + f, + "origin_timestamp: {}", + self.origin_timestamp.to_utc_datetime() + )?; + writeln!( + f, + "receive_timestamp: {}", + self.receive_timestamp.to_utc_datetime() + )?; + writeln!( + f, + "transmit_timestamp: {}", + self.transmit_timestamp.to_utc_datetime() + )?; + Ok(()) + } +} + +impl Packet { + /// NTP packet size in bytes, without extensions + pub const SIZE: usize = 48; + + /// Construct an NTP request packet using an arbitrary u32 as the transmit timestamp + /// + /// Convention is that the transmit timestamp does not need to correlate with any real timestamp + /// in the NTP request. Hence, the `u64` input. + /// + /// If one wants to modify this constructor, they can simply do so via + /// ``` + /// use clock_bound::daemon::io::ntp::packet::Packet; + /// + /// let modified_request = Packet { + /// poll: 4, + /// ..Packet::new_request(0xd00f) + /// }; + /// ``` + pub const fn new_request(transmit_timestamp: u64) -> Self { + let transmit_timestamp = Timestamp::new(transmit_timestamp); + // These values don't HAVE to be zero, but keeping it simple for now. + Self { + leap_indicator: LeapIndicator::NoWarning, + version: Version::V4, + mode: Mode::Client, + stratum: 0, + poll: 0, + precision: 0, + root_delay: Short::new(0), + root_dispersion: Short::new(0), + reference_id: [0u8; 4], + reference_timestamp: Timestamp::new(0), + origin_timestamp: Timestamp::new(0), + receive_timestamp: Timestamp::new(0), + transmit_timestamp, + } + } + + /// Emit packet into bytes + /// + /// ## Panics + /// Panics if buffer is smaller than [`Packet::SIZE`] + pub fn emit_bytes(&self, mut buffer: &mut [u8]) { + use bytes::BufMut; + let header: u8 = + self.mode.to_bits() | self.version.get() << 3 | self.leap_indicator.to_bits() << 6; + buffer.put_u8(header); + buffer.put_u8(self.stratum); + buffer.put_u8(self.poll); + buffer.put_i8(self.precision); + buffer.put_u32_ne(self.root_delay.into_network_endian()); + buffer.put_u32_ne(self.root_dispersion.into_network_endian()); + buffer.put(self.reference_id.as_slice()); + buffer.put_u64_ne(self.reference_timestamp.into_network_endian()); + buffer.put_u64_ne(self.origin_timestamp.into_network_endian()); + buffer.put_u64_ne(self.receive_timestamp.into_network_endian()); + buffer.put_u64_ne(self.transmit_timestamp.into_network_endian()); + } + + /// Parse from packet payload + pub fn parse_from_bytes(input: &[u8]) -> nom::IResult<&[u8], Packet> { + let (input, (leap_indicator, version, mode)) = + nom::bits::bits((LeapIndicator::parse, Version::parse, Mode::parse)).parse(input)?; + + let (input, (stratum, poll, precision)) = (be_u8(), be_u8(), be_i8()).parse(input)?; + + let (input, (root_delay, root_dispersion)) = (Short::parse, Short::parse).parse(input)?; + + let (input, reference_id) = nom::bytes::take(4usize).parse(input)?; + let reference_id: [u8; 4] = reference_id.try_into().unwrap(); + + let (input, (reference_timestamp, origin_timestamp)) = + (Timestamp::parse, Timestamp::parse).parse(input)?; + let (input, (receive_timestamp, transmit_timestamp)) = + (Timestamp::parse, Timestamp::parse).parse(input)?; + + let rv = Self { + leap_indicator, + version, + mode, + stratum, + poll, + precision, + root_delay, + root_dispersion, + reference_id, + reference_timestamp, + origin_timestamp, + receive_timestamp, + transmit_timestamp, + }; + Ok((input, rv)) + } +} + +impl From for NtpData { + fn from(value: Packet) -> Self { + let root_delay = ClockBoundDuration::from(value.root_delay); + let root_dispersion = ClockBoundDuration::from(value.root_dispersion); + let server_recv_time = ClockBoundInstant::from(value.receive_timestamp); + let server_send_time = ClockBoundInstant::from(value.transmit_timestamp); + + NtpData { + server_recv_time, + server_send_time, + + root_delay, + root_dispersion, + + stratum: value.stratum, + } + } +} + +#[cfg(test)] +mod test { + use chrono::{DateTime, TimeDelta}; + use hex_literal::hex; + + use super::*; + + // test packet grabbed from wireshark + // Network Time Protocol (NTP Version 4, server) + // Flags: 0x24, Leap Indicator: no warning, Version number: NTP Version 4, Mode: server + // [Request In: 5] + // [Delta Time: 0.002007000 seconds] + // Peer Clock Stratum: primary reference (1) + // Peer Polling Interval: 3 (8 seconds) + // Peer Clock Precision: -18 (0.000003815 seconds) + // Root Delay: 0.000031 seconds + // Root Dispersion: 0.000015 seconds + // Reference ID: Unidentified reference source '���z' + // Reference Timestamp: Jan 29, 2025 22:28:14.000004228 UTC // 1738189694 seconds + // Origin Timestamp: Jan 29, 2025 22:28:14.687415882 UTC // 1738189694 seconds + // Receive Timestamp: Jan 29, 2025 22:28:14.689357564 UTC // 1738189694 seconds + // Transmit Timestamp: Jan 29, 2025 22:28:14.689379474 UTC // 1738189694 seconds + const NTP_PACKET: &[u8] = &hex!( + " + 240103ee0000000200000001a9fea97aeb4529fe + 000046f3eb4529feaffa7cbceb4529feb079bcc4 + eb4529feb07b2c5b + " + ); + + #[test] + fn parse() { + let (leftover, packet) = Packet::parse_from_bytes(NTP_PACKET).unwrap(); + assert!(leftover.is_empty()); + assert_eq!(packet.leap_indicator, LeapIndicator::NoWarning); + assert_eq!(packet.version.get(), 4); + assert_eq!(packet.mode, Mode::Server); + assert_eq!(packet.stratum, 1); + assert_eq!(packet.poll, 3); + assert_eq!(packet.precision, -18); // todo, create type which represents this better + packet.root_delay.assert_within_error_bound( + TimeDelta::from_std(std::time::Duration::from_secs_f64(0.000031)).unwrap(), + ); + packet.root_dispersion.assert_within_error_bound( + TimeDelta::from_std(std::time::Duration::from_secs_f64(0.000015)).unwrap(), + ); + assert_eq!(packet.reference_id, [169, 254, 169, 122]); + packet + .reference_timestamp + .assert_within_error_bound(DateTime::from_timestamp(1738189694, 000004228).unwrap()); + packet + .origin_timestamp + .assert_within_error_bound(DateTime::from_timestamp(1738189694, 687415882).unwrap()); + packet + .receive_timestamp + .assert_within_error_bound(DateTime::from_timestamp(1738189694, 689357564).unwrap()); + packet + .transmit_timestamp + .assert_within_error_bound(DateTime::from_timestamp(1738189694, 689379474).unwrap()); + } + + #[test] + fn emit() { + let (_, packet) = Packet::parse_from_bytes(NTP_PACKET).unwrap(); + let mut output = vec![0u8; Packet::SIZE]; + packet.emit_bytes(&mut output); + assert_eq!(output, NTP_PACKET); + } + + #[test] + fn display_does_not_panic() { + let packet = Packet::new_request(0xd00f_d00f); + let _ = packet.to_string(); + } + + #[test] + fn conversion_packet_to_ntpdata() { + let (_, packet) = Packet::parse_from_bytes(NTP_PACKET).unwrap(); + let ntpdata = NtpData::from(packet); + + assert!((ntpdata.root_delay.as_seconds_f64() - 0.000031).abs() < 1.0 / 65536.0); + assert!((ntpdata.root_dispersion.as_seconds_f64() - 0.000015).abs() < 1.0 / 65536.0); + assert_eq!( + ntpdata.server_recv_time, + ClockBoundInstant::from_time(1738189694, 689357564) + ); + assert_eq!( + ntpdata.server_send_time, + ClockBoundInstant::from_time(1738189694, 689379474) + ); + assert_eq!(ntpdata.stratum, 1); + } +} diff --git a/clock-bound/src/daemon/io/ntp/packet/header.rs b/clock-bound/src/daemon/io/ntp/packet/header.rs new file mode 100644 index 0000000..e6c0979 --- /dev/null +++ b/clock-bound/src/daemon/io/ntp/packet/header.rs @@ -0,0 +1,223 @@ +//! Header components in the NTP packet + +use nom::Parser; + +/// Leap indicator of an NTP packet +/// +/// See [RFC 5905](https://datatracker.ietf.org/doc/html/rfc5905#section-7.3) +/// for more information +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LeapIndicator { + /// No Warning + NoWarning, + /// Last minute of the day had 61 seconds + LastMinute61Seconds, + /// Last minute of the day had 59 seconds + LastMinute59Seconds, + /// Unknown (Clock unsynchronized) + Unknown, +} + +impl LeapIndicator { + /// parse from bits + pub fn parse(input: Bits) -> nom::IResult { + // unwrap ok. max value of 2 bits is 3 + nom::bits::complete::take(2usize) + .map(|val| Self::from_bits(val).unwrap()) + .parse(input) + } + + /// Construct from network bits + /// + /// Returns none is val >= 4 + pub fn from_bits(val: u8) -> Option { + match val { + 0 => Some(Self::NoWarning), + 1 => Some(Self::LastMinute61Seconds), + 2 => Some(Self::LastMinute59Seconds), + 3 => Some(Self::Unknown), + _ => None, + } + } + + /// Construct network bits + pub fn to_bits(self) -> u8 { + match self { + Self::NoWarning => 0, + Self::LastMinute61Seconds => 1, + Self::LastMinute59Seconds => 2, + Self::Unknown => 3, + } + } +} + +/// Mode of an NTP packet +/// +/// See [RFC 5905](https://datatracker.ietf.org/doc/html/rfc5905#section-7.3) +/// for more information +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Mode { + /// reserved + Reserved, + /// symmetric active + SymmetricActive, + /// symmetric passive + SymmetricPassive, + /// client + Client, + /// server + Server, + /// broadcast + Broadcast, + /// ntp control message + NtpControlMessage, + /// reserved for private use + ReservedForPrivateUse, +} + +impl Mode { + /// parse from bits + pub fn parse(input: Bits) -> nom::IResult { + nom::bits::complete::take(3usize) + .map(|val| Self::from_bits(val).unwrap()) + .parse(input) + } + + /// Construct from network bits + pub fn from_bits(bits: u8) -> Option { + match bits { + 0 => Some(Self::Reserved), + 1 => Some(Self::SymmetricActive), + 2 => Some(Self::SymmetricPassive), + 3 => Some(Self::Client), + 4 => Some(Self::Server), + 5 => Some(Self::Broadcast), + 6 => Some(Self::NtpControlMessage), + 7 => Some(Self::ReservedForPrivateUse), + _ => None, + } + } + + /// Construct network bits + pub fn to_bits(self) -> u8 { + match self { + Self::Reserved => 0, + Self::SymmetricActive => 1, + Self::SymmetricPassive => 2, + Self::Client => 3, + Self::Server => 4, + Self::Broadcast => 5, + Self::NtpControlMessage => 6, + Self::ReservedForPrivateUse => 7, + } + } +} + +/// The 3-bit version field in the NTP header +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + inner: u8, +} + +impl Version { + /// Version 4 + pub const V4: Self = Self { inner: 4 }; + + /// parse from bits + pub fn parse(input: Bits) -> nom::IResult { + nom::bits::complete::take(3usize) + .map(|val| Self::from_bits(val).unwrap()) + .parse(input) + } + + /// Constructor + /// + /// Returns Some if value is 3 bits or less + pub const fn from_bits(value: u8) -> Option { + if value <= 0b111 { + Some(Self { inner: value }) + } else { + None + } + } + + /// Get version as u8 + pub const fn get(self) -> u8 { + self.inner + } +} + +type Bits<'a> = (&'a [u8], usize); + +#[cfg(test)] +mod test { + use super::*; + use rstest::rstest; + + #[rstest] + #[case(&[0 << 6], LeapIndicator::NoWarning)] + #[case(&[1 << 6], LeapIndicator::LastMinute61Seconds)] + #[case(&[2 << 6], LeapIndicator::LastMinute59Seconds)] + #[case(&[3 << 6], LeapIndicator::Unknown)] + fn parse_leap_indicator(#[case] byte: &[u8], #[case] expected: LeapIndicator) { + let res: nom::IResult<&[u8], LeapIndicator> = + nom::bits::bits(LeapIndicator::parse).parse(byte); + let res = res.unwrap(); + assert_eq!(res.1, expected); + assert!(res.0.is_empty()); + } + + #[rstest] + #[case(0, LeapIndicator::NoWarning)] + #[case(1, LeapIndicator::LastMinute61Seconds)] + #[case(2, LeapIndicator::LastMinute59Seconds)] + #[case(3, LeapIndicator::Unknown)] + fn leap_indicator_to_bits(#[case] expected: u8, #[case] leap_indicator: LeapIndicator) { + let bits = leap_indicator.to_bits(); + assert_eq!(bits, expected); + } + + #[rstest] + #[case(0)] + #[case(1)] + #[case(7)] + #[case(4)] + fn parse_version(#[case] byte: u8) { + let input = [byte << 5]; + let input = input.as_slice(); + let res: nom::IResult<&[u8], Version> = nom::bits::bits(Version::parse).parse(input); + let res = res.unwrap(); + assert_eq!(res.1, Version::from_bits(byte).unwrap()); + assert!(res.0.is_empty()); + } + + #[rstest] + #[case(&[0 << 5], Mode::Reserved)] + #[case(&[1 << 5], Mode::SymmetricActive)] + #[case(&[2 << 5], Mode::SymmetricPassive)] + #[case(&[3 << 5], Mode::Client)] + #[case(&[4 << 5], Mode::Server)] + #[case(&[5 << 5], Mode::Broadcast)] + #[case(&[6 << 5], Mode::NtpControlMessage)] + #[case(&[7 << 5], Mode::ReservedForPrivateUse)] + fn parse_mode(#[case] byte: &[u8], #[case] expected: Mode) { + let res: nom::IResult<&[u8], Mode> = nom::bits::bits(Mode::parse).parse(byte); + let res = res.unwrap(); + assert_eq!(res.1, expected); + assert!(res.0.is_empty()); + } + + #[rstest] + #[case(0, Mode::Reserved)] + #[case(1, Mode::SymmetricActive)] + #[case(2, Mode::SymmetricPassive)] + #[case(3, Mode::Client)] + #[case(4, Mode::Server)] + #[case(5, Mode::Broadcast)] + #[case(6, Mode::NtpControlMessage)] + #[case(7, Mode::ReservedForPrivateUse)] + fn mode_to_bits(#[case] expected: u8, #[case] mode: Mode) { + let bits = mode.to_bits(); + assert_eq!(bits, expected); + } +} diff --git a/clock-bound/src/daemon/io/ntp/packet/short.rs b/clock-bound/src/daemon/io/ntp/packet/short.rs new file mode 100644 index 0000000..cc44f24 --- /dev/null +++ b/clock-bound/src/daemon/io/ntp/packet/short.rs @@ -0,0 +1,201 @@ +//! NTP Short implementation + +use chrono::TimeDelta; +use nom::Parser; +use std::time::Duration; + +use crate::daemon::time::Duration as ClockBoundDuration; + +/// NTP Short value +/// +/// See [RFC 5905](https://datatracker.ietf.org/doc/html/rfc5905#section-6) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +#[repr(transparent)] +pub struct Short { + // stored in native endian + inner: u32, +} + +impl Short { + /// Maximum spec-supported value + pub const MAX: Self = Self { inner: 0xFFFF_FFFF }; + /// Maximum spec-supported value in seconds + pub const MAX_SEC: f64 = Self::to_secs_f64(Self::MAX); + /// Zero value + pub const ZERO: Self = Self { inner: 0 }; + + /// Minimum subsecond difference this type can represent, in seconds + pub const EPSILON: f64 = 1.0f64 / (1u64 << 16) as f64; + + /// Construct from a native endian representation value + pub const fn new(ne: u32) -> Self { + Self { inner: ne } + } + + /// Convert to seconds + pub const fn to_secs_f64(self) -> f64 { + self.inner as f64 / 65536.0 + } + + /// Convert from seconds + /// + /// Returns None if out of bounds or negative + pub fn from_secs_f64(secs: f64) -> Option { + (0.0..=Self::MAX_SEC).contains(&secs).then(|| { + let inner = (secs * 65536.0).ceil() as u32; + Self { inner } + }) + } + + /// Convert to time delta + pub fn to_time_delta(self) -> TimeDelta { + let seconds = self.inner >> 16; + let fraction = self.inner & 0xFFFF; + let nanoseconds = f64::from(fraction) / f64::from(1u32 << 16) * 1e9f64; + let nanoseconds = nanoseconds as u32; + // unwrap ok. TimeDelta encompasses all possible time of Short + TimeDelta::new(i64::from(seconds), nanoseconds).unwrap() + } + + /// Convert to network endian representation + pub fn into_network_endian(self) -> u32 { + self.inner.to_be() + } + + /// Parse from bytes + pub fn parse(input: &[u8]) -> nom::IResult<&[u8], Self> { + nom::number::be_u32() + .parse(input) + .map(|(input, val)| (input, Self::new(val))) + } + + /// panics if not within expected error bounds + #[cfg(test)] + pub fn assert_within_error_bound(self, expected: TimeDelta) { + let error = self.to_time_delta() - expected; + let error = error.abs().to_std().unwrap().as_secs_f64(); + + if error > Self::EPSILON { + panic!( + "error when comparing. expected: {expected}, actual: {0}", + self.to_time_delta() + ); + } + } +} + +impl From for Duration { + fn from(value: Short) -> Self { + let secs = value.to_secs_f64(); + Duration::from_secs_f64(secs) + } +} + +impl TryFrom for Short { + type Error = OutOfRangeError; + + fn try_from(value: Duration) -> Result { + Self::from_secs_f64(value.as_secs_f64()).ok_or(OutOfRangeError) + } +} + +impl From for ClockBoundDuration { + fn from(value: Short) -> Self { + let secs = value.to_secs_f64(); + Self::from_seconds_f64(secs) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutOfRangeError; + +impl std::fmt::Display for OutOfRangeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("NTP out of range") + } +} + +#[cfg(test)] +mod test { + use hex_literal::hex; + + use super::*; + + #[test] + fn negative_is_none() { + let value = Short::from_secs_f64(-1.0); + assert_eq!(value, None); + } + + #[test] + fn above_max_saturates() { + let secs = Duration::from_secs_f64(u16::MAX as f64 + 1.0); + Short::try_from(secs).unwrap_err(); + } + + #[test] + fn seconds() { + let secs = 15; + let duration = Duration::from_secs(secs as u64); + let value = Short::try_from(duration).unwrap(); + + assert_eq!(value.inner, secs << 16); + } + + #[rstest::rstest] + #[case::minimal(1.0 / (1 << 17) as f64, 1)] + #[case::maximal(1.0 - 1.0 / ( 1 << 17) as f64, 1 << 16)] + fn fraction(#[case] secs: f64, #[case] expected_inner: u32) { + let value = Short::from_secs_f64(secs).unwrap(); + assert_eq!(value.inner, expected_inner); + } + + #[test] + fn to_secs() { + let ntp_value = Short { inner: 3 << 16 }; + assert_eq!(ntp_value.to_secs_f64(), 3.0); + } + + // Root Delay: 0.000031 seconds. Value taken from a wireshark capture + const ROOT_DELAY: &[u8] = &hex!("00000002"); + const ROOT_DELAY_SECS: f64 = 0.000031; + + #[test] + fn parse() { + let (leftover, parsed) = Short::parse(ROOT_DELAY).unwrap(); + assert!(leftover.is_empty()); + // NTP short is only SO accurate + let allowed_error = 1f64 / (1 << 16) as f64; + let error = (parsed.to_secs_f64() - ROOT_DELAY_SECS).abs(); + assert!(error <= allowed_error); + } + + #[test] + fn to_time_delta() { + let value = Short::from_secs_f64(15.00078).unwrap(); + // loses accuracy when going to Short + let float_value = value.to_secs_f64(); + let time_delta = value.to_time_delta(); + let time_delta_secs = time_delta.to_std().unwrap().as_secs_f64(); + // time delta is accurate to nanoseconds. F64 has roughly 15.95 digits worth of accuracy. + // Given that the maximum value that Short can realistically hold is less than 65_535.999_999_999, + // then these values should be accurate to +/- 1 nanosecond + let allowable_error = 0.000_000_001; + let error = (time_delta_secs - float_value).abs(); + assert!(error <= allowable_error); + } + + #[test] + fn max_value() { + let value = Short::from_secs_f64(Short::MAX_SEC).unwrap(); + assert_eq!(value, Short::MAX); + } + + #[test] + fn from_clock_bound_duration() { + let short = Short::from_secs_f64(11.123).unwrap(); + let duration = ClockBoundDuration::from(short); + + assert_eq!(duration.as_seconds_f64(), short.to_secs_f64()); + } +} diff --git a/clock-bound/src/daemon/io/ntp/packet/timestamp.rs b/clock-bound/src/daemon/io/ntp/packet/timestamp.rs new file mode 100644 index 0000000..a0aafb0 --- /dev/null +++ b/clock-bound/src/daemon/io/ntp/packet/timestamp.rs @@ -0,0 +1,204 @@ +//! NTP Timestamp implementation + +use chrono::{DateTime, TimeDelta, Utc}; +use nom::Parser; + +use crate::daemon::time::Instant as ClockBoundInstant; + +/// NTP Timestamp value +/// +/// See [RFC 5905](https://datatracker.ietf.org/doc/html/rfc5905#section-6) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +#[repr(transparent)] +pub struct Timestamp { + // stored in native endian + inner: u64, +} + +impl Timestamp { + /// Maximum spec-supported value + pub const MAX: Self = Self { inner: u64::MAX }; + + /// Zero value + pub const ZERO: Self = Self { inner: 0 }; + + /// Minimum subsecond difference this type can represent, in seconds + pub const EPSILON: f64 = 1.0f64 / (1u64 << 32) as f64; + + /// Maximum spec-supported value as seconds since the NTP epoch for 1900 + pub const fn max_seconds_since_ntp_epoch() -> f64 { + let td = Self::MAX.to_time_delta_since_ntp_epoch(); + let secs = td.num_seconds() as f64; + let subsecs = td.subsec_nanos() as f64 / 1e9f64; + secs + subsecs + } + + // From https://stackoverflow.com/questions/29112071/how-to-convert-ntp-time-to-unix-epoch-time-in-c-language-linux + // + // Unix uses an epoch located at 1/1/1970-00:00h (UTC) and NTP uses 1/1/1900-00:00h. + // This leads to an offset equivalent to 70 years in seconds. + // there are 17 leap years between the two dates so the offset is + // (70*365 + 17)*86400 = 2208988800 + const NTP_UNIX_EPOCH_DIFFERENCE_CHRONO: chrono::TimeDelta = + chrono::TimeDelta::new(2_208_988_800, 0).unwrap(); + + /// Construct from a native endian representation + pub const fn new(val: u64) -> Self { + Self { inner: val } + } + + /// Convert to time since the NTP epoch + /// + /// NTP epoch is 1/1/1900-00:00h. + /// NOTE that this is different from the UNIX epoch at 1970 + pub const fn to_time_delta_since_ntp_epoch(self) -> TimeDelta { + const FRACTION_TO_NANO: f64 = 1e9f64 / (1u64 << 32) as f64; + let seconds = self.inner >> 32; + let fraction = self.inner & 0xFFFF_FFFF; + let nanoseconds = fraction as f64 * FRACTION_TO_NANO; + // round down on Self -> float conversions. Opposite in other direction. + let nanoseconds = nanoseconds as u32; + // Unwrap ok. TimeDelta encompasses all of Timestamp + TimeDelta::new(seconds as i64, nanoseconds).unwrap() + } + + /// Convert to time since UNIX epoch + pub fn to_time_delta_since_unix_epoch(self) -> TimeDelta { + let ntp_time = self.to_time_delta_since_ntp_epoch(); + ntp_time - Self::NTP_UNIX_EPOCH_DIFFERENCE_CHRONO + } + + /// Convert to UTC datetime + pub fn to_utc_datetime(self) -> DateTime { + let secs = self.to_time_delta_since_unix_epoch(); + DateTime::::from_timestamp(secs.num_seconds(), secs.subsec_nanos().unsigned_abs()) + .unwrap() + } + + /// Convert UTC datetime to ntp timestamp + /// + /// Returns None if out of range + pub fn from_utc_system(val: chrono::DateTime) -> Option { + let seconds = val.timestamp() + Self::NTP_UNIX_EPOCH_DIFFERENCE_CHRONO.num_seconds(); + let nanoseconds = f64::from(val.timestamp_subsec_nanos()) / 1e9; + let seconds = seconds as f64 + nanoseconds; + Self::from_secs_since_ntp_epoch(seconds) + } + + /// Convert from seconds + pub fn from_secs_since_ntp_epoch(secs: f64) -> Option { + (0.0..=Self::max_seconds_since_ntp_epoch()) + .contains(&secs) + .then(|| { + // round up when going from f64 -> Self. Down in opposite direction + let inner = (secs * (1u64 << 32) as f64).ceil() as u64; + Self { inner } + }) + } + + /// Convert to network endian representation + pub fn into_network_endian(self) -> u64 { + self.inner.to_be() + } + + /// Parse from bytes of an NTP packet + pub fn parse(input: &[u8]) -> nom::IResult<&[u8], Self> { + nom::number::be_u64() + .parse(input) + .map(|(input, val)| (input, Self::new(val))) + } + + /// panics if not within expected error bounds + #[cfg(test)] + pub fn assert_within_error_bound(self, expected: DateTime) { + let error = self.to_utc_datetime() - expected; + let error = error.abs().to_std().unwrap().as_secs_f64(); + + if error > Self::EPSILON { + panic!( + "error when comparing. expected: {expected}, actual: {0}", + self.to_utc_datetime() + ); + } + } +} + +impl From for DateTime { + fn from(value: Timestamp) -> Self { + value.to_utc_datetime() + } +} + +impl From for ClockBoundInstant { + fn from(value: Timestamp) -> Self { + let dt = value.to_utc_datetime(); + Self::from_time(i128::from(dt.timestamp()), dt.timestamp_subsec_nanos()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use hex_literal::hex; + + #[test] + fn negative_is_none() { + let value = Timestamp::from_secs_since_ntp_epoch(-1.0); + assert_eq!(value, None); + } + + #[test] + fn above_max_errors() { + let time_stamp = DateTime::::from_timestamp(u32::MAX as i64 + 1, 0).unwrap(); + let value = Timestamp::from_utc_system(time_stamp); + assert_eq!(value, None); + } + + #[test] + fn seconds() { + let secs = 15; + let value = Timestamp::from_secs_since_ntp_epoch(secs as f64).unwrap(); + + assert_eq!(value.inner, secs << 32); + } + + #[rstest::rstest] + #[case::minimal(1.0 / (1u64 << 33) as f64, 1)] + #[case::maximal(1.0 - 1.0 / ( 1u64 << 33) as f64, 1 << 32)] + fn fraction(#[case] secs: f64, #[case] expected_inner: u64) { + let value = Timestamp::from_secs_since_ntp_epoch(secs).unwrap(); + assert_eq!(value.inner, expected_inner); + } + + #[test] + fn to_secs() { + let ntp_value = Timestamp { inner: 3 << 32 }; + assert_eq!(ntp_value.to_time_delta_since_ntp_epoch().num_seconds(), 3); + } + + // Jan 29, 2025 22:28:14.000004228 UTC + // seconds since epoch: 1738189694 + // nanoseconds: 4228 + const TIMESTAMP: &[u8] = &hex!("eb4529fe000046f3"); + const SECONDS: u64 = 1738189694; + const NANOSECONDS: i32 = 4228; + + #[test] + fn parse_from_packet() { + let (leftover, parsed) = Timestamp::parse(TIMESTAMP).unwrap(); + assert!(leftover.is_empty()); + let secs = parsed.to_time_delta_since_unix_epoch(); + assert_eq!(secs.num_seconds() as u64, SECONDS); + let error = secs.subsec_nanos() - NANOSECONDS; + assert!(error.abs() < 1, "{error} {secs}") + } + + #[test] + fn from_clock_bound_instant() { + let (_, timestamp) = Timestamp::parse(TIMESTAMP).unwrap(); + let datetime = timestamp.to_utc_datetime(); + let instant = ClockBoundInstant::from(timestamp); + + assert_eq!(instant.as_seconds() as i64, datetime.timestamp()); + } +} diff --git a/clock-bound/src/daemon/io/tsc.rs b/clock-bound/src/daemon/io/tsc.rs new file mode 100644 index 0000000..fd8df69 --- /dev/null +++ b/clock-bound/src/daemon/io/tsc.rs @@ -0,0 +1,47 @@ +//! Module for reading TSC values. + +/// Reads the current value of the processor's time-stamp counter. +#[cfg(target_arch = "aarch64")] +pub fn read_timestamp_counter() -> u64 { + // aarch64 documentation: https://developer.arm.com/documentation/ddi0601/2021-12/AArch64-Registers/CNTVCT-EL0--Counter-timer-Virtual-Count-register + use std::arch::asm; + + let rv: u64; + unsafe { + asm!("mrs {}, cntvct_el0", out(reg) rv); + } + rv +} + +#[cfg(target_arch = "x86_64")] +pub fn read_timestamp_counter() -> u64 { + /* + There are a number of options for getting tsc values on x86_64 cpus. + We could get them from the registers ourselves leveraging assembly + ``` + // From: https://oliveryang.net/2015/09/pitfalls-of-TSC-usage/ + static uint64_t rdtsc(void) + { + uint64_t var; + uint32_t hi, lo; + + __asm volatile + ("rdtsc" : "=a" (lo), "=d" (hi)); + + var = ((uint64_t)hi << 32) | lo; + return (var); + } + ``` + + Or we can get them from the llvm libs. + https://doc.rust-lang.org/beta/src/core/stdarch/crates/core_arch/src/x86/rdtsc.rs.html#55 + core::arch::x86_64::_rdtsc; + { + _rdtsc() + } + + I've chosen to get the values from llvm because as I'm confident they are implemented correctly. + */ + use core::arch::x86_64::_rdtsc; + unsafe { _rdtsc() } +} diff --git a/test/link-local/Cargo.toml b/test/link-local/Cargo.toml new file mode 100644 index 0000000..170c7c6 --- /dev/null +++ b/test/link-local/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "link-local" +description = "A test program that attempts to sample NTP packets from link local." +license = "Apache-2.0" +publish = false + +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +keywords.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "link-local-test" +path = "src/main.rs" + +[dependencies] +clock-bound = { version = "2.0", path = "../../clock-bound", features = [ + "daemon", +] } +tokio = { version = "1.47.1", features = ["macros", "rt"] } diff --git a/test/link-local/Makefile.toml b/test/link-local/Makefile.toml new file mode 100644 index 0000000..1ab86e5 --- /dev/null +++ b/test/link-local/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in examples/client/rust" +''' diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs new file mode 100644 index 0000000..fa1be48 --- /dev/null +++ b/test/link-local/src/main.rs @@ -0,0 +1,45 @@ +//! Link Local test executable. +//! +//! This executable tests that the link local runner is able to send and receive packets from the +//! link local address and that the polling rate is roughly once a second. + +use clock_bound::daemon::io::{SourceIO, ntp::Event}; +use std::time; +use tokio::sync::mpsc; +use tokio::time::{Duration, timeout}; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + println!("Lets get a NTP packet!"); + let (link_local_sender, mut link_local_receiver) = mpsc::channel::(1); + + let mut start = time::Instant::now(); + + let mut sourceio = SourceIO::construct(); + sourceio.create_link_local(link_local_sender); + + let mut polling_rate = time::Duration::from_secs(0); + for i in 0..11 { + // On instances that aren't able to connect to link local the runner will run infinitely. + // To address this we timeout if an NTP event has not been received. + let ntpevent = timeout(Duration::from_secs(5), link_local_receiver.recv()) + .await + .unwrap(); + let now = time::Instant::now(); + let d = now - start; + println!( + "It looks like we got an ntp packet \n{ntpevent:#?}\n{:?} ms", + d.as_millis() + ); + + // Skip the first sample, the IO runner will poll immediately after it's created. + if i > 0 { + polling_rate += d; + } + + start = now; + } + polling_rate /= 10; + println!("Polling rate avg: {polling_rate:?}"); + assert!(polling_rate.abs_diff(time::Duration::from_secs(1)) < time::Duration::from_millis(100)); +} From 222df58cc166c6548c5276958b6112aa871c436a Mon Sep 17 00:00:00 2001 From: tphan25 Date: Wed, 8 Oct 2025 16:24:44 -0400 Subject: [PATCH 016/177] Implement Clock Adjustment in ClockState component (#17) * Implement Clock Adjustment in ClockState component The ClockState component will manage both adjusting the underlying kernel clocks (like any other time sync daemon), plus updating the ClockBound SHM segment. This commit implements that functionality on a unit struct for ClockState for now, which supports a method `adjust_clock`. It takes a `phase_correction` as input (to avoid the ambiguous "offset" phrasing that confuses us all), and a `skew`, representing the frequency error that the underlying oscillator exhibits. We pass the phase correction to the kernel PLL to correct, and simply write the `skew` value into the `freq` value used by the kernel timekeeping utilities, all in a single call. * Expose a CLI tool `clock_adjust` `clock_adjust` is a lightweight wrapper over our internal `ClockState` compnoent's `adjust_clock` function. It allows for the user to supply a given phase correction and skew, just like the internal parameters of the program, in order to pass these values to the kernel via `adjtimex` system call. It can be useful for testing of the clock adjustment component itself, validating that the phase correction and skew are applied correctly if the CLOCK_REALTIME is compared with a separate reference source (e.g. if I supply a `skew` of 0 but a `phase_correction` of 1000 nanoseconds, I should see that CLOCK_MONOTONIC_RAW and CLOCK_REALTIME eventually reach an offset of +1000 nanoseconds after the call) --------- Co-authored-by: Tom Phan --- Cargo.lock | 415 ++++++++++++++------ Cargo.toml | 1 + clock-bound/Cargo.toml | 14 +- clock-bound/src/daemon/clock_state.rs | 177 +++++++++ clock-bound/src/daemon/time.rs | 1 + clock-bound/src/daemon/time/inner.rs | 13 +- clock-bound/src/daemon/time/timex.rs | 239 +++++++++++ clock-bound/src/daemon/time/tsc.rs | 49 ++- test/clock-bound-adjust-clock/Cargo.toml | 25 ++ test/clock-bound-adjust-clock/Makefile.toml | 8 + test/clock-bound-adjust-clock/README.md | 46 +++ test/clock-bound-adjust-clock/src/main.rs | 45 +++ 12 files changed, 911 insertions(+), 122 deletions(-) create mode 100644 clock-bound/src/daemon/time/timex.rs create mode 100644 test/clock-bound-adjust-clock/Cargo.toml create mode 100644 test/clock-bound-adjust-clock/Makefile.toml create mode 100644 test/clock-bound-adjust-clock/README.md create mode 100644 test/clock-bound-adjust-clock/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index b3a8656..582ec3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] @@ -37,9 +37,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -52,9 +52,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -85,6 +85,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "approx" version = "0.5.1" @@ -96,15 +102,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.1.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -112,7 +118,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -123,9 +129,34 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.2" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "bon" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] [[package]] name = "bumpalo" @@ -147,9 +178,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.39" +version = "1.2.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" dependencies = [ "find-msvc-tools", "shlex", @@ -157,9 +188,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" @@ -171,7 +202,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -219,12 +250,14 @@ name = "clock-bound" version = "2.0.3" dependencies = [ "approx", + "bon", "byteorder", "bytes", "chrono", "errno", "hex-literal", "libc", + "mockall", "nix", "nom", "rstest", @@ -236,6 +269,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "clock-bound-adjust-clock" +version = "2.0.3" +dependencies = [ + "anyhow", + "clap", + "clock-bound", +] + [[package]] name = "clock-bound-ffi" version = "2.0.3" @@ -280,6 +322,47 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "equivalent" version = "1.0.2" @@ -288,12 +371,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -304,9 +387,21 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" [[package]] name = "futures-core" @@ -353,21 +448,21 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "wasi 0.13.3+wasi-0.2.2", - "windows-targets 0.52.6", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" @@ -417,6 +512,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.11.4" @@ -433,7 +534,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -482,9 +583,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "log" @@ -494,9 +595,9 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" @@ -527,6 +628,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "nix" version = "0.26.4" @@ -569,18 +696,18 @@ dependencies = [ [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" @@ -600,6 +727,42 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -620,18 +783,24 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "regex" -version = "1.11.2" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", @@ -641,9 +810,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", @@ -708,15 +877,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.2" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -739,9 +908,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -749,18 +918,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -836,18 +1005,23 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.18.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ - "cfg-if", "fastrand", "getrandom", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.17" @@ -1045,11 +1219,20 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.13.3+wasi-0.2.2" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -1113,22 +1296,22 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.62.1" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.0", + "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" -version = "0.60.1" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -1137,9 +1320,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.2" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -1148,32 +1331,26 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.0", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -1200,7 +1377,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -1221,19 +1407,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.1.3", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -1244,9 +1430,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -1256,9 +1442,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -1268,9 +1454,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -1280,9 +1466,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -1292,9 +1478,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -1304,9 +1490,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -1316,9 +1502,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -1328,9 +1514,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" @@ -1342,10 +1528,7 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.33.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" -dependencies = [ - "bitflags 2.4.2", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml index 3086232..a2a6b2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "test/clock-bound-vmclock-client-test", "test/link-local", "test/vmclock-updater", + "test/clock-bound-adjust-clock", ] resolver = "3" diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index dd18e71..875ee77 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -13,6 +13,7 @@ repository.workspace = true version.workspace = true [dependencies] +bon = { version = "3.8.0", optional = true } byteorder = "1" bytes = { version = "1", optional = true } chrono = { version = "0.4", optional = true } @@ -23,7 +24,7 @@ libc = { version = "0.2", default-features = false, features = [ nix = { version = "0.26", features = ["feature", "time"] } nom = { version = "8", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } -thiserror = "2" +thiserror = { version = "2.0", optional = true } tokio = { version = "1.47.1", features = [ "net", "macros", @@ -37,10 +38,19 @@ tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } [dev-dependencies] approx = "0.5" hex-literal = "0.4" +mockall = "0.13.1" rstest = "0.26" tempfile = "3.13" [features] client = [] -daemon = ["dep:serde", "dep:tokio", "dep:chrono", "dep:bytes", "dep:nom"] +daemon = [ + "dep:bon", + "dep:serde", + "dep:tokio", + "dep:chrono", + "dep:bytes", + "dep:nom", + "dep:thiserror", +] default = ["client", "daemon"] diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 43d965e..092acae 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -1 +1,178 @@ //! Adjust system clock and clockbound shared memory +use errno::Errno; +use libc::{TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT, ntp_adjtime}; +use thiserror::Error; +use tracing::debug; + +use crate::daemon::time::{Duration, timex::Timex, tsc::Skew}; + +/// Error type returned when dealing with underlying `adjtimex` or `ntp_adjtime` +/// results. +#[derive(Debug, Error)] +pub enum NtpAdjTimeError { + #[error("Failed to adjust the clock: {0}")] + Failure(Errno), + #[error("Unexpected bad state return value from ntp_adjtime: {0}")] + BadState(i32), + #[error("Invalid return value from ntp_adjtime: {0}")] + InvalidState(i32), +} + +/// Concrete struct implementing `ntp_adjtime` by delegating to the `libc` +/// implementation. Should be the only actual concrete implementation. +pub struct KAPIClockAdjuster; +impl NtpAdjTime for KAPIClockAdjuster { + fn ntp_adjtime(&self, tx: &mut Timex) -> i32 { + // # Safety + // `tx` should point to a valid struct because of validation guarantees of `Timex` + unsafe { ntp_adjtime(&raw mut **tx) } + } +} + +/// Noop Clock Adjuster, which doesn't actually adjust the clock parameters but just +/// returns `TIME_OK`. +pub struct NoopClockAdjuster; +impl NtpAdjTime for NoopClockAdjuster { + fn ntp_adjtime(&self, _tx: &mut Timex) -> i32 { + TIME_OK + } +} + +/// Lightweight trait around `ntp_adjtime` function (formerly `adjtimex`). +/// Useful for mocking, or potentially as an abstraction around modifying +/// other clocks' parameters in the future. +#[cfg_attr(test, mockall::automock)] +pub trait NtpAdjTime { + fn ntp_adjtime(&self, tx: &mut Timex) -> i32; +} + +pub struct ClockAdjuster { + ntp_adjtime: T, +} + +impl ClockAdjuster { + pub fn new(ntp_adjtime: T) -> Self { + Self { ntp_adjtime } + } + + /// Performs an adjustment of the clock, to apply the given phase correction + /// and skew values, in a single system call. + /// + /// # Errors + /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno + /// `NtpAdjTimeError::BadState` if some state other than `TIME_OK` is returned from `ntp_adjtime` + /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` + pub fn adjust_clock( + &self, + phase_correction: Duration, + skew: Skew, + ) -> Result<(), NtpAdjTimeError> { + let mut tx = Timex::clock_adjustment() + .phase_correction(phase_correction) + .skew(skew) + .call(); + + debug!("calling ntp_adjtime with {:?}", tx); + match self.ntp_adjtime.ntp_adjtime(&mut tx) { + TIME_OK => Ok(()), + cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { + Err(NtpAdjTimeError::BadState(cs)) + } + -1 => Err(NtpAdjTimeError::Failure(errno::errno())), + unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), + } + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::positives(Duration::from_nanos(500), Skew::from_ppm(1.0))] + #[case::negatives(Duration::from_nanos(-500), Skew::from_ppm(-1.0))] + #[case::zeroes(Duration::from_nanos(0), Skew::from_ppm(0.0))] + #[case::positive_offset_negative_skew(Duration::from_nanos(500), Skew::from_ppm(-1.0))] + #[case::negative_offset_positive_skew(Duration::from_nanos(-500), Skew::from_ppm(1.0))] + fn adjust_clock_happy_paths( + #[case] input_phase_correction: Duration, + #[case] input_skew: Skew, + ) { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_OK); + + // Call adjust_clock with test values + let result = clock_adjuster.adjust_clock(input_phase_correction, input_skew); + + assert!(result.is_ok()); + } + + #[test] + fn adjust_clock_failure() { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(-1); + + // Call adjust_clock with test values + let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), NtpAdjTimeError::Failure(_))); + } + + #[test] + fn adjust_clock_bad_state() { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_ERROR); + + // Call adjust_clock with test values + let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), NtpAdjTimeError::BadState(_))); + } + + #[test] + fn adjust_clock_unexpected_value() { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(12345); + + // Call adjust_clock with test values + let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + NtpAdjTimeError::InvalidState(_) + )); + } +} diff --git a/clock-bound/src/daemon/time.rs b/clock-bound/src/daemon/time.rs index 784c9bc..f3e23ee 100644 --- a/clock-bound/src/daemon/time.rs +++ b/clock-bound/src/daemon/time.rs @@ -5,6 +5,7 @@ pub mod inner; pub mod instant; +pub mod timex; pub mod tsc; pub use instant::{Duration, Instant}; diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 17ed288..41148cf 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -4,7 +4,7 @@ use std::{ marker::PhantomData, - ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Sub, SubAssign}, + ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}, }; /// Abstraction used to reuse basic time arithmetic, but allow for different types based on its usage @@ -245,6 +245,17 @@ impl MulAssign for Diff { } } +impl Neg for Diff { + type Output = Self; + + fn neg(self) -> Self::Output { + Self { + duration: -self.duration, + _marker: PhantomData, + } + } +} + #[cfg(test)] mod test { use super::*; diff --git a/clock-bound/src/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs new file mode 100644 index 0000000..7e68da4 --- /dev/null +++ b/clock-bound/src/daemon/time/timex.rs @@ -0,0 +1,239 @@ +use std::ops::{Deref, DerefMut}; + +use bon::bon; +use libc::{ + MOD_FREQUENCY, MOD_NANO, MOD_OFFSET, MOD_STATUS, MOD_TIMECONST, STA_FREQHOLD, STA_PLL, timeval, + timex, +}; +use tracing::warn; + +use crate::daemon::time::{Duration, tsc::Skew}; + +const MAX_PHASE_OFFSET: Duration = Duration::from_millis(500); +const MAX_SKEW: Skew = Skew::from_ppm(512.0); + +/// Newtype wrapping `libc::timex` to provide valid +/// constructor with less verbosity. Provides a constructor `new` +/// which zero-initializes all fields. +#[derive(Debug)] +pub struct Timex(timex); + +#[bon] +impl Timex { + /// Builds a `libc::timex` used for adjustment of the system clock, to apply the given phase correction + /// and skew values, in a single system call. + /// + /// The skew, or frequency error relative to a baseline at 0, is applied directly + /// to the `freq` in the timekeeping utilities, e.g. passing +1ppm will "speed up" the clock + /// by 1ppm. Thus, if the clock is slow by 1 microsecond every second, we should pass in +1ppm. + /// + /// The phase correction will be passed directly to the kernel PLL to correct the system + /// clock via a slew with exponential decaying effect (the proportion % corrected per second + /// is controlled by `tx.constant`). + /// + /// NOTE: + /// Slew correction of the offset by PLL is NOT applied to `freq`, but rather to the `tick_length` used in the kernel + /// which is used to calculate the `mult` factor used in timestamping (e.g. `ns ~= (clocksource * mult) >> shift`) + /// This allows our `freq` control via `skew` parameter to be independent of the phase correction, for better stability. + /// Caveat of this, is that if we are still slewing, we might our estimate of the phase offset between `CLOCK_REALTIME` and + /// ClockBound's internal clock could be prone to error/overestimates, so controlling how/when we estimate that offset is + /// needed. + #[builder] + #[allow( + clippy::cast_possible_truncation, + reason = "phase correction is clamped then converted so no truncation" + )] + pub fn clock_adjustment(mut phase_correction: Duration, mut skew: Skew) -> Self { + let mut tx = Self::default(); + if skew > MAX_SKEW || skew < -MAX_SKEW { + warn!("Skew of {skew} is outside of bounds +/-{MAX_SKEW}, clamping the value",); + skew = skew.clamp(-MAX_SKEW, MAX_SKEW); + } + tx.freq = skew.to_timex_freq(); + if phase_correction > MAX_PHASE_OFFSET || phase_correction < -MAX_PHASE_OFFSET { + warn!( + "Phase correction of {}ns is outside of bounds +/-{}ns, clamping the value", + phase_correction.as_nanos(), + MAX_PHASE_OFFSET.as_nanos() + ); + phase_correction = phase_correction.clamp(-MAX_PHASE_OFFSET, MAX_PHASE_OFFSET); + } + tx.offset = phase_correction.as_nanos() as i64; + + // PLL clock adjustment proportion is dependent on this time constant. + // The clock adjustment factor over the length of a second + // is calculated as `shift_right(offset, SHIFT_PLL + ntpdata->time_constant)`, where const `SHIFT_PLL` = 2 + // So, if we want to correct the clock quickly, we use a lower time constant. + // The value is clamped between 0 and 10. + // For now, we use 0, to aggressively correct the clock, which means we'd expect for + // offset to be corrected by `offset >> 2` every second (exponentially decaying) + tx.constant = 0; + // Set `modes` bits for all fields we modify, plus ADJ_NANO to use nanosecond units + // and ADJ_STATUS to set status bits below. + tx.modes = MOD_FREQUENCY | MOD_OFFSET | MOD_TIMECONST | MOD_NANO | MOD_STATUS; + // STA_FREQHOLD: Hold the frequency that we prescribe, if this is omitted the PLL would modify `freq` + // which we do not want since ClockBound's clock sync algorithm should determine the proper + // frequency setting. + // STA_PLL: Additionally, only rely on PLL to perform phase adjustments + tx.status = STA_FREQHOLD | STA_PLL; + + tx + } +} + +impl Default for Timex { + fn default() -> Self { + Self(timex { + modes: 0, + offset: 0, + freq: 0, + maxerror: 0, + esterror: 0, + status: 0, + constant: 0, + precision: 0, + tolerance: 0, + time: timeval { + tv_sec: 0, + tv_usec: 0, + }, + tick: 0, + ppsfreq: 0, + jitter: 0, + shift: 0, + stabil: 0, + jitcnt: 0, + calcnt: 0, + errcnt: 0, + stbcnt: 0, + tai: 0, + __unused1: 0, + __unused2: 0, + __unused3: 0, + __unused4: 0, + __unused5: 0, + __unused6: 0, + __unused7: 0, + __unused8: 0, + __unused9: 0, + __unused10: 0, + __unused11: 0, + }) + } +} + +impl Deref for Timex { + type Target = timex; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Timex { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::*; + + #[test] + fn test_timex_default() { + let timex = Timex::default(); + assert_eq!(timex.modes, 0); + assert_eq!(timex.offset, 0); + assert_eq!(timex.freq, 0); + assert_eq!(timex.maxerror, 0); + assert_eq!(timex.esterror, 0); + assert_eq!(timex.status, 0); + assert_eq!(timex.constant, 0); + assert_eq!(timex.precision, 0); + assert_eq!(timex.tolerance, 0); + assert_eq!(timex.time.tv_sec, 0); + assert_eq!(timex.time.tv_usec, 0); + assert_eq!(timex.tick, 0); + assert_eq!(timex.ppsfreq, 0); + assert_eq!(timex.jitter, 0); + assert_eq!(timex.shift, 0); + assert_eq!(timex.stabil, 0); + assert_eq!(timex.jitcnt, 0); + assert_eq!(timex.calcnt, 0); + assert_eq!(timex.errcnt, 0); + assert_eq!(timex.stbcnt, 0); + assert_eq!(timex.tai, 0); + assert_eq!(timex.__unused1, 0); + assert_eq!(timex.__unused2, 0); + assert_eq!(timex.__unused3, 0); + assert_eq!(timex.__unused4, 0); + assert_eq!(timex.__unused5, 0); + assert_eq!(timex.__unused6, 0); + assert_eq!(timex.__unused7, 0); + assert_eq!(timex.__unused8, 0); + assert_eq!(timex.__unused9, 0); + assert_eq!(timex.__unused10, 0); + assert_eq!(timex.__unused11, 0); + } + + #[rstest] + #[case::upper_bound( + Duration::from_millis(500), + Skew::from_ppm(512.0), + 500_000_000, + 33_554_432 + )] + #[case::above_upper_bound_gets_clamped( + Duration::from_millis(600), + Skew::from_ppm(1024.0), + 500_000_000, + 33_554_432 + )] + #[case::lower_bound( + Duration::from_millis(-500), + Skew::from_ppm(-512.0), + -500_000_000, + -33_554_432, + )] + #[case::below_lower_bound_gets_clamped( + Duration::from_millis(-600), + Skew::from_ppm(-1024.0), + -500_000_000, + -33_554_432, + )] + #[case::normal_value_positives( + Duration::from_millis(100), + Skew::from_ppm(128.0), + 100_000_000, + 8_388_608 + )] + #[case::normal_value_negatives( + Duration::from_millis(-100), + Skew::from_ppm(-128.0), + -100_000_000, + -8_388_608, + )] + #[case::zero(Duration::from_millis(0), Skew::from_ppm(0.0), 0, 0)] + fn test_timex_clock_adjustment( + #[case] phase_correction: Duration, + #[case] skew: Skew, + #[case] expected_offset: i64, + #[case] expected_freq: i64, + ) { + let tx = Timex::clock_adjustment() + .phase_correction(phase_correction) + .skew(skew) + .call(); + assert_eq!(tx.offset, expected_offset); + assert_eq!(tx.freq, expected_freq); + // assert modes, status and constant are set properly for our adjustment + assert_eq!( + tx.modes, + MOD_FREQUENCY | MOD_OFFSET | MOD_TIMECONST | MOD_NANO | MOD_STATUS + ); + assert_eq!(tx.status, STA_FREQHOLD | STA_PLL); + assert_eq!(tx.constant, 0); + } +} diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index c767522..6514c65 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -4,6 +4,7 @@ use super::Duration; use super::inner::{Diff, Time}; +use std::ops::Neg; use std::{ fmt::Display, ops::{Div, Mul, MulAssign}, @@ -11,6 +12,8 @@ use std::{ use serde::{Deserialize, Serialize}; +const FREQUENCY_TO_TIMEX_SCALE: f64 = (1 << 16) as f64; + /// Marker type to crate a raw timestamp with [`super::inner::Time`] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Tsc; @@ -158,19 +161,43 @@ impl Skew { const PERCENT: f64 = 0.01; /// Construct a new skew from parts per million (ppm) - pub fn from_ppm(skew: f64) -> Self { + pub const fn from_ppm(skew: f64) -> Self { Self(skew * Self::PPM) } /// Construct a new skew from percentage - pub fn from_percent(skew: f64) -> Self { + pub const fn from_percent(skew: f64) -> Self { Self(skew * Self::PERCENT) } /// Get the inner value - pub fn get(self) -> f64 { + pub const fn get(self) -> f64 { self.0 } + + /// In struct timex, freq, ppsfreq, and stabil are ppm (parts per + /// million) with a 16-bit fractional part, which means that a value + /// of 1 in one of those fields actually means 2^-16 ppm, and + /// 2^16=65536 is 1 ppm. This is the case for both input values (in + /// the case of freq) and output values. + /// ref: See NOTES in + pub fn to_timex_freq(self) -> i64 { + (FREQUENCY_TO_TIMEX_SCALE * self.0 / Self::PPM) as i64 + } + + /// `clamp` implementation delegating to inner `f64` for `Skew` values. + #[must_use] + pub fn clamp(self, min: Self, max: Self) -> Self { + Self(self.get().clamp(min.get(), max.get())) + } +} + +impl Neg for Skew { + type Output = Self; + + fn neg(self) -> Self::Output { + Self(-self.0) + } } impl Display for Skew { @@ -318,6 +345,22 @@ mod tests { assert_abs_diff_eq!(skew.get(), 0.05); } + #[test] + fn skew_to_timex_freq() { + let skew = Skew::from_ppm(1.0); + assert_eq!(skew.to_timex_freq(), 65536); + let skew = Skew::from_ppm(2.0); + assert_eq!(skew.to_timex_freq(), 131072); + let skew = Skew::from_ppm(-1.0); + assert_eq!(skew.to_timex_freq(), -65536); + let skew = Skew::from_ppm(0.0); + assert_eq!(skew.to_timex_freq(), 0); + let skew = Skew::from_ppm(f64::MAX); + assert_eq!(skew.to_timex_freq(), i64::MAX); + let skew = Skew::from_ppm(f64::MIN); + assert_eq!(skew.to_timex_freq(), i64::MIN); + } + #[test] fn skew_display() { let skew = Skew::from_ppm(100.0); diff --git a/test/clock-bound-adjust-clock/Cargo.toml b/test/clock-bound-adjust-clock/Cargo.toml new file mode 100644 index 0000000..492603b --- /dev/null +++ b/test/clock-bound-adjust-clock/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "clock-bound-adjust-clock" +description = "A Rust example program of the ClockBound daemon's clock adjustment routines." +license = "MIT OR Apache-2.0" +publish = false + +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +keywords.workspace = true +readme.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "clock-bound-adjust-clock" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4.5.31", features = ["derive"] } +clock-bound = { version = "2.0", path = "../../clock-bound", features = [ + "daemon", +] } diff --git a/test/clock-bound-adjust-clock/Makefile.toml b/test/clock-bound-adjust-clock/Makefile.toml new file mode 100644 index 0000000..bf2bd29 --- /dev/null +++ b/test/clock-bound-adjust-clock/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in test/clock-bound-adjust-clock" +''' diff --git a/test/clock-bound-adjust-clock/README.md b/test/clock-bound-adjust-clock/README.md new file mode 100644 index 0000000..4075116 --- /dev/null +++ b/test/clock-bound-adjust-clock/README.md @@ -0,0 +1,46 @@ +# Test program: clock-bound-adjust-clock + +This directory contains the source code for a test program `clock-bound-adjust-clock`. + +`clock-bound-adjust-clock` is a lightweight wrapper over our internal `ClockState` +component's `adjust_clock` function. It allows for the user to supply a +given phase correction and skew, just like the internal parameters of +the program, in order to pass these values to the kernel via `adjtimex` +system call. + +It can be useful for testing of the clock adjustment component itself, +validating that the phase correction and skew are applied correctly if +the CLOCK_REALTIME is compared with a separate reference source (e.g. if +I supply a `skew` of 0 but a `phase_correction` of 1000 nanoseconds, I +should see that CLOCK_MONOTONIC_RAW and CLOCK_REALTIME eventually reach +an offset of +1000 nanoseconds after the call) + +## Prerequisites + +The program must be run as a user with sufficient permissions to adjust the clock (generally `root`). + +Currently only Linux is supported. + +## Building with Cargo + +Run the following command to build the example program. + +``` +cargo build --release +``` + +## Running the example after a Cargo build + +Run the following commands to run the example program. + +``` +cd target/release/ +./clock-bound-adjust-clock +``` + +The output should look something like the following: + +``` +$ ./clock-bound-adjust-clock --skew-ppb 1000 --phase-correction-seconds 0.5 +Applied +65536 frequency setting (+1000 ppb) and +0.5 second phase correction to kernel to slew/correct. +``` diff --git a/test/clock-bound-adjust-clock/src/main.rs b/test/clock-bound-adjust-clock/src/main.rs new file mode 100644 index 0000000..87e7e45 --- /dev/null +++ b/test/clock-bound-adjust-clock/src/main.rs @@ -0,0 +1,45 @@ +//! A program to apply a clock phase correction and skew correction using +//! the timekeeping utilities internal to ClockBound. +use clap::Parser; +use clock_bound::daemon::{ + clock_state::{ClockAdjuster, KAPIClockAdjuster}, + time::{Duration, tsc::Skew}, +}; + +#[derive(Parser)] +#[command(version, about, long_about = None)] +struct Args { + /// Skew adjustment value, translated to a frequency + /// adjustment in the kernel. Given a positive value, a + /// positive `freq` value will be applied in the kernel timing + /// parameters, which would "speed up" `CLOCK_REALTIME`, and vice versa + /// for negative values. + #[arg(short, long, allow_negative_numbers = true)] + skew_ppb: i64, + + /// Phase correction to apply to the clock in seconds (as a floating point number). + #[arg(short, long, allow_negative_numbers = true)] + phase_correction_seconds: f64, +} + +#[allow( + clippy::cast_precision_loss, + reason = "skew ends up being clamped anyways so no loss of precision in i64 -> f64" +)] +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let phase_correction = Duration::from_seconds_f64(args.phase_correction_seconds); + let skew = Skew::from_ppm(args.skew_ppb as f64 * 1e-3); + + let clock_adjuster = ClockAdjuster::new(KAPIClockAdjuster); + clock_adjuster + .adjust_clock(phase_correction, skew) + .map_err(|e| anyhow::anyhow!(e))?; + println!( + "Applied {:+} frequency setting ({:+} ppb) and {:+} second phase correction to kernel to slew/correct.", + skew.to_timex_freq(), + skew.get() * 1e9, + phase_correction.as_seconds_f64(), + ); + Ok(()) +} From 99e2675c59600ed4df707820fb2c4e6cfb15cece Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Wed, 8 Oct 2025 17:27:54 -0400 Subject: [PATCH 017/177] Corrected typos, inconsistent wording and variable names. (#23) This commit corrects spelling typos, inconsistent language and variable naming patterns to align with expectations. There are no changes to logic in this commit. --- clock-bound/src/daemon/io.rs | 6 +++--- test/link-local/Makefile.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 34a3a48..cb74312 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -1,6 +1,6 @@ //! Perform IO on clock events //! -//! This module implements the logic needed to sample reference clocks, be it from NTP sources from +//! This module implements the logic needed to retrieve time sync sample measurements, be it from NTP sources from //! over the internet, the PHC via Linux's `ioctl` interface or some other source. #![allow(dead_code)] @@ -58,8 +58,8 @@ impl SourceIO { spawn(async move { let socket = UdpSocket::bind(ntp::BIND_ADDRESS).await.unwrap(); - let mut linklocal = LinkLocal::construct(socket, communication_channels); - linklocal.run().await; + let mut link_local = LinkLocal::construct(socket, communication_channels); + link_local.run().await; }); ctrl_sender }); diff --git a/test/link-local/Makefile.toml b/test/link-local/Makefile.toml index 1ab86e5..35a64f9 100644 --- a/test/link-local/Makefile.toml +++ b/test/link-local/Makefile.toml @@ -4,5 +4,5 @@ extend = "../../Makefile.toml" [tasks.custom-docs-flow] clear = true script = ''' -echo "skipping custom docs flow in examples/client/rust" +echo "skipping custom docs flow in test/link-local" ''' From 297da190bcbb8324cffd88568e2290e90bce4d35 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Thu, 9 Oct 2025 16:26:38 -0400 Subject: [PATCH 018/177] Add cfg directives to unblock building on Mac (#28) * Add cfg directives to unblock building on Mac MacOS/Darwin use a different timex struct from Linux uses https://android.googlesource.com/platform/external/rust/crates/libc/+/5991f7847a79b0666db84bfef1b54d28151855e2/src/unix/bsd/apple/mod.rs This commit adds conditional compilation flags for those respective fields, all over the place where needed for timex. * fix lint error --- clock-bound/src/daemon/time/timex.rs | 34 ++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/clock-bound/src/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs index 7e68da4..b305c99 100644 --- a/clock-bound/src/daemon/time/timex.rs +++ b/clock-bound/src/daemon/time/timex.rs @@ -1,9 +1,10 @@ use std::ops::{Deref, DerefMut}; use bon::bon; +#[cfg(not(target_os = "macos"))] +use libc::timeval; use libc::{ - MOD_FREQUENCY, MOD_NANO, MOD_OFFSET, MOD_STATUS, MOD_TIMECONST, STA_FREQHOLD, STA_PLL, timeval, - timex, + MOD_FREQUENCY, MOD_NANO, MOD_OFFSET, MOD_STATUS, MOD_TIMECONST, STA_FREQHOLD, STA_PLL, timex, }; use tracing::warn; @@ -93,10 +94,12 @@ impl Default for Timex { constant: 0, precision: 0, tolerance: 0, + #[cfg(not(target_os = "macos"))] time: timeval { tv_sec: 0, tv_usec: 0, }, + #[cfg(not(target_os = "macos"))] tick: 0, ppsfreq: 0, jitter: 0, @@ -106,17 +109,29 @@ impl Default for Timex { calcnt: 0, errcnt: 0, stbcnt: 0, + #[cfg(not(target_os = "macos"))] tai: 0, + #[cfg(not(target_os = "macos"))] __unused1: 0, + #[cfg(not(target_os = "macos"))] __unused2: 0, + #[cfg(not(target_os = "macos"))] __unused3: 0, + #[cfg(not(target_os = "macos"))] __unused4: 0, + #[cfg(not(target_os = "macos"))] __unused5: 0, + #[cfg(not(target_os = "macos"))] __unused6: 0, + #[cfg(not(target_os = "macos"))] __unused7: 0, + #[cfg(not(target_os = "macos"))] __unused8: 0, + #[cfg(not(target_os = "macos"))] __unused9: 0, + #[cfg(not(target_os = "macos"))] __unused10: 0, + #[cfg(not(target_os = "macos"))] __unused11: 0, }) } @@ -153,8 +168,11 @@ mod test { assert_eq!(timex.constant, 0); assert_eq!(timex.precision, 0); assert_eq!(timex.tolerance, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.time.tv_sec, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.time.tv_usec, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.tick, 0); assert_eq!(timex.ppsfreq, 0); assert_eq!(timex.jitter, 0); @@ -164,17 +182,29 @@ mod test { assert_eq!(timex.calcnt, 0); assert_eq!(timex.errcnt, 0); assert_eq!(timex.stbcnt, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.tai, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused1, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused2, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused3, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused4, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused5, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused6, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused7, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused8, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused9, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused10, 0); + #[cfg(not(target_os = "macos"))] assert_eq!(timex.__unused11, 0); } From 99476e242542841ff412883225a7e097bed96abf Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 10 Oct 2025 10:38:26 -0400 Subject: [PATCH 019/177] Add slack review approval webhook (#31) --- .../workflows/pr_review_approval_slack.yml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/pr_review_approval_slack.yml diff --git a/.github/workflows/pr_review_approval_slack.yml b/.github/workflows/pr_review_approval_slack.yml new file mode 100644 index 0000000..3c7d80f --- /dev/null +++ b/.github/workflows/pr_review_approval_slack.yml @@ -0,0 +1,21 @@ +name: pr_review_approval_slack +permissions: + contents: read + +on: + pull_request_review: + types: [submitted] + branches: [main] + +jobs: + pr_review_approval_slack: + if: github.event.review.state == 'approved' + name: Notify slack of pull request approvals + runs-on: ubuntu-latest + steps: + - name: Send approval info to slack + uses: slackapi/slack-github-action@v2.1.1 + with: + payload-delimiter: "_" + webhook: ${{ secrets.PR_REVIEW_APPROVAL_WEBHOOK_URL }} + webhook-type: webhook-trigger \ No newline at end of file From 36d8ef2343c64eb9add49ade161141f9850a13bf Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 13 Oct 2025 11:32:11 -0400 Subject: [PATCH 020/177] [clock_sync_algorithm] add generic ring buffer (#27) --- .../src/daemon/clock_sync_algorithm.rs | 3 + .../clock_sync_algorithm/ring_buffer.rs | 221 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 4106fcf..dd000fe 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -1 +1,4 @@ //! Feed forward clock sync algorithm + +mod ring_buffer; +pub use ring_buffer::RingBuffer; diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs new file mode 100644 index 0000000..ef28061 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs @@ -0,0 +1,221 @@ +//! Ring buffers used internally in the FF clock sync algorithm + +use std::{collections::VecDeque, num::NonZeroUsize}; + +/// A fixed-size ring buffer +/// +/// This is largely a wrapper around `VecDeque` while adding protections +/// from arbitrarily growing. +/// +/// Uses `head` and `tail` terminology. The head is where the most recent values +/// are added, and `tail` is where the oldest values are. Values are added to the head +/// via [`RingBuffer::push`]. +pub struct RingBuffer { + buffer: VecDeque, + capacity: usize, +} + +impl RingBuffer { + /// Creates a new ring buffer with the given capacity + pub fn new(capacity: NonZeroUsize) -> Self { + let capacity = capacity.get(); + Self { + buffer: VecDeque::with_capacity(capacity), + capacity, + } + } + + /// Pushes a new value into the buffer, overwriting the oldest value if the buffer is full + /// + /// Returns the value that was overwritten, if any + pub fn push(&mut self, value: T) -> Option { + let popped = if self.buffer.len() == self.capacity { + self.buffer.pop_front() + } else { + None + }; + self.buffer.push_back(value); + popped + } + + /// Pops a value from the tail + /// + /// Used to remove stale values. Returns `None` if the values are empty + pub fn pop(&mut self) -> Option { + self.buffer.pop_front() + } + + /// Returns the value at the given index, or `None` if the index is out of bounds + pub fn peek_at(&self, index: usize) -> Option<&T> { + self.buffer.get(index) + } + + /// Returns the number of values in the buffer + pub fn len(&self) -> usize { + self.buffer.len() + } + + /// Returns the capacity of the buffer + pub fn capacity(&self) -> usize { + self.capacity + } + + /// Returns `true` if the buffer is empty + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + /// Returns `true` if the buffer is full + pub fn is_full(&self) -> bool { + self.buffer.len() == self.capacity + } + + /// Clears the buffer + pub fn clear(&mut self) { + self.buffer.clear(); + } + + /// Get the latest value added to the ring buffer + pub fn head(&self) -> Option<&T> { + self.buffer.back() + } + + /// Get the oldest value in the ring buffer + pub fn tail(&self) -> Option<&T> { + self.buffer.front() + } + + /// iterate from the tail to the head (oldest to newest) + /// + /// Use `rev` on the iterator if you want to search the other way + pub fn iter(&self) -> impl Iterator { + self.buffer.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[test] + fn new_buffer() { + let buffer: RingBuffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + assert_eq!(buffer.capacity(), 3); + assert_eq!(buffer.len(), 0); + assert!(buffer.is_empty()); + assert!(!buffer.is_full()); + } + + #[test] + fn push_and_peek() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + + buffer.push(1); + buffer.push(2); + + assert_eq!(buffer.peek_at(0), Some(&1)); + assert_eq!(buffer.peek_at(1), Some(&2)); + assert_eq!(buffer.peek_at(2), None); + } + + #[test] + fn buffer_overflow() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(2).unwrap()); + + buffer.push(1); + buffer.push(2); + buffer.push(3); + + assert_eq!(buffer.peek_at(0), Some(&2)); + assert_eq!(buffer.peek_at(1), Some(&3)); + assert_eq!(buffer.peek_at(2), None); + } + + #[test] + fn wrap() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(2).unwrap()); + + buffer.push(1); + buffer.push(2); + buffer.push(3); + } + + #[test] + fn head_and_tail() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + + assert_eq!(buffer.head(), None); + assert_eq!(buffer.tail(), None); + + buffer.push(1); + assert_eq!(buffer.head(), Some(&1)); + assert_eq!(buffer.tail(), Some(&1)); + + buffer.push(2); + assert_eq!(buffer.head(), Some(&2)); + assert_eq!(buffer.tail(), Some(&1)); + } + + #[test] + fn clear() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + + buffer.push(1); + buffer.push(2); + buffer.clear(); + + assert!(buffer.is_empty()); + assert_eq!(buffer.len(), 0); + assert_eq!(buffer.head(), None); + assert_eq!(buffer.tail(), None); + } + + #[test] + fn iter() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + buffer.push(1); + buffer.push(2); + buffer.push(3); + + let values: Vec<&i32> = buffer.iter().collect(); + assert_eq!(values, vec![&1, &2, &3]); + + // Test iteration after overflow + buffer.push(4); + let values: Vec<&i32> = buffer.iter().collect(); + assert_eq!(values, vec![&2, &3, &4]); + } + + #[rstest] + #[case(1)] + #[case(5)] + #[case(10)] + fn various_capacities(#[case] capacity: usize) { + let mut buffer = RingBuffer::new(NonZeroUsize::new(capacity).unwrap()); + + for i in 0..capacity { + buffer.push(i); + assert_eq!(buffer.len(), i + 1); + } + + assert!(buffer.is_full()); + + // Push one more to test overflow + buffer.push(capacity); + assert_eq!(buffer.len(), capacity); + } + + #[test] + fn pop() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + buffer.push(1); + buffer.push(2); + buffer.push(3); + + assert_eq!(buffer.pop(), Some(1)); + assert_eq!(buffer.pop(), Some(2)); + assert_eq!(buffer.pop(), Some(3)); + assert_eq!(buffer.pop(), None); + } +} From 9a7ab95648e819618cf387e059d3d4921daae77e Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 13 Oct 2025 11:35:32 -0400 Subject: [PATCH 021/177] Moved NtpEvent over to daemon::event::Ntp (#29) * Moved NtpEvent over to daemon::event::Ntp * Revision: Refactor NTP Events into submodule --- clock-bound/src/daemon.rs | 2 + clock-bound/src/daemon/event.rs | 11 ++ clock-bound/src/daemon/event/ntp.rs | 186 ++++++++++++++++++++++++ clock-bound/src/daemon/io.rs | 7 +- clock-bound/src/daemon/io/ntp.rs | 45 ++---- clock-bound/src/daemon/io/ntp/packet.rs | 31 ++-- test/link-local/src/main.rs | 5 +- 7 files changed, 233 insertions(+), 54 deletions(-) create mode 100644 clock-bound/src/daemon/event.rs create mode 100644 clock-bound/src/daemon/event/ntp.rs diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 4b39415..5998aa8 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -7,3 +7,5 @@ pub mod clock_state; pub mod clock_sync_algorithm; pub mod time; + +pub mod event; diff --git a/clock-bound/src/daemon/event.rs b/clock-bound/src/daemon/event.rs new file mode 100644 index 0000000..a3025bf --- /dev/null +++ b/clock-bound/src/daemon/event.rs @@ -0,0 +1,11 @@ +//! Clock synchronization events +//! +//! These are the in-memory representations of NTP and PHC reads. +mod ntp; +pub use ntp::{Ntp, NtpData, Stratum, TryFromU8Error, ValidStratumLevel}; + +/// A time synchronization event handled by ClockBound +pub enum Event { + /// NTP Event + Ntp(Ntp), +} diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs new file mode 100644 index 0000000..0e33ddd --- /dev/null +++ b/clock-bound/src/daemon/event/ntp.rs @@ -0,0 +1,186 @@ +//! NTP Time synchronization events +use std::{ + error::Error, + fmt::{Display, Formatter}, +}; + +use crate::daemon::time::{Duration, Instant, TscCount}; + +/// Contains the NTP and time stamp counter samples to be used by synchronization algorithm. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ntp { + /// TSC value before sending event + pub tsc_pre: TscCount, + /// TSC value after sending event + pub tsc_post: TscCount, + /// NTP Packet data + pub ntp_data: NtpData, +} + +/// NTP-specific data +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NtpData { + /// NTP Server recv time + pub server_recv_time: Instant, + /// NTP Server send time + pub server_send_time: Instant, + + /// Root Delay of NTP packet + pub root_delay: Duration, + /// Root Dispersion of NTP packet + pub root_dispersion: Duration, + + /// NTP Stratum. Used in reporting, not used in ff-sync + pub stratum: Stratum, +} + +/// An NTP stratum +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Stratum { + /// Unspecified or invalid. + /// + /// Corresponds to a value of 0 in an NTP packet + Unspecified, + /// A server stratum level + /// + /// Corresponds to a value of 1-15 in an NTP packet + Level(ValidStratumLevel), + /// Clock is unsynchronized + /// + /// Corresponds to a value of 16 in an NTP packet + Unsynchronized, +} + +impl Stratum { + /// Stratum 1 + pub const ONE: Self = Self::Level(ValidStratumLevel(1)); + + /// Stratum 2 + pub const TWO: Self = Self::Level(ValidStratumLevel(2)); + + /// Construct a new stratum from a `u8` value + /// + /// Returns none if the value is > 16 + pub const fn new(value: u8) -> Option { + match value { + 0 => Some(Self::Unspecified), + 16 => Some(Self::Unsynchronized), + 1..=15 => match ValidStratumLevel::new(value) { + Some(level) => Some(Self::Level(level)), + None => None, + }, + _ => None, + } + } +} + +impl From for u8 { + fn from(stratum: Stratum) -> Self { + match stratum { + Stratum::Unspecified => 0, + Stratum::Level(level) => level.get(), + Stratum::Unsynchronized => 16, + } + } +} + +impl TryFrom for Stratum { + type Error = TryFromU8Error; + + fn try_from(value: u8) -> Result { + Stratum::new(value).ok_or(TryFromU8Error) + } +} + +/// The error type returned when a checked integral type conversion fails. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TryFromU8Error; + +impl Display for TryFromU8Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid value") + } +} + +impl Error for TryFromU8Error {} + +/// A valid stratum level, from 1 to 15 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ValidStratumLevel(u8); + +impl ValidStratumLevel { + /// Create a new valid stratum level + /// Returns None if the value is not between 1 and 15 + pub const fn new(value: u8) -> Option { + if value > 0 && value <= 15 { + Some(Self(value)) + } else { + None + } + } + + /// Get the inner value + pub fn get(self) -> u8 { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stratum_new_valid_values() { + assert_eq!(Stratum::new(0), Some(Stratum::Unspecified)); + assert_eq!(Stratum::new(16), Some(Stratum::Unsynchronized)); + + // Test valid levels 1-15 + for i in 1..=15 { + let stratum = Stratum::new(i); + assert!(stratum.is_some()); + let Some(Stratum::Level(level)) = stratum else { + panic!("Expected Stratum::Level for value {}", i); + }; + assert_eq!(level.get(), i); + } + } + + #[test] + fn stratum_new_invalid_values() { + assert_eq!(Stratum::new(17), None); + assert_eq!(Stratum::new(255), None); + } + + #[test] + fn stratum_conversion_to_u8() { + assert_eq!(u8::from(Stratum::Unspecified), 0); + assert_eq!(u8::from(Stratum::Unsynchronized), 16); + + // Test conversion of valid levels + for i in 1..=15 { + let level = ValidStratumLevel::new(i).unwrap(); + assert_eq!(u8::from(Stratum::Level(level)), i); + } + } + + #[test] + fn stratum_try_from_u8() { + // Test valid conversions + assert!(matches!(Stratum::try_from(0), Ok(Stratum::Unspecified))); + assert!(matches!(Stratum::try_from(16), Ok(Stratum::Unsynchronized))); + + // Test valid levels + for i in 1..=15 { + let result = Stratum::try_from(i); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), Stratum::Level(_))); + } + } + + #[test] + fn invalid_try_from_u8() { + // Test invalid conversions + assert!(matches!(Stratum::try_from(17), Err(TryFromU8Error))); + assert!(matches!(Stratum::try_from(255), Err(TryFromU8Error))); + } +} diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index cb74312..475a7fc 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -13,7 +13,8 @@ use tokio::task::spawn; use tracing::{debug, info}; pub mod ntp; -use ntp::{Event as NtpEvent, LinkLocal}; +use super::event; +use ntp::LinkLocal; mod tsc; @@ -43,7 +44,7 @@ impl SourceIO { /// # Panics /// - If not called within the `tokio` runtime. /// - If socket binding fails. - pub fn create_link_local(&mut self, event_sender: mpsc::Sender) { + pub fn create_link_local(&mut self, event_sender: mpsc::Sender) { info!("Creating link local source."); let entry = self.sources.entry(TimeSource::LinkLocal); @@ -85,7 +86,7 @@ struct ClockDisruptionChannels { #[derive(Debug)] pub struct CommunicationChannels { /// The channel which the IO task passes NTP events. - event_sender: mpsc::Sender, + event_sender: mpsc::Sender, /// The channel which the IO task receives control events. ctrl_receiver: mpsc::Receiver, diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index 0ff57b7..6f57f52 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -11,10 +11,11 @@ use tokio::{ }; use tracing::{debug, info}; +use super::CommunicationChannels; use super::tsc::read_timestamp_counter; -use super::{CommunicationChannels, NtpEvent}; -use crate::daemon::time::{ - Duration as ClockBoundDuration, Instant as ClockBoundInstant, tsc::TscCount, +use crate::daemon::{ + event::{self, NtpData}, + time::tsc::TscCount, }; pub mod packet; @@ -25,17 +26,6 @@ const LINK_LOCAL_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(169, 25 const INTERVAL_DURATION: Duration = Duration::from_secs(1); const LINK_LOCAL_TIMEOUT: Duration = Duration::from_millis(100); -/// Contains the NTP and time stamp counter samples to be used by synchronization algorithm. -#[derive(Clone, Debug)] -pub struct Event { - /// TSC value before requesting a NTP packet - pub tsc_pre: TscCount, - /// TSC value after receiving a NTP packet - pub tsc_post: TscCount, - /// NTP Packet data - pub ntp_data: NtpData, -} - #[derive(Debug, Error)] pub enum LinkLocalError { #[error("IO failure.")] @@ -43,29 +33,11 @@ pub enum LinkLocalError { #[error("Failed to parse NTP packet.")] PacketParsing(String), #[error("Send NtpEvent message failed.")] - SendEventMessage(#[from] mpsc::error::SendError), + SendEventMessage(#[from] mpsc::error::SendError), #[error("Operation timed out.")] Timeout(#[from] time::error::Elapsed), } -#[derive(Clone, Debug)] -pub struct NtpData { - /// NTP Server recv time - server_recv_time: ClockBoundInstant, - /// NTP Server send time - server_send_time: ClockBoundInstant, - - /// Root Delay of NTP packet - root_delay: ClockBoundDuration, - /// Root Dispersion of NTP packet - root_dispersion: ClockBoundDuration, - - /// NTP Stratum. Used in reporting, not used in ff-sync - stratum: Stratum, -} - -type Stratum = u8; - /// Contains the data needed to run the link local runner. #[derive(Debug)] pub struct LinkLocal { @@ -114,10 +86,13 @@ impl LinkLocal { let (_, ntp_packet) = Packet::parse_from_bytes(&self.ntp_buffer) .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; - let ntp_event: Event = Event { + let ntp_data = NtpData::try_from(ntp_packet) + .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; + + let ntp_event = event::Ntp { tsc_pre: TscCount::new(sent_timestamp.into()), tsc_post: TscCount::new(received_timestamp.into()), - ntp_data: NtpData::from(ntp_packet), + ntp_data, }; debug!(?recv_packet_result, "Received packet."); diff --git a/clock-bound/src/daemon/io/ntp/packet.rs b/clock-bound/src/daemon/io/ntp/packet.rs index 0a477b6..a076f65 100644 --- a/clock-bound/src/daemon/io/ntp/packet.rs +++ b/clock-bound/src/daemon/io/ntp/packet.rs @@ -24,8 +24,7 @@ pub use header::{LeapIndicator, Mode, Version}; pub use short::Short; pub use timestamp::Timestamp; -use super::NtpData; - +use crate::daemon::event::{NtpData, Stratum, TryFromU8Error}; use crate::daemon::time::{Duration as ClockBoundDuration, Instant as ClockBoundInstant}; /// An NTP packet, as defined in RFC 5905 @@ -196,22 +195,26 @@ impl Packet { } } -impl From for NtpData { - fn from(value: Packet) -> Self { +impl TryFrom for NtpData { + type Error = TryFromU8Error; + + fn try_from(value: Packet) -> Result { let root_delay = ClockBoundDuration::from(value.root_delay); let root_dispersion = ClockBoundDuration::from(value.root_dispersion); let server_recv_time = ClockBoundInstant::from(value.receive_timestamp); let server_send_time = ClockBoundInstant::from(value.transmit_timestamp); - NtpData { + let stratum = Stratum::try_from(value.stratum)?; + + Ok(NtpData { server_recv_time, server_send_time, root_delay, root_dispersion, - stratum: value.stratum, - } + stratum, + }) } } @@ -291,20 +294,20 @@ mod test { } #[test] - fn conversion_packet_to_ntpdata() { + fn conversion_packet_to_ntp_data() { let (_, packet) = Packet::parse_from_bytes(NTP_PACKET).unwrap(); - let ntpdata = NtpData::from(packet); + let ntp_data = NtpData::try_from(packet).unwrap(); - assert!((ntpdata.root_delay.as_seconds_f64() - 0.000031).abs() < 1.0 / 65536.0); - assert!((ntpdata.root_dispersion.as_seconds_f64() - 0.000015).abs() < 1.0 / 65536.0); + assert!((ntp_data.root_delay.as_seconds_f64() - 0.000031).abs() < 1.0 / 65536.0); + assert!((ntp_data.root_dispersion.as_seconds_f64() - 0.000015).abs() < 1.0 / 65536.0); assert_eq!( - ntpdata.server_recv_time, + ntp_data.server_recv_time, ClockBoundInstant::from_time(1738189694, 689357564) ); assert_eq!( - ntpdata.server_send_time, + ntp_data.server_send_time, ClockBoundInstant::from_time(1738189694, 689379474) ); - assert_eq!(ntpdata.stratum, 1); + assert_eq!(ntp_data.stratum, Stratum::ONE); } } diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index fa1be48..2d0af2a 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -3,7 +3,8 @@ //! This executable tests that the link local runner is able to send and receive packets from the //! link local address and that the polling rate is roughly once a second. -use clock_bound::daemon::io::{SourceIO, ntp::Event}; +use clock_bound::daemon::event; +use clock_bound::daemon::io::SourceIO; use std::time; use tokio::sync::mpsc; use tokio::time::{Duration, timeout}; @@ -11,7 +12,7 @@ use tokio::time::{Duration, timeout}; #[tokio::main(flavor = "current_thread")] async fn main() { println!("Lets get a NTP packet!"); - let (link_local_sender, mut link_local_receiver) = mpsc::channel::(1); + let (link_local_sender, mut link_local_receiver) = mpsc::channel::(1); let mut start = time::Instant::now(); From f40ef2ae2e0c6370b5627fe8bfdc7571f8cac69b Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 13 Oct 2025 16:36:38 -0400 Subject: [PATCH 022/177] Add async ring buffer (#32) * Add async ring buffer * Revision: add documentation on how Notify is used in the async ring buffer --- clock-bound/Makefile.toml | 1 + clock-bound/src/daemon.rs | 2 + clock-bound/src/daemon/async_ring_buffer.rs | 332 ++++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 clock-bound/Makefile.toml create mode 100644 clock-bound/src/daemon/async_ring_buffer.rs diff --git a/clock-bound/Makefile.toml b/clock-bound/Makefile.toml new file mode 100644 index 0000000..8ee118d --- /dev/null +++ b/clock-bound/Makefile.toml @@ -0,0 +1 @@ +extend = "../Makefile.toml" diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 5998aa8..93502fd 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -1,5 +1,7 @@ //! Clock Synchronization Daemon +pub mod async_ring_buffer; + pub mod io; pub mod clock_state; diff --git a/clock-bound/src/daemon/async_ring_buffer.rs b/clock-bound/src/daemon/async_ring_buffer.rs new file mode 100644 index 0000000..58cba54 --- /dev/null +++ b/clock-bound/src/daemon/async_ring_buffer.rs @@ -0,0 +1,332 @@ +//! Async ring buffer +//! +//! Used for communication from [IO tasks](super::io) to the [Clock Sync Algorithm](super::clock_sync_algorithm). +//! +//! The ring buffer is a single producer single consumer (SPSC) channel. +//! +//! # Receiver +//! Receiving works similar to other async channels, like +//! [tokio's `mpsc::Receiver`](https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Receiver.html). +//! The `Receiver` can `.await` for more data to be added to the queue, and also get's notified +//! when the sender has dropped. +//! +//! The receiver acts differently from `tokio` in that when the sender closes, the channel closes +//! immediately. It does NOT pull the rest of the messages before closing. This is to better match +//! the needs of ClockBound where we should clean up resources immediately when a source is not reachable. +//! +//! # Sender +//! The `Sender` acts slightly different from +//! [tokio's mpsc::Sender](https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html). +//! Because this is a ring-buffer, instead of waiting for capacity, it overwrites the inner value. This +//! means that writing to the sender is never `async`. +//! +//! # Implementation Notes +//! Implementation currently wraps the inner state with an `Arc>`. There are more optimized way to do this, +//! but those can come in with time. +//! +//! ## Notifications +//! This implementation uses [`tokio::sync::Notify`] as the async primitive to wake up the [`Receiver::recv`] futures after +//! making state affecting calls to the [`Sender`]. +//! +//! General structure is that an `Arc` shares a notify between the [`Sender`] and the [`Receiver`]. +//! When the [`Sender`] writes a new value or drops, it notifies the inner `Notify` for the receiver +//! to wake up and handle the update. +//! +//! # Panics +//! Code in this module will panic if called outside of a tokio runtime + +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; + +use tokio::sync::Notify; + +/// Create a new Sender-Receiver pair async ring buffer +/// +/// See [module level documentation](self) for more information. +/// +/// # Panics +/// Panics if size is 0 +pub fn create(size: usize) -> (Sender, Receiver) { + assert!(size > 0, "Ring buffer size must be greater than 0"); + let buffer = Arc::new(Mutex::new(Buffer::new(size))); + let notifier = Arc::new(Notify::new()); + let tx = Sender::new(Arc::clone(&buffer), Arc::clone(¬ifier)); + let rx = Receiver::new(buffer, notifier); + (tx, rx) +} + +/// The sender half of a ring buffer SPSC +/// +/// See the [module documentation](self) for more information. +#[derive(Debug)] +pub struct Sender { + inner: Arc>>, + notifier: Arc, +} + +impl Sender { + fn new(buffer: Arc>>, notifier: Arc) -> Self { + Self { + inner: buffer, + notifier, + } + } + + /// Send a value to the ring buffer, overwriting the oldest value if full + /// + /// # Errors + /// Returns [`BufferClosedError`] if the receiver dropped, and therefore nothing + /// is available to receive messages. + #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] + pub fn send(&self, value: T) -> Result<(), BufferClosedError> { + let mut guard = self.inner.lock().unwrap(); + if guard.receiver_dropped { + return Err(BufferClosedError); + } + guard.push(value); + drop(guard); + self.notifier.notify_one(); + Ok(()) + } + + /// Return true if the buffer is empty + #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] + pub fn is_empty(&self) -> bool { + self.inner.lock().unwrap().is_empty() + } + + /// Returns `true` if the receiver has dropped, and therefore the channel is closed + #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] + pub fn is_closed(&self) -> bool { + self.inner.lock().unwrap().receiver_dropped + } +} + +impl Drop for Sender { + fn drop(&mut self) { + { + // brace drops guard + let mut guard = self.inner.lock().unwrap(); + guard.sender_dropped = true; + } + self.notifier.notify_one(); + } +} + +/// The receiver half of a ring buffer SPSC +/// +/// See the [module documentation](self) for more information. +#[derive(Debug)] +pub struct Receiver { + inner: Arc>>, + notifiee: Arc, +} + +impl Receiver { + fn new(buffer: Arc>>, notifiee: Arc) -> Self { + Self { + inner: buffer, + notifiee, + } + } + + /// Receives the next value for this receiver + /// + /// # Errors + /// This method returns [`BufferClosedError`] if the paired [`Sender`] has dropped (destructed). + /// This can be used as a signal to clean up paired resources on this side of hte channel. + /// + /// # Cancel safety + /// This method is cancel safe. + /// If recv is used as the event in a `tokio::select!` statement and some other branch completes first, + /// it is guaranteed that no messages were received on this channel. + #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] + pub async fn recv(&self) -> Result { + // loop to check values, then await for notification, then get value again + loop { + { + // brace drops guard + let mut guard = self.inner.lock().unwrap(); + if guard.sender_dropped { + return Err(BufferClosedError); + } + if let Some(value) = guard.pop() { + return Ok(value); + } + } + self.notifiee.notified().await; + } + } + + /// Returns `true` if the sender has dropped, and therefore the channel is closed + #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] + pub fn is_closed(&self) -> bool { + self.inner.lock().unwrap().sender_dropped + } +} + +impl Drop for Receiver { + fn drop(&mut self) { + let mut guard = self.inner.lock().unwrap(); + guard.receiver_dropped = true; + } +} + +/// Shared data between the paired [`Tx`] and [`Rx`] +#[derive(Debug)] +struct Buffer { + data: VecDeque, + capacity: usize, + sender_dropped: bool, + receiver_dropped: bool, +} + +impl Buffer { + fn new(capacity: usize) -> Self { + Self { + data: VecDeque::with_capacity(capacity), + capacity, + sender_dropped: false, + receiver_dropped: false, + } + } + + /// Pushes a new value into the buffer, overwriting the oldest value if the buffer is full + /// + /// Returns the value that was overwritten, if any + fn push(&mut self, value: T) { + if self.data.len() == self.capacity { + self.data.pop_front(); + } + self.data.push_back(value); + } + + /// Pops a value from the tail + /// + /// Used to remove stale values. Returns `None` if the values are empty + fn pop(&mut self) -> Option { + self.data.pop_front() + } + + /// Returns `true` if the buffer is empty + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } +} + +#[derive(Debug, thiserror::Error)] +#[error("Buffer has been closed")] +pub struct BufferClosedError; + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn create_buffer() { + let (tx, _rx) = create::(5); + assert!(tx.is_empty()); + } + + #[tokio::test] + async fn basic_send_receive() { + let (tx, rx) = create(2); + tx.send(1).unwrap(); + tx.send(2).unwrap(); + + assert_eq!(rx.recv().await.unwrap(), 1); + assert_eq!(rx.recv().await.unwrap(), 2); + } + + #[tokio::test] + async fn buffer_overflow() { + let (tx, rx) = create(2); + tx.send(1).unwrap(); + tx.send(2).unwrap(); + tx.send(3).unwrap(); // This should overwrite the oldest value (1) + + assert_eq!(rx.recv().await.unwrap(), 2); // First value (1) was overwritten + assert_eq!(rx.recv().await.unwrap(), 3); + } + + #[tokio::test] + async fn sender_drop() { + let (tx, rx) = create::(2); + tx.send(1).unwrap(); + drop(tx); + + assert!(rx.is_closed()); + // Should receive BufferClosedError after sender is dropped + assert!(rx.recv().await.is_err()); + } + + #[tokio::test] + async fn receiver_drop() { + let (tx, rx) = create::(2); + drop(rx); + + assert!(tx.is_closed()); + // Should receive BufferClosedError when trying to send after receiver is dropped + assert!(tx.send(1).is_err()); + } + + #[tokio::test] + async fn empty_buffer() { + let (tx, _rx) = create::(2); + assert!(tx.is_empty()); + + tx.send(1).unwrap(); + assert!(!tx.is_empty()); + } + + #[tokio::test] + async fn concurrent_send_receive() { + let (tx, rx) = create(3); + let tx_notified = Arc::new(Notify::new()); + let rx_notified = Arc::clone(&tx_notified); + + let handle = tokio::spawn(async move { + for i in 0..5 { + tx.send(i).unwrap(); + tx_notified.notified().await; + } + }); + + let mut received = Vec::new(); + for _ in 0..5 { + if let Ok(value) = rx.recv().await { + received.push(value); + rx_notified.notify_one(); + } + } + + assert_eq!(received.len(), 5); + // Check that values are in sequence (though not necessarily starting from 0 + // due to potential overwrites) + for i in 1..received.len() { + assert_eq!(received[i], i); + } + handle.await.unwrap(); + } + + #[tokio::test] + async fn test_cancel_safety() { + let (tx, rx) = create(2); + tx.send(1).unwrap(); + + tokio::select! { + biased; + _ = async {} => { + // The empty branch should complete first + } + _ = rx.recv() => { + panic!("This branch should not complete first"); + } + } + + // The value should still be available + assert_eq!(rx.recv().await.unwrap(), 1); + } +} From c68f12a806b2127eac8d1626441f67461a655fa0 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 14 Oct 2025 11:42:19 -0400 Subject: [PATCH 023/177] Add ClockParameters (#30) --- clock-bound/src/daemon.rs | 2 ++ clock-bound/src/daemon/clock_parameters.rs | 40 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 clock-bound/src/daemon/clock_parameters.rs diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 93502fd..503bd76 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -4,6 +4,8 @@ pub mod async_ring_buffer; pub mod io; +pub mod clock_parameters; + pub mod clock_state; pub mod clock_sync_algorithm; diff --git a/clock-bound/src/daemon/clock_parameters.rs b/clock-bound/src/daemon/clock_parameters.rs new file mode 100644 index 0000000..73c72fd --- /dev/null +++ b/clock-bound/src/daemon/clock_parameters.rs @@ -0,0 +1,40 @@ +//! Calculated clock parameters +//! +//! The output of the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) + +use crate::daemon::time::{Duration, Instant, TscCount, tsc::Period}; + +/// Clock parameters +/// +/// These values are calculated by the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) +/// and used by the [`ClockState`](super::clock_state) +pub struct ClockParameters { + /// The tsc values that these account for + pub tsc_count: TscCount, + /// The time at `tsc_count` + pub time: Instant, + /// The clock error bound of `time` at `tsc_count` + pub clock_error_bound: Duration, + /// The period of the TSC clock at `tsc_count` + pub period: Period, + /// The max error of the `period` at `tsc_count` + pub period_max_error: Period, +} + +/// Information on the selected clock +/// +/// This struct is stored in the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) as the +/// final output product of the algorithm. +/// +/// Includes [`ClockParameters`] as well as information about the +/// selected clock +/// +/// TODO: Include the name of the source, as well. +pub struct SelectedClockInfo { + /// Calculated clock parameters from the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) + pub clock_parameters: ClockParameters, + /// Stratum of the selected clock + /// + /// None if reading from a non-NTP device + pub stratum: Option, // TODO: use the enum in another PR +} From c26bd8b0f1fb51e72941e8af7f2ba256222ab4fc Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 14 Oct 2025 13:00:59 -0400 Subject: [PATCH 024/177] Update contributors (#37) * Update contributors * Revision: Add nick matthews as contributor --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index a2a6b2e..d312ee9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,9 @@ authors = [ "Wenhao Piao ", "Thoth Gunter ", "Shamik Chakraborty ", + "Mohammed Kabir ", + "Myles Neloms ", + "Nick Matthews ", ] categories = [ "date-and-time" ] edition = "2024" From de00e873f71be3008e818c8afca839b486032634 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Wed, 15 Oct 2025 10:35:15 -0400 Subject: [PATCH 025/177] Updated link local testing scripts and added link local testing to ci scripts. (#25) * Added link local testing to ci scripts. This commit adds link local testing workflows to github action scripts and makes small changes to cleanup the testing scripts. * Added README file * Update .github/workflows/link_local.yml updated the build command to only build the link-local-test executable. Co-authored-by: Shamik Chakraborty --- .github/workflows/link_local.yml | 49 ++++++++++++++++++++++++ Cargo.lock | 14 +++++++ test/link-local/Cargo.toml | 1 + test/link-local/README.md | 66 ++++++++++++++++++++++++++++++++ test/link-local/src/main.rs | 5 +++ 5 files changed, 135 insertions(+) create mode 100644 .github/workflows/link_local.yml create mode 100644 test/link-local/README.md diff --git a/.github/workflows/link_local.yml b/.github/workflows/link_local.yml new file mode 100644 index 0000000..0d27193 --- /dev/null +++ b/.github/workflows/link_local.yml @@ -0,0 +1,49 @@ +name: Link Local + +permissions: + contents: read + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run cargo build + run: cargo build --bin link-local-test --release + + - name: Upload link-local-test artifact + uses: actions/upload-artifact@v4 + with: + name: link-local-test + path: target/release/link-local-test + + Link-Local_Tests: + name: Link-Local Tests + needs: build + runs-on: + - codebuild-StagingClockBound-${{ github.run_id }}-${{ github.run_attempt }} + buildspec-override:true + + steps: + - name: Download coverage artifact + uses: actions/download-artifact@v5 + with: + name: link-local-test + + - run: ls + - run: echo "Change permissions of artifact." + - run: chmod 755 link-local-test + - run: echo "Run link local test!" + - run: ./link-local-test diff --git a/Cargo.lock b/Cargo.lock index 582ec3c..a22f562 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -579,6 +579,7 @@ version = "2.0.3" dependencies = [ "clock-bound", "tokio", + "tracing-subscriber", ] [[package]] @@ -593,6 +594,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1168,12 +1178,16 @@ version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "serde", "serde_json", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", "tracing-serde", diff --git a/test/link-local/Cargo.toml b/test/link-local/Cargo.toml index 170c7c6..7221571 100644 --- a/test/link-local/Cargo.toml +++ b/test/link-local/Cargo.toml @@ -21,3 +21,4 @@ clock-bound = { version = "2.0", path = "../../clock-bound", features = [ "daemon", ] } tokio = { version = "1.47.1", features = ["macros", "rt"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/link-local/README.md b/test/link-local/README.md new file mode 100644 index 0000000..70c6aaf --- /dev/null +++ b/test/link-local/README.md @@ -0,0 +1,66 @@ +# Test program: link-local-test + +This directory contains the source code for a test program written to +validate the implementation of the link local NTP runner. The link local +NTP runner sends NTP packets to the AWS link local NTP address. + +## Prerequisites + +This program must be run on an AWS instance or a computer where `169.254.169.123:123` is mapped to an NTP server to complete successfully. + +## Building with Cargo + +Run the following command to build the test program. + +```sh +cargo build --release +``` + +## Running the program after a Cargo build + +Run the following commands to run the test program. + +```sh +cd target/release/ +./link-local-test +``` + + +The output should look something like the following: + +```sh +$ ./link-local-test +Lets get a NTP packet! +It looks like we got an ntp packet +Some( + Event { + tsc_pre: Time { + instant: 1755135610916260, + _marker: PhantomData, + }, + tsc_post: Time { + instant: 1755135611903996, + _marker: PhantomData, + }, + ntp_data: NtpData { + server_recv_time: Time { + instant: 1759936069727275701000000, + _marker: PhantomData, + }, + server_send_time: Time { + instant: 1759936069727290229000000, + _marker: PhantomData, + }, + root_delay: Diff { + duration: 30517578125, + _marker: PhantomData, + }, + root_dispersion: Diff { + duration: 15258789062, + _marker: PhantomData, + }, + stratum: 1, + }, + }, +) +``` diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index 2d0af2a..cedc68f 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -8,9 +8,14 @@ use clock_bound::daemon::io::SourceIO; use std::time; use tokio::sync::mpsc; use tokio::time::{Duration, timeout}; +use tracing_subscriber::EnvFilter; #[tokio::main(flavor = "current_thread")] async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + println!("Lets get a NTP packet!"); let (link_local_sender, mut link_local_receiver) = mpsc::channel::(1); From f7fdbfb34d4840f12255e37f19a9ff2894311dbd Mon Sep 17 00:00:00 2001 From: tphan25 Date: Wed, 15 Oct 2025 13:38:22 -0400 Subject: [PATCH 026/177] Add a trait `Clock` to support reads (#21) This commit adds a trait `Clock` into our `inner` time types module, which supports retrieving a `Time` where T is a timescale. This will allow us to mock up or implement wrappers around actual underlying clocks, with some samples here in `daemon/time/clocks.rs`. --- clock-bound/src/daemon/time.rs | 1 + clock-bound/src/daemon/time/clocks.rs | 87 +++++++++++++++++++++++++++ clock-bound/src/daemon/time/inner.rs | 5 ++ 3 files changed, 93 insertions(+) create mode 100644 clock-bound/src/daemon/time/clocks.rs diff --git a/clock-bound/src/daemon/time.rs b/clock-bound/src/daemon/time.rs index f3e23ee..0f42b7d 100644 --- a/clock-bound/src/daemon/time.rs +++ b/clock-bound/src/daemon/time.rs @@ -3,6 +3,7 @@ //! Other time libraries do not meet our needs, as we make heavy usage of time stamp counters (TSCs) //! for the bulk of our processing. These values are more low-level than those seen in `chrono` or other time types +pub mod clocks; pub mod inner; pub mod instant; pub mod timex; diff --git a/clock-bound/src/daemon/time/clocks.rs b/clock-bound/src/daemon/time/clocks.rs new file mode 100644 index 0000000..3af1d05 --- /dev/null +++ b/clock-bound/src/daemon/time/clocks.rs @@ -0,0 +1,87 @@ +//! Clocks used in ClockBound +use crate::daemon::time::{Instant, inner::Clock, instant::Utc}; +use nix::time::{ClockId, clock_gettime}; + +/// Wrapper around reads of the internal clock tracked by the ClockBound `ClockSyncAlgorithm`. +pub struct ClockBound; +impl Clock for ClockBound { + /// Get the current `Instant` by reading `ClockParameters` + fn get_time(&self) -> Instant { + todo!("implement this clock once we have some sane parameters"); + } +} + +/// Wrapper around `CLOCK_REALTIME` reads, which provides a UTC timestamp. +/// `CLOCK_REALTIME` is steered by userspace clock corrections of phase and frequency (e.g. that's our job), +/// and can jump forwards and backwards. +pub struct RealTime; +impl Clock for RealTime { + /// Get the current `Instant` by reading `CLOCK_REALTIME` + /// + /// # Panics + /// Panics if `clock_gettime` call fails (if pointer allocated for the call is invalid, or `ClockId` supplied is invalid or unavailable on the system) + #[allow( + clippy::cast_possible_truncation, + reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no truncation" + )] + #[allow( + clippy::cast_sign_loss, + reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no loss of sign" + )] + fn get_time(&self) -> Instant { + // Unwrap safety: `nix` crate supplies valid pointer and `ClockId` so the `clock_gettime` call should not be able to fail + let now = clock_gettime(ClockId::CLOCK_REALTIME).unwrap(); + Instant::from_time(now.tv_sec().into(), now.tv_nsec() as u32) + } +} + +/// Wrapper around `CLOCK_MONOTONIC_RAW` reads, which provides a UTC timestamp. +/// +/// `CLOCK_MONOTONIC_RAW` is controlled solely in the kernel, unaffected by phase and frequency corrections. +/// It simply has its rate of change aligned to that specified by the arch counter frequency hardware spec, so it +/// may be slow or fast depending on the state of the underlying oscillator. +pub struct MonotonicRaw; +impl Clock for MonotonicRaw { + /// Get the current `Instant` by reading `CLOCK_MONOTONIC_RAW` + /// + /// # Panics + /// Panics if `clock_gettime` call fails (if pointer allocated for the call is invalid, or `ClockId` supplied is invalid or unavailable on the system) + #[allow( + clippy::cast_possible_truncation, + reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no truncation" + )] + #[allow( + clippy::cast_sign_loss, + reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no loss of sign" + )] + fn get_time(&self) -> Instant { + // Unwrap safety: `nix` crate supplies valid pointer and `ClockId` so the `clock_gettime` call should not be able to fail + let now = clock_gettime(ClockId::CLOCK_MONOTONIC_RAW).unwrap(); + Instant::from_time(now.tv_sec().into(), now.tv_nsec() as u32) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Ensure that CLOCK_REALTIME and the std library utility for CLOCK_REALTIME are approximately the same. + #[test] + fn test_realtime() { + let realtime = RealTime; + let now = realtime.get_time(); + let now_std = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap(); + assert!(now_std.as_secs().abs_diff(now.as_seconds() as u64) < 1); + } + + // Naively show that CLOCK_MONOTONIC_RAW is monotonic (and hope that it's raw) + #[test] + fn test_monotonic_raw() { + let monotonic_raw = MonotonicRaw; + let now = monotonic_raw.get_time(); + let later = monotonic_raw.get_time(); + assert!(now < later); + } +} diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 41148cf..7421b8b 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -10,6 +10,11 @@ use std::{ /// Abstraction used to reuse basic time arithmetic, but allow for different types based on its usage pub trait Type: crate::private::Sealed {} +pub trait Clock { + /// Read the current clock time. + fn get_time(&self) -> Time; +} + /// Abstract type for Time while keeping arithmetic consistent /// /// This type is not usually used directly, but rather through the [`Instant`](super::Instant) and [`Tsc`](super::TscCount) types. From d30d1fc48f9c730006a8c56e385627fff4a58e92 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Wed, 15 Oct 2025 14:07:05 -0400 Subject: [PATCH 027/177] Improvements to `Timex` API + docs (#33) Implementing feedback in previous PR, to avoid allowing invalid `Timex` constructions at call-site by removing `Deref + DerefMut` impls. Instead, we no longer perform any mutations even within the class itself, as we know how to construct valid payloads and can construct the inner type via its `pub` fields anyways. We remove the `Default` implementation too. Also updates to add some doc comments and trait bound adjustments. --- clock-bound/src/daemon/clock_state.rs | 4 +- clock-bound/src/daemon/time/timex.rs | 126 ++++++-------------------- clock-bound/src/daemon/time/tsc.rs | 3 + 3 files changed, 34 insertions(+), 99 deletions(-) diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 092acae..0dc5936 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -25,7 +25,7 @@ impl NtpAdjTime for KAPIClockAdjuster { fn ntp_adjtime(&self, tx: &mut Timex) -> i32 { // # Safety // `tx` should point to a valid struct because of validation guarantees of `Timex` - unsafe { ntp_adjtime(&raw mut **tx) } + unsafe { ntp_adjtime(tx.expose()) } } } @@ -46,7 +46,7 @@ pub trait NtpAdjTime { fn ntp_adjtime(&self, tx: &mut Timex) -> i32; } -pub struct ClockAdjuster { +pub struct ClockAdjuster { ntp_adjtime: T, } diff --git a/clock-bound/src/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs index b305c99..2dc7090 100644 --- a/clock-bound/src/daemon/time/timex.rs +++ b/clock-bound/src/daemon/time/timex.rs @@ -1,5 +1,5 @@ -use std::ops::{Deref, DerefMut}; - +//! This module contains a newtype `Timex` wrapping an inner `libc::timex`, which allows construction +//! only of valid values for the sake of the types of `adjtimex`/`ntp_adjtime` calls we'll make in ClockBound. use bon::bon; #[cfg(not(target_os = "macos"))] use libc::timeval; @@ -14,13 +14,17 @@ const MAX_PHASE_OFFSET: Duration = Duration::from_millis(500); const MAX_SKEW: Skew = Skew::from_ppm(512.0); /// Newtype wrapping `libc::timex` to provide valid -/// constructor with less verbosity. Provides a constructor `new` -/// which zero-initializes all fields. +/// constructors for each type of `adjtimex`/`ntp_adjtime` operation. #[derive(Debug)] pub struct Timex(timex); #[bon] impl Timex { + /// Expose a mutable reference to the inner `libc::timex`. + pub fn expose(&mut self) -> *mut timex { + &raw mut self.0 + } + /// Builds a `libc::timex` used for adjustment of the system clock, to apply the given phase correction /// and skew values, in a single system call. /// @@ -45,12 +49,10 @@ impl Timex { reason = "phase correction is clamped then converted so no truncation" )] pub fn clock_adjustment(mut phase_correction: Duration, mut skew: Skew) -> Self { - let mut tx = Self::default(); if skew > MAX_SKEW || skew < -MAX_SKEW { warn!("Skew of {skew} is outside of bounds +/-{MAX_SKEW}, clamping the value",); skew = skew.clamp(-MAX_SKEW, MAX_SKEW); } - tx.freq = skew.to_timex_freq(); if phase_correction > MAX_PHASE_OFFSET || phase_correction < -MAX_PHASE_OFFSET { warn!( "Phase correction of {}ns is outside of bounds +/-{}ns, clamping the value", @@ -59,38 +61,26 @@ impl Timex { ); phase_correction = phase_correction.clamp(-MAX_PHASE_OFFSET, MAX_PHASE_OFFSET); } - tx.offset = phase_correction.as_nanos() as i64; - - // PLL clock adjustment proportion is dependent on this time constant. - // The clock adjustment factor over the length of a second - // is calculated as `shift_right(offset, SHIFT_PLL + ntpdata->time_constant)`, where const `SHIFT_PLL` = 2 - // So, if we want to correct the clock quickly, we use a lower time constant. - // The value is clamped between 0 and 10. - // For now, we use 0, to aggressively correct the clock, which means we'd expect for - // offset to be corrected by `offset >> 2` every second (exponentially decaying) - tx.constant = 0; - // Set `modes` bits for all fields we modify, plus ADJ_NANO to use nanosecond units - // and ADJ_STATUS to set status bits below. - tx.modes = MOD_FREQUENCY | MOD_OFFSET | MOD_TIMECONST | MOD_NANO | MOD_STATUS; - // STA_FREQHOLD: Hold the frequency that we prescribe, if this is omitted the PLL would modify `freq` - // which we do not want since ClockBound's clock sync algorithm should determine the proper - // frequency setting. - // STA_PLL: Additionally, only rely on PLL to perform phase adjustments - tx.status = STA_FREQHOLD | STA_PLL; - - tx - } -} - -impl Default for Timex { - fn default() -> Self { Self(timex { - modes: 0, - offset: 0, - freq: 0, + // Set `modes` bits for all fields we modify, plus ADJ_NANO to use nanosecond units + // and ADJ_STATUS to set status bits below. + modes: MOD_FREQUENCY | MOD_OFFSET | MOD_TIMECONST | MOD_NANO | MOD_STATUS, + offset: phase_correction.as_nanos() as i64, + freq: skew.to_timex_freq(), maxerror: 0, esterror: 0, - status: 0, + // STA_FREQHOLD: Hold the frequency that we prescribe, if this is omitted the PLL would modify `freq` + // which we do not want since ClockBound's clock sync algorithm should determine the proper + // frequency setting. + // STA_PLL: Additionally, only rely on PLL to perform phase adjustments + status: STA_FREQHOLD | STA_PLL, + // PLL clock adjustment proportion is dependent on this time constant. + // The clock adjustment factor over the length of a second + // is calculated as `shift_right(offset, SHIFT_PLL + ntpdata->time_constant)`, where const `SHIFT_PLL` = 2 + // So, if we want to correct the clock quickly, we use a lower time constant. + // The value is clamped between 0 and 10. + // For now, we use 0, to aggressively correct the clock, which means we'd expect for + // offset to be corrected by `offset >> 2` every second (exponentially decaying) constant: 0, precision: 0, tolerance: 0, @@ -137,77 +127,18 @@ impl Default for Timex { } } -impl Deref for Timex { - type Target = timex; - - fn deref(&self) -> &Self::Target { +impl AsRef for Timex { + fn as_ref(&self) -> &timex { &self.0 } } -impl DerefMut for Timex { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} #[cfg(test)] mod test { use rstest::rstest; use super::*; - #[test] - fn test_timex_default() { - let timex = Timex::default(); - assert_eq!(timex.modes, 0); - assert_eq!(timex.offset, 0); - assert_eq!(timex.freq, 0); - assert_eq!(timex.maxerror, 0); - assert_eq!(timex.esterror, 0); - assert_eq!(timex.status, 0); - assert_eq!(timex.constant, 0); - assert_eq!(timex.precision, 0); - assert_eq!(timex.tolerance, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.time.tv_sec, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.time.tv_usec, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.tick, 0); - assert_eq!(timex.ppsfreq, 0); - assert_eq!(timex.jitter, 0); - assert_eq!(timex.shift, 0); - assert_eq!(timex.stabil, 0); - assert_eq!(timex.jitcnt, 0); - assert_eq!(timex.calcnt, 0); - assert_eq!(timex.errcnt, 0); - assert_eq!(timex.stbcnt, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.tai, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused1, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused2, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused3, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused4, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused5, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused6, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused7, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused8, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused9, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused10, 0); - #[cfg(not(target_os = "macos"))] - assert_eq!(timex.__unused11, 0); - } - #[rstest] #[case::upper_bound( Duration::from_millis(500), @@ -252,10 +183,11 @@ mod test { #[case] expected_offset: i64, #[case] expected_freq: i64, ) { - let tx = Timex::clock_adjustment() + let binding = Timex::clock_adjustment() .phase_correction(phase_correction) .skew(skew) .call(); + let tx = binding.as_ref(); assert_eq!(tx.offset, expected_offset); assert_eq!(tx.freq, expected_freq); // assert modes, status and constant are set properly for our adjustment diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 6514c65..30a0b73 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -186,6 +186,9 @@ impl Skew { } /// `clamp` implementation delegating to inner `f64` for `Skew` values. + /// + /// # Panics + /// Panics if `min > max`, `min` is NaN, or `max` is NaN. #[must_use] pub fn clamp(self, min: Self, max: Self) -> Self { Self(self.get().clamp(min.get(), max.get())) From d6fa68c4d273f7054f215c2404be5b018d123fe5 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Wed, 15 Oct 2025 14:22:04 -0400 Subject: [PATCH 028/177] Implement clock stepping in ClockState component (#20) * Implement Clock Adjustment stepping in ClockState The ClockState component should be able to apply a step offset to correct the clock quickly, since the standard utilities (ADJ_OFFSET and PLL usage) don't allow for larger offsets to be quickly corrected. This can help if for example a device has a broken RTC and needs to be corrected from 1970 to the present, or a significantly large clock offset has otherwise been accumulated from UTC. * Expose a CLI tool `step-clock` `step-clock` is a lightweight wrapper over our internal `ClockState` component's `step_clock` function. It allows for the user to supply a given phase correction, which is applied to `CLOCK_REALTIME` via a step. It can be useful for testing of the clock stepping functionality itself - e.g. if I supply a `--phase-correction-seconds` of `10.0`, I should expect the clock to be stepped 10 seconds forward in time. Additionally, renaming `clock-bound-adjust-clock` to just `adjust-clock` to reduce verbosity, and updating the README to account for that. --- Cargo.lock | 1 + clock-bound/src/daemon/clock_state.rs | 46 ++++++++++ clock-bound/src/daemon/time/instant.rs | 50 ++++++++++ clock-bound/src/daemon/time/timex.rs | 91 ++++++++++++++++++- test/clock-bound-adjust-clock/Cargo.toml | 9 +- test/clock-bound-adjust-clock/README.md | 41 +++++++-- .../src/{main.rs => adjust_clock.rs} | 0 .../src/step_clock.rs | 39 ++++++++ 8 files changed, 265 insertions(+), 12 deletions(-) rename test/clock-bound-adjust-clock/src/{main.rs => adjust_clock.rs} (100%) create mode 100644 test/clock-bound-adjust-clock/src/step_clock.rs diff --git a/Cargo.lock b/Cargo.lock index a22f562..78bfb2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,7 @@ name = "clock-bound-adjust-clock" version = "2.0.3" dependencies = [ "anyhow", + "chrono", "clap", "clock-bound", ] diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 0dc5936..c2a423c 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -82,6 +82,31 @@ impl ClockAdjuster { unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), } } + + /// Applies an instantaneous step of `CLOCK_REALTIME` based on the passed `phase_correction` value. + /// + /// # Errors + /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno + /// `NtpAdjTimeError::BadState` if some state other than `TIME_ERROR` is returned from `ntp_adjtime` + /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` + pub fn step_clock(&self, phase_correction: Duration) -> Result<(), NtpAdjTimeError> { + let mut tx = Timex::clock_step() + .phase_correction(phase_correction) + .call(); + + debug!("calling ntp_adjtime with {:?}", tx); + // NOTE: we actually expect TIME_ERROR if the clock adjustment succeeds, since + // that indicates the clock is now "unsynchronized" (expected after we step the clock + // discontinuously) + match self.ntp_adjtime.ntp_adjtime(&mut tx) { + TIME_ERROR => Ok(()), + cs @ (TIME_OK | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { + Err(NtpAdjTimeError::BadState(cs)) + } + -1 => Err(NtpAdjTimeError::Failure(errno::errno())), + unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), + } + } } #[cfg(test)] @@ -175,4 +200,25 @@ mod test { NtpAdjTimeError::InvalidState(_) )); } + + #[rstest] + #[case::positive(Duration::from_millis(100))] + #[case::negative(-Duration::from_millis(100))] + #[case::zero(Duration::from_millis(0))] + fn step_clock_happy_paths(#[case] input_phase_correction: Duration) { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_ERROR); + + // Call step_clock with test values + let result = clock_adjuster.step_clock(input_phase_correction); + + assert!(result.is_ok()); + } } diff --git a/clock-bound/src/daemon/time/instant.rs b/clock-bound/src/daemon/time/instant.rs index e034a5f..3c6f2f1 100644 --- a/clock-bound/src/daemon/time/instant.rs +++ b/clock-bound/src/daemon/time/instant.rs @@ -1,5 +1,7 @@ //! A simplified time type for `ClockBound` +use libc::timeval; + use super::inner::{Diff, Time}; /// Marker type to signify a time as a timestamp @@ -273,6 +275,31 @@ impl Duration { pub const fn as_days(self) -> i128 { self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) } + + /// Returns the `Duration` converted to a `timeval`. + /// It's used by `adjtimex`/`ntp_adjtime` in the returned timestamp or in `ADJ_SETOFFSET`. + /// + /// A `timeval` is equivalent to `timespec` in usage, but with normally less resolution.. + /// However, a caller may supply `ADJ_NANO` to tell the kernel we want to supply a nanosecond value inside + /// `tv_usec`, and so we do that to be consistent with our `clock_adjust` offset resolution). + /// Thus, this does not return a correct `timeval` by the specifications of the fields themselves, but instead + /// a `timeval` in the context of an `adjtimex` call with `ADJ_NANO` set. + #[allow( + clippy::cast_possible_truncation, + reason = "tv_sec truncation should be acceptable to i64::MAX for any of our use cases, and tv_usec should be between 0 and 1e9-1 so no truncation" + )] + pub fn to_timeval_nanos(self) -> timeval { + let mut tv = timeval { + tv_sec: self.as_seconds() as i64, + tv_usec: (self.as_nanos() % NANOS_PER_SECOND) as i64, + }; + // Normalize the timeval, as `tv_usec` cannot be negative (so we push the "negative" place into `tv_sec`) + if tv.tv_usec < 0 { + tv.tv_sec -= 1; + tv.tv_usec += 1_000_000_000_i64; + } + tv + } } pub(crate) const FEMTOS_PER_SEC: i128 = 1_000_000_000_000_000; @@ -280,12 +307,15 @@ pub(crate) const FEMTOS_PER_MILLI: i128 = 1_000_000_000_000; pub(crate) const FEMTOS_PER_MICRO: i128 = 1_000_000_000; pub(crate) const FEMTOS_PER_NANO: i128 = 1_000_000; pub(crate) const FEMTOS_PER_PICO: i128 = 1_000; +pub(crate) const NANOS_PER_SECOND: i128 = 1_000_000_000; pub(crate) const SECS_PER_MINUTE: i128 = 60; pub(crate) const MINS_PER_HOUR: i128 = 60; pub(crate) const HOURS_PER_DAY: i128 = 24; #[cfg(test)] mod test { + use rstest::rstest; + use super::*; #[test] @@ -429,4 +459,24 @@ mod test { let duration = Duration::from_nanos_f64(1.5); assert_eq!(duration.as_nanos(), 1); } + + #[rstest] + #[case::positive(Duration::from_nanos(1_400_000_000), 1, 400_000_000)] + #[case::negative(Duration::from_nanos(-1_600_000_000), -2, 400_000_000)] + // Take time in seconds since unix epoch * 100 + #[case::bignum( + Duration::from_nanos(1_760_120_080_500_000_000), + 1_760_120_080, + 500_000_000 + )] + #[case::negative_bignum(Duration::from_nanos(-1_760_120_080_500_000_000), -1_760_120_081, 500_000_000)] + fn duration_to_timeval_micros( + #[case] duration: Duration, + #[case] tv_sec: i64, + #[case] tv_usec_nanos: i64, + ) { + let tv = duration.to_timeval_nanos(); + assert_eq!(tv.tv_sec, tv_sec); + assert_eq!(tv.tv_usec, tv_usec_nanos); + } } diff --git a/clock-bound/src/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs index 2dc7090..2720c0f 100644 --- a/clock-bound/src/daemon/time/timex.rs +++ b/clock-bound/src/daemon/time/timex.rs @@ -4,7 +4,8 @@ use bon::bon; #[cfg(not(target_os = "macos"))] use libc::timeval; use libc::{ - MOD_FREQUENCY, MOD_NANO, MOD_OFFSET, MOD_STATUS, MOD_TIMECONST, STA_FREQHOLD, STA_PLL, timex, + ADJ_SETOFFSET, MOD_FREQUENCY, MOD_NANO, MOD_OFFSET, MOD_STATUS, MOD_TIMECONST, STA_FREQHOLD, + STA_PLL, timex, }; use tracing::warn; @@ -125,6 +126,71 @@ impl Timex { __unused11: 0, }) } + + /// Construct a `libc::timex` used for stepping the clock by some phase correction, + /// with a full step (can go forwards or backwards). + /// This is used to set the system clock to the current time, which is useful for + /// initializing the clock after a reboot. + /// + /// `ADJ_SETOFFSET` is only supported on Linux `adjtimex`, in the future we should have some implementation + /// for other platforms e.g. FreeBSD + #[cfg(target_os = "linux")] + #[allow( + clippy::field_reassign_with_default, + reason = "false positive, can't use default constructor for inner type fields mutated" + )] + #[builder] + pub fn clock_step(phase_correction: Duration) -> Self { + Self(timex { + // Set `modes` bits for `ADJ_SETOFFSET` to step the clock, and MOD_NANO to use nanosecond units + modes: ADJ_SETOFFSET | MOD_NANO, + offset: 0, + freq: 0, + maxerror: 0, + esterror: 0, + status: 0, + constant: 0, + precision: 0, + tolerance: 0, + // `ADJ_SETOFFSET` uses `time` rather than offset field to indicate how much to step the clock + #[cfg(not(target_os = "macos"))] + time: phase_correction.to_timeval_nanos(), + #[cfg(not(target_os = "macos"))] + tick: 0, + ppsfreq: 0, + jitter: 0, + shift: 0, + stabil: 0, + jitcnt: 0, + calcnt: 0, + errcnt: 0, + stbcnt: 0, + #[cfg(not(target_os = "macos"))] + tai: 0, + #[cfg(not(target_os = "macos"))] + __unused1: 0, + #[cfg(not(target_os = "macos"))] + __unused2: 0, + #[cfg(not(target_os = "macos"))] + __unused3: 0, + #[cfg(not(target_os = "macos"))] + __unused4: 0, + #[cfg(not(target_os = "macos"))] + __unused5: 0, + #[cfg(not(target_os = "macos"))] + __unused6: 0, + #[cfg(not(target_os = "macos"))] + __unused7: 0, + #[cfg(not(target_os = "macos"))] + __unused8: 0, + #[cfg(not(target_os = "macos"))] + __unused9: 0, + #[cfg(not(target_os = "macos"))] + __unused10: 0, + #[cfg(not(target_os = "macos"))] + __unused11: 0, + }) + } } impl AsRef for Timex { @@ -198,4 +264,27 @@ mod test { assert_eq!(tx.status, STA_FREQHOLD | STA_PLL); assert_eq!(tx.constant, 0); } + + #[rstest] + #[case::positive( + Duration::from_millis(100), + timeval {tv_sec: 0, tv_usec: 100_000_000}, + )] + #[case::negative( + -Duration::from_millis(100), + timeval {tv_sec: -1, tv_usec: 900_000_000}, + )] + #[case::zero( + Duration::from_millis(0), + timeval {tv_sec: 0, tv_usec: 0}, + )] + fn test_timex_clock_step(#[case] phase_correction: Duration, #[case] expected_time: timeval) { + let binding = Timex::clock_step() + .phase_correction(phase_correction) + .call(); + let tx = binding.as_ref(); + assert_eq!(tx.time, expected_time); + // assert modes is set properly for our adjustment + assert_eq!(tx.modes, ADJ_SETOFFSET | MOD_NANO); + } } diff --git a/test/clock-bound-adjust-clock/Cargo.toml b/test/clock-bound-adjust-clock/Cargo.toml index 492603b..ced5a9f 100644 --- a/test/clock-bound-adjust-clock/Cargo.toml +++ b/test/clock-bound-adjust-clock/Cargo.toml @@ -14,11 +14,16 @@ repository.workspace = true version.workspace = true [[bin]] -name = "clock-bound-adjust-clock" -path = "src/main.rs" +name = "adjust-clock" +path = "src/adjust_clock.rs" + +[[bin]] +name = "step-clock" +path = "src/step_clock.rs" [dependencies] anyhow = "1" +chrono = "0.4" clap = { version = "4.5.31", features = ["derive"] } clock-bound = { version = "2.0", path = "../../clock-bound", features = [ "daemon", diff --git a/test/clock-bound-adjust-clock/README.md b/test/clock-bound-adjust-clock/README.md index 4075116..176a8a9 100644 --- a/test/clock-bound-adjust-clock/README.md +++ b/test/clock-bound-adjust-clock/README.md @@ -2,7 +2,8 @@ This directory contains the source code for a test program `clock-bound-adjust-clock`. -`clock-bound-adjust-clock` is a lightweight wrapper over our internal `ClockState` +### `adjust-clock` +`adjust-clock` is a lightweight wrapper over our internal `ClockState` component's `adjust_clock` function. It allows for the user to supply a given phase correction and skew, just like the internal parameters of the program, in order to pass these values to the kernel via `adjtimex` @@ -15,6 +16,16 @@ I supply a `skew` of 0 but a `phase_correction` of 1000 nanoseconds, I should see that CLOCK_MONOTONIC_RAW and CLOCK_REALTIME eventually reach an offset of +1000 nanoseconds after the call) +### `step-clock` +`step-clock` is a lightweight wrapper over our internal `ClockState` +component's `step_clock` function. It allows for the user to supply a +given phase correction, which is applied to `CLOCK_REALTIME` via a step. +**DO NOT USE THIS IF IN AN ENVIRONMENT WHICH HAS OTHER SOFTWARE WHICH DO NOT EXPECT CLOCK_REALTIME TO BE STEPPED, UNLESS YOU KNOW WHAT YOU ARE DOING!** + +It can be useful for testing of the clock stepping functionality itself - +e.g. if I supply a `--phase-correction-seconds` of `10.0`, I should expect the clock to be +stepped 10 seconds forward in time. + ## Prerequisites The program must be run as a user with sufficient permissions to adjust the clock (generally `root`). @@ -23,24 +34,36 @@ Currently only Linux is supported. ## Building with Cargo -Run the following command to build the example program. +Run the following command to build the example programs. ``` cargo build --release ``` -## Running the example after a Cargo build +## Running `adjust-clock` -Run the following commands to run the example program. +The build artifact should show up at +``` +./target/release/adjust-clock +``` +You can run the command like below, and the output should look similar: ``` -cd target/release/ -./clock-bound-adjust-clock +$ ./target/release/adjust-clock --skew-ppb 1000 --phase-correction-seconds 0.5 +Applied +65536 frequency setting (+1000 ppb) and +0.5 second phase correction to kernel to slew/correct. ``` -The output should look something like the following: +## Running `step-clock` +The build artifact should show up at ``` -$ ./clock-bound-adjust-clock --skew-ppb 1000 --phase-correction-seconds 0.5 -Applied +65536 frequency setting (+1000 ppb) and +0.5 second phase correction to kernel to slew/correct. +./target/release/step-clock +``` + +You can run the command like below, and the output should look similar: ``` +$ ./target/release/step-clock --phase-correction-seconds 5.0 +Initial time is 2025-10-06T20:38:17.704638277Z +Applied +5 second phase correction to kernel to step CLOCK_REALTIME. +Final time is 2025-10-06T20:38:22.704708241Z +``` \ No newline at end of file diff --git a/test/clock-bound-adjust-clock/src/main.rs b/test/clock-bound-adjust-clock/src/adjust_clock.rs similarity index 100% rename from test/clock-bound-adjust-clock/src/main.rs rename to test/clock-bound-adjust-clock/src/adjust_clock.rs diff --git a/test/clock-bound-adjust-clock/src/step_clock.rs b/test/clock-bound-adjust-clock/src/step_clock.rs new file mode 100644 index 0000000..0c68d30 --- /dev/null +++ b/test/clock-bound-adjust-clock/src/step_clock.rs @@ -0,0 +1,39 @@ +//! A program to apply a clock phase correction and skew correction using +//! the timekeeping utilities internal to ClockBound. +use chrono::{DateTime, Utc}; +use clap::Parser; +use clock_bound::daemon::{ + clock_state::{ClockAdjuster, KAPIClockAdjuster}, + time::Duration, +}; + +#[derive(Parser)] +#[command(version, about, long_about = None)] +struct Args { + /// Phase correction to apply to the clock in seconds (as a floating point number). + #[arg(short, long, allow_negative_numbers = true)] + phase_correction_seconds: f64, +} + +#[allow( + clippy::cast_precision_loss, + reason = "skew ends up being clamped anyways so no loss of precision in i64 -> f64" +)] +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let phase_correction = Duration::from_seconds_f64(args.phase_correction_seconds); + + let initial_time: DateTime = Utc::now(); + println!("Initial time is {initial_time:?}"); + let clock_adjuster = ClockAdjuster::new(KAPIClockAdjuster); + clock_adjuster + .step_clock(phase_correction) + .map_err(|e| anyhow::anyhow!(e))?; + println!( + "Applied {:+} second phase correction to kernel to step CLOCK_REALTIME.", + phase_correction.as_seconds_f64(), + ); + let final_time: DateTime = Utc::now(); + println!("Final time is {final_time:?}"); + Ok(()) +} From 3c1407d1f90ed30a853b921f1829842ea1e7e45e Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 16 Oct 2025 12:47:34 -0400 Subject: [PATCH 029/177] Modify Period type to use f64 (#39) * Modify Period type to use f64 When working with these types, we have to use floats anyways (created with divisions on duration types). Lets be explicit on the floating point usage. This will lower rounding errors when the period is <1 second as compared to the duration based one. * Revision: Update function documentation --- clock-bound/src/daemon/time/tsc.rs | 54 +++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 30a0b73..1687f97 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -209,26 +209,40 @@ impl Display for Skew { } } -/// A representation of a TSC clock period +/// A representation of a TSC clock period in seconds /// /// Logically, this value is the mathematical inverse of [`Frequency`]. In other words, /// `[Period] = 1 / [Frequency]` +/// +/// ## Note on lossy-ness +/// All time durations are stored internally as `i128` values. This means it is possible +/// to store frequency values that will have precision loss when converted from +/// measurements based on [`Duration`] types #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(transparent)] -pub struct Period(Duration); +pub struct Period(f64); impl Period { + /// Construct from seconds + /// + /// # Panics + /// Panics if `seconds` <= 0 + pub fn from_seconds(seconds: f64) -> Self { + assert!(seconds > 0.0); + Self(seconds) + } + /// Construct from a duration /// /// # Panics /// Panics if `duration <= 0` pub fn from_duration(duration: Duration) -> Self { assert!(duration.get() > 0); - Self(duration) + Self(duration.as_seconds_f64()) } - /// Get the inner duration - pub fn get(self) -> Duration { + /// Get the inner duration in seconds + pub fn get(self) -> f64 { self.0 } @@ -238,11 +252,13 @@ impl Period { /// Given that there is a floating point to integer conversion, precision loss can be /// seen from either large (> 1 PHz) or small (< 1 Hz) frequency values. pub fn from_frequency(frequency: Frequency) -> Self { - // no assert statement. Frequency can't be negative - let period_femtos = 1.0e15 / frequency.0; - let period_femtos = period_femtos.round() as i128; - let period = Duration::from_femtos(period_femtos); - Self::from_duration(period) + Self::from_seconds(1.0 / frequency.get()) + } +} + +impl std::fmt::Display for Period { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:.e}s", self.0) } } @@ -250,8 +266,8 @@ impl Mul for TscDiff { type Output = Duration; fn mul(self, rhs: Period) -> Self::Output { - let dur = self.get() * rhs.get().as_femtos(); - Duration::from_femtos(dur) + let dur_seconds = self.get() as f64 * rhs.get(); + Duration::from_seconds_f64(dur_seconds) } } @@ -276,8 +292,8 @@ impl Div for Duration { type Output = TscDiff; fn div(self, rhs: Period) -> Self::Output { - let diff = self.as_femtos() / rhs.get().as_femtos(); - TscDiff::new(diff) + let diff = self.as_seconds_f64() / rhs.get(); + TscDiff::new(diff as i128) // truncate } } @@ -305,7 +321,7 @@ mod tests { fn frequency_period() { let f = Frequency::from_hz(10.0); // 10 Hz = 0.1 seconds period let period = f.period(); - assert_eq!(period.get(), Duration::from_millis(100)); + assert_abs_diff_eq!(period.get(), 0.1); } #[test] @@ -409,7 +425,7 @@ mod tests { fn period_from_frequency() { let freq = Frequency::from_hz(1000.0); let period = Period::from_frequency(freq); - assert_eq!(period.get(), Duration::from_micros(1000)); + assert_abs_diff_eq!(period.get(), 0.001); } #[test] @@ -434,4 +450,10 @@ mod tests { assert_abs_diff_eq!(ppm_skew.get(), 100.0e-6); assert_abs_diff_eq!(percent_skew.get(), 0.01); } + + #[test] + fn period_display() { + let period = Period::from_seconds(1e-9); + assert_eq!(period.to_string(), "1e-9s"); + } } From 2bd6ca0bd7caec5e654c6ccd8a7fc3ce4d7914c0 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 16 Oct 2025 18:46:46 -0400 Subject: [PATCH 030/177] add TscRtt trait and refactor event::Ntp (#40) TscRtt is an abstraction around Ntp (and Phc in upcoming PR) to allow us to have generic operations when they only care about something's RTT difference. Also add a change to to ensure that tsc_diff can never be negative. Why would that happen? IDK but hopefully it never presents ourselves. Also, it means our clock sync algorithm can have more assurance that we never have to deal with this situation --- clock-bound/src/daemon/event.rs | 16 +++++ clock-bound/src/daemon/event/ntp.rs | 97 ++++++++++++++++++++++++++++- clock-bound/src/daemon/io/ntp.rs | 16 +++-- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/clock-bound/src/daemon/event.rs b/clock-bound/src/daemon/event.rs index a3025bf..6720ca5 100644 --- a/clock-bound/src/daemon/event.rs +++ b/clock-bound/src/daemon/event.rs @@ -4,8 +4,24 @@ mod ntp; pub use ntp::{Ntp, NtpData, Stratum, TryFromU8Error, ValidStratumLevel}; +use crate::daemon::time::{TscCount, TscDiff}; + /// A time synchronization event handled by ClockBound pub enum Event { /// NTP Event Ntp(Ntp), } + +/// Simple abstraction around types that have a TSC read before and after reference clock reads +pub trait TscRtt { + /// The TSC read before sending an event request + fn tsc_pre(&self) -> TscCount; + + /// The TSC read after receiving an event response + fn tsc_post(&self) -> TscCount; + + /// The TSC round-trip-time of an event + fn rtt(&self) -> TscDiff { + self.tsc_post() - self.tsc_pre() + } +} diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index 0e33ddd..84769a5 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -4,17 +4,66 @@ use std::{ fmt::{Display, Formatter}, }; +use super::TscRtt; use crate::daemon::time::{Duration, Instant, TscCount}; /// Contains the NTP and time stamp counter samples to be used by synchronization algorithm. +/// +/// `tsc_post` must be greater than `tsc_pre` #[derive(Debug, Clone, PartialEq, Eq)] pub struct Ntp { /// TSC value before sending event - pub tsc_pre: TscCount, + tsc_pre: TscCount, /// TSC value after sending event - pub tsc_post: TscCount, + tsc_post: TscCount, /// NTP Packet data - pub ntp_data: NtpData, + data: NtpData, +} + +#[bon::bon] +impl Ntp { + /// Construct a [`Ntp`] + /// + /// Returns `None` if `tsc_post <= tsc_pre` + #[builder] + pub fn new(tsc_pre: TscCount, tsc_post: TscCount, ntp_data: NtpData) -> Option { + if tsc_post > tsc_pre { + Some(Self { + tsc_pre, + tsc_post, + data: ntp_data, + }) + } else { + None + } + } +} + +impl Ntp { + /// `tsc_pre` getter + pub fn tsc_pre(&self) -> TscCount { + self.tsc_pre + } + + /// `tsc_post` getter + pub fn tsc_post(&self) -> TscCount { + self.tsc_post + } + + /// `data` getter + pub fn data(&self) -> &NtpData { + &self.data + } +} + +impl TscRtt for Ntp { + fn tsc_pre(&self) -> TscCount { + self.tsc_pre + } + + fn tsc_post(&self) -> TscCount { + self.tsc_post + } } /// NTP-specific data @@ -129,6 +178,48 @@ impl ValidStratumLevel { mod tests { use super::*; + #[test] + fn valid_ntp_event() { + let event = Ntp::builder() + .tsc_pre(TscCount::new(1)) + .tsc_post(TscCount::new(2)) + .ntp_data(NtpData { + server_recv_time: Instant::new(1), + server_send_time: Instant::new(2), + root_delay: Duration::new(3), + root_dispersion: Duration::new(4), + stratum: Stratum::ONE, + }) + .build(); + + let event = event.unwrap(); + + assert_eq!(event.tsc_pre().get(), 1); + assert_eq!(event.tsc_post().get(), 2); + assert_eq!(event.data().server_recv_time, Instant::new(1)); + assert_eq!(event.data().server_send_time, Instant::new(2)); + assert_eq!(event.data().root_delay, Duration::new(3)); + assert_eq!(event.data().root_dispersion, Duration::new(4)); + assert_eq!(event.data().stratum, Stratum::ONE); + } + + #[test] + fn wrong_tsc_order() { + let event = Ntp::builder() + .tsc_pre(TscCount::new(2)) + .tsc_post(TscCount::new(1)) + .ntp_data(NtpData { + server_recv_time: Instant::new(1), + server_send_time: Instant::new(2), + root_delay: Duration::new(3), + root_dispersion: Duration::new(4), + stratum: Stratum::ONE, + }) + .build(); + + assert!(event.is_none()); + } + #[test] fn stratum_new_valid_values() { assert_eq!(Stratum::new(0), Some(Stratum::Unspecified)); diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index 6f57f52..ed4d5d1 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -36,6 +36,8 @@ pub enum LinkLocalError { SendEventMessage(#[from] mpsc::error::SendError), #[error("Operation timed out.")] Timeout(#[from] time::error::Elapsed), + #[error("TSC order failure. tsc_pre: {pre}. tsc_post: {post}")] + TscOrder { pre: u64, post: u64 }, } /// Contains the data needed to run the link local runner. @@ -89,11 +91,15 @@ impl LinkLocal { let ntp_data = NtpData::try_from(ntp_packet) .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; - let ntp_event = event::Ntp { - tsc_pre: TscCount::new(sent_timestamp.into()), - tsc_post: TscCount::new(received_timestamp.into()), - ntp_data, - }; + let ntp_event = event::Ntp::builder() + .tsc_pre(TscCount::new(sent_timestamp.into())) + .tsc_post(TscCount::new(received_timestamp.into())) + .ntp_data(ntp_data) + .build() + .ok_or(LinkLocalError::TscOrder { + pre: sent_timestamp, + post: received_timestamp, + })?; debug!(?recv_packet_result, "Received packet."); self.communication_channels From a1ba7761ec695720d4f884a11fd237575c4d56a8 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 20 Oct 2025 12:00:34 -0400 Subject: [PATCH 031/177] Add boilerplate struct for ClockSyncAlgorithm (#38) --- clock-bound/src/daemon/clock_parameters.rs | 1 + .../src/daemon/clock_sync_algorithm.rs | 31 ++++++++++++ .../src/daemon/clock_sync_algorithm/ff.rs | 8 ++++ .../clock_sync_algorithm/ff/event_buffer.rs | 7 +++ .../ff/event_buffer/estimate.rs | 11 +++++ .../ff/event_buffer/local.rs | 11 +++++ .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 48 +++++++++++++++++++ .../clock_sync_algorithm/ring_buffer.rs | 5 +- .../src/daemon/clock_sync_algorithm/source.rs | 12 +++++ .../clock_sync_algorithm/source/link_local.rs | 39 +++++++++++++++ 10 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/ff.rs create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/source.rs create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs diff --git a/clock-bound/src/daemon/clock_parameters.rs b/clock-bound/src/daemon/clock_parameters.rs index 73c72fd..c6ae545 100644 --- a/clock-bound/src/daemon/clock_parameters.rs +++ b/clock-bound/src/daemon/clock_parameters.rs @@ -8,6 +8,7 @@ use crate::daemon::time::{Duration, Instant, TscCount, tsc::Period}; /// /// These values are calculated by the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) /// and used by the [`ClockState`](super::clock_state) +#[derive(Debug, Clone, PartialEq)] pub struct ClockParameters { /// The tsc values that these account for pub tsc_count: TscCount, diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index dd000fe..617d110 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -1,4 +1,35 @@ //! Feed forward clock sync algorithm +#![expect(dead_code, reason = "remove when RoutableEvent is added")] + +pub mod ff; mod ring_buffer; pub use ring_buffer::RingBuffer; + +use crate::daemon::{clock_parameters::ClockParameters, event}; + +pub mod source; + +/// ClockBound's Clock Sync Algorithm +/// +/// The ClockBound clock sync algorithm’s role is to consume, transform, and relay input +/// clock sources to answer the singular question: +/// > What time is it? +/// +/// The [`ClockSyncAlgorithm`] is a [sans-io](https://sans-io.readthedocs.io/) component +/// that feeds on time synchronization events, and outputs the singular best estimate for +/// the time, TSC frequency, and their associated errors. +/// +/// # Usage +/// TODO +#[derive(Debug, Clone, bon::Builder)] +pub struct ClockSyncAlgorithm { + /// The link-local reference clock's ff algorithm + link_local: source::LinkLocal, +} + +impl ClockSyncAlgorithm { + fn feed_link_local(&mut self, event: event::Ntp) -> Option { + self.link_local.feed(event) + } +} diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs new file mode 100644 index 0000000..2ec75ac --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs @@ -0,0 +1,8 @@ +//! Flavours of clock sync algorithms +//! +//! This is where math happens + +pub mod event_buffer; + +mod ntp; +pub use ntp::Ntp; diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs new file mode 100644 index 0000000..2de9e89 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs @@ -0,0 +1,7 @@ +//! Local and Estimate ring buffers used within a Feed Forward Clock Sync Algorithm + +mod local; +pub use local::Local; + +mod estimate; +pub use estimate::Estimate; diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs new file mode 100644 index 0000000..04516e4 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs @@ -0,0 +1,11 @@ +//! An estimate event buffer + +use std::marker::PhantomData; + +/// An estimate ring buffer +/// +/// TODO: Implement +#[derive(Debug, Clone)] +pub struct Estimate { + _phantom: PhantomData, +} diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs new file mode 100644 index 0000000..53c8a4e --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs @@ -0,0 +1,11 @@ +//! Local event buffer + +use std::marker::PhantomData; + +/// A local buffer of events +/// +/// TODO implement +#[derive(Debug, Clone)] +pub struct Local { + _inner: PhantomData, +} diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs new file mode 100644 index 0000000..d5bae58 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -0,0 +1,48 @@ +//! The NTP Feed-forward time synchronization algorithm + +use std::num::NonZeroUsize; + +use super::event_buffer; +use crate::daemon::{clock_parameters::ClockParameters, event, time::tsc::Period}; + +/// Feed forward time synchronization algorithm for a single NTP source +#[derive(Debug, Clone)] +pub struct Ntp { + /// Events within the current SKM (within 1000 seconds) + local: event_buffer::Local, + /// Best RTT values of each SKM over the last week + estimate: event_buffer::Estimate, + /// Current calculation of [`ClockParameters`] + clock_parameters: Option, + /// Current TSC period estimate + period_estimate: Option, +} + +impl Ntp { + /// Create a new feed forward time synchronization algorithm + /// + /// `local_capacity` should be the number of data-points to span an SKM window. + /// For example, if the source is expected to sample once every second, the `local_capacity` + /// should have a max value of 1000. + pub fn new(_local_capacity: NonZeroUsize) -> Self { + todo!() + // Self { + // local: event_buffer::Local::new(local_capacity), + // estimate: event_buffer::Estimate::new(), + // clock_parameters: None, + // period_estimate: None, + // } + } + + /// Feed an event into this algorithm + /// + /// Returns [`Some`] if the event has improved this source's [`ClockParameters`]. + pub fn feed(&mut self, _event: event::Ntp) -> Option { + todo!() + } + + /// Get the current [`ClockParameters`] + pub fn clock_parameters(&self) -> Option<&ClockParameters> { + self.clock_parameters.as_ref() + } +} diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs index ef28061..9de88a8 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs @@ -1,6 +1,6 @@ //! Ring buffers used internally in the FF clock sync algorithm -use std::{collections::VecDeque, num::NonZeroUsize}; +use std::{collections::VecDeque, fmt::Debug, num::NonZeroUsize}; /// A fixed-size ring buffer /// @@ -10,6 +10,7 @@ use std::{collections::VecDeque, num::NonZeroUsize}; /// Uses `head` and `tail` terminology. The head is where the most recent values /// are added, and `tail` is where the oldest values are. Values are added to the head /// via [`RingBuffer::push`]. +#[derive(Debug, Clone)] pub struct RingBuffer { buffer: VecDeque, capacity: usize, @@ -29,7 +30,7 @@ impl RingBuffer { /// /// Returns the value that was overwritten, if any pub fn push(&mut self, value: T) -> Option { - let popped = if self.buffer.len() == self.capacity { + let popped = if self.is_full() { self.buffer.pop_front() } else { None diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source.rs b/clock-bound/src/daemon/clock_sync_algorithm/source.rs new file mode 100644 index 0000000..a91961d --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/source.rs @@ -0,0 +1,12 @@ +//! Reference clock sources +//! +//! Used to separate instantiations of [`ff`](super::ff). +//! Just because a link local connection and a NTP source use the same underlying +//! [`ff::Ntp`](super::ff::Ntp) algorithm, does not mean everything about the sources are the same. +//! +//! This module contains wrapping logic around [`ff`](super::ff) to enable stronger separation of +//! concerns between different source types. + +mod link_local; + +pub use link_local::LinkLocal; diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs new file mode 100644 index 0000000..67e8a68 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs @@ -0,0 +1,39 @@ +//! Link local source + +use std::num::NonZeroUsize; + +use crate::daemon::clock_parameters::ClockParameters; +use crate::daemon::clock_sync_algorithm::ff; +use crate::daemon::event; + +/// A Link Local reference clock source +/// +/// Wraps around an NTP feed-forward clock-sync algorithm +#[derive(Debug, Clone)] +pub struct LinkLocal { + inner: ff::Ntp, +} + +impl LinkLocal { + // Poll every 2 seconds. Capacity is 1000 / 2 = 500 + const CAPACITY: NonZeroUsize = NonZeroUsize::new(500).unwrap(); + + /// Create a new Link Local reference clock source + pub fn new() -> Self { + Self { + inner: ff::Ntp::new(Self::CAPACITY), + } + } + + /// Feed an event into the link local NTP clock-sync algorithm + #[tracing::instrument(level = "info", skip_all)] + pub fn feed(&mut self, event: event::Ntp) -> Option { + self.inner.feed(event) + } +} + +impl Default for LinkLocal { + fn default() -> Self { + Self::new() + } +} From 723719b63b3f69d40c6c1920d93de101de01f1ec Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 20 Oct 2025 12:06:38 -0400 Subject: [PATCH 032/177] Add ff-tester time module and refactor cb time (#36) * Refactor clockbound time types to enable reuse and tight integration with ff-tester crate * Revision: Add license files * Revision: it's -> its --------- Co-authored-by: Mohammed Kabir Co-authored-by: Thoth Gunter --- Cargo.lock | 47 +- Cargo.toml | 1 + clock-bound-ff-tester/Cargo.toml | 24 + clock-bound-ff-tester/LICENSE.Apache-2.0 | 202 ++++++++ clock-bound-ff-tester/LICENSE.MIT | 9 + clock-bound-ff-tester/Makefile.toml | 1 + clock-bound-ff-tester/src/lib.rs | 3 + clock-bound-ff-tester/src/time.rs | 20 + .../src/time/estimate_instant.rs | 84 ++++ clock-bound-ff-tester/src/time/series.rs | 185 +++++++ .../src/time/true_instant.rs | 72 +++ clock-bound/src/daemon/time/inner.rs | 459 ++++++++++++++++- clock-bound/src/daemon/time/instant.rs | 461 +----------------- clock-bound/src/daemon/time/tsc.rs | 1 - clock-bound/src/lib.rs | 7 - 15 files changed, 1104 insertions(+), 472 deletions(-) create mode 100644 clock-bound-ff-tester/Cargo.toml create mode 100644 clock-bound-ff-tester/LICENSE.Apache-2.0 create mode 100644 clock-bound-ff-tester/LICENSE.MIT create mode 100644 clock-bound-ff-tester/Makefile.toml create mode 100644 clock-bound-ff-tester/src/lib.rs create mode 100644 clock-bound-ff-tester/src/time.rs create mode 100644 clock-bound-ff-tester/src/time/estimate_instant.rs create mode 100644 clock-bound-ff-tester/src/time/series.rs create mode 100644 clock-bound-ff-tester/src/time/true_instant.rs diff --git a/Cargo.lock b/Cargo.lock index 78bfb2e..f1cff57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,7 +260,7 @@ dependencies = [ "mockall", "nix", "nom", - "rstest", + "rstest 0.26.1", "serde", "tempfile", "thiserror", @@ -279,6 +279,19 @@ dependencies = [ "clock-bound", ] +[[package]] +name = "clock-bound-ff-tester" +version = "2.0.3" +dependencies = [ + "approx", + "clap", + "clock-bound", + "rstest 0.25.0", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "clock-bound-ffi" version = "2.0.3" @@ -842,6 +855,18 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros 0.25.0", + "rustc_version", +] + [[package]] name = "rstest" version = "0.26.1" @@ -850,7 +875,25 @@ checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" dependencies = [ "futures-timer", "futures-util", - "rstest_macros", + "rstest_macros 0.26.1", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d312ee9..a864b46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "clock-bound", "clock-bound-ffi", + "clock-bound-ff-tester", "examples/client/rust", "test/clock-bound-vmclock-client-test", "test/link-local", diff --git a/clock-bound-ff-tester/Cargo.toml b/clock-bound-ff-tester/Cargo.toml new file mode 100644 index 0000000..efb256b --- /dev/null +++ b/clock-bound-ff-tester/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "clock-bound-ff-tester" +description = "A library for deterministic feed-forward clock synchronization algorithm testing" +license = "MIT OR Apache-2.0" + +authors.workspace = true +categories.workspace = true +edition = "2024" +exclude.workspace = true +keywords.workspace = true +publish.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +clock-bound = { path = "../clock-bound", features = ["daemon"] } +serde = { version = "1.0", features = ["derive"] } +thiserror = { version = "2.0" } + +[dev-dependencies] +approx = "0.5" +rstest = "0.25" +serde_json = "1.0.145" diff --git a/clock-bound-ff-tester/LICENSE.Apache-2.0 b/clock-bound-ff-tester/LICENSE.Apache-2.0 new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/clock-bound-ff-tester/LICENSE.Apache-2.0 @@ -0,0 +1,202 @@ + + 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 + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/clock-bound-ff-tester/LICENSE.MIT b/clock-bound-ff-tester/LICENSE.MIT new file mode 100644 index 0000000..9a3be15 --- /dev/null +++ b/clock-bound-ff-tester/LICENSE.MIT @@ -0,0 +1,9 @@ +MIT License + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +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. \ No newline at end of file diff --git a/clock-bound-ff-tester/Makefile.toml b/clock-bound-ff-tester/Makefile.toml new file mode 100644 index 0000000..8ee118d --- /dev/null +++ b/clock-bound-ff-tester/Makefile.toml @@ -0,0 +1 @@ +extend = "../Makefile.toml" diff --git a/clock-bound-ff-tester/src/lib.rs b/clock-bound-ff-tester/src/lib.rs new file mode 100644 index 0000000..11bddb2 --- /dev/null +++ b/clock-bound-ff-tester/src/lib.rs @@ -0,0 +1,3 @@ +//! Feed Forward Time sync algorithm tester + +pub mod time; diff --git a/clock-bound-ff-tester/src/time.rs b/clock-bound-ff-tester/src/time.rs new file mode 100644 index 0000000..06b4e41 --- /dev/null +++ b/clock-bound-ff-tester/src/time.rs @@ -0,0 +1,20 @@ +//! Simple time library for usage in `ff-tester` and a lost opportunity to have a library called "eff time" +//! +//! Other time libraries do not meet our needs, as `ClockBound` uses hardware counters +//! for the bulk of it's processing. These values are lower-level and abstractier than those seen in `chrono` and `std::time`. +//! +//! Furthermore, `ff-tester` during simulations must make considerations based on a mythical "true time", where the precise, accurate, and otherwise +//! exact time is known a-priori. Furthermore, it must also make distinctions between "time estimates", where a best effort of time is known. + +// These types are unchanged from clock-bound +pub use clock_bound::daemon::time::inner; +pub use clock_bound::daemon::time::tsc::{Frequency, Period, Skew, TscCount, TscDiff}; + +mod true_instant; +pub use true_instant::{AssumeTrue, DemoteToEstimate, TrueDuration, TrueInstant}; + +mod estimate_instant; +pub use estimate_instant::{CbBridge, EstimateDuration, EstimateInstant}; + +mod series; +pub use series::Series; diff --git a/clock-bound-ff-tester/src/time/estimate_instant.rs b/clock-bound-ff-tester/src/time/estimate_instant.rs new file mode 100644 index 0000000..a605202 --- /dev/null +++ b/clock-bound-ff-tester/src/time/estimate_instant.rs @@ -0,0 +1,84 @@ +//! The corresponding local view of time based off of a hardware timer +//! +//! All time readings/measurements have an error bound. This is a type to designate a time which has +//! come from an external reading and may have known inaccuracies. +//! +//! A linux "system time" is an "estimate time" in this definition. However, a linux system time is a linux specific clock, +//! and this crate uses a different name to prevent confusion from that term. For example, a TSC reading (a raw timestamp), +//! multiplied by its current frequency and start time creates another estimate time. But that time is not the same as the +//! linux system time. + +use super::inner::{Diff, Time}; +use clock_bound::daemon::time as cb_time; + +/// Marker type to create a local timestamp with [`crate::time::Time`] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct Estimate; + +impl super::inner::Type for Estimate {} +impl super::inner::FemtoType for Estimate {} + +/// Representation of an absolute time timestamp +/// +/// This value represents the number of nanoseconds since epoch, without leap seconds +/// +/// This type's epoch is January 1, 1970 0:00:00 UTC (aka "UNIX timestamp") +/// +/// This type's inner value is an i128 number of nanoseconds from epoch. +pub type EstimateInstant = Time; + +/// The corresponding duration type for [`EstimateInstant`] +pub type EstimateDuration = Diff; + +pub trait CbBridge { + type Estimate; + fn into_estimate(self) -> Self::Estimate; + fn from_estimate(estimate: Self::Estimate) -> Self; +} + +impl CbBridge for cb_time::Instant { + type Estimate = EstimateInstant; + + fn into_estimate(self) -> Self::Estimate { + EstimateInstant::new(self.get()) + } + + fn from_estimate(estimate: Self::Estimate) -> Self { + cb_time::Instant::new(estimate.get()) + } +} + +impl CbBridge for cb_time::Duration { + type Estimate = EstimateDuration; + + fn into_estimate(self) -> Self::Estimate { + EstimateDuration::new(self.get()) + } + + fn from_estimate(estimate: Self::Estimate) -> Self { + cb_time::Duration::new(estimate.get()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cb_bridge_instant() { + let cb_instant = cb_time::Instant::new(123456789); + let estimate_instant: EstimateInstant = cb_instant.into_estimate(); + assert_eq!(cb_instant.get(), estimate_instant.get()); + let cb_instant2 = cb_time::Instant::from_estimate(estimate_instant); + assert_eq!(cb_instant, cb_instant2); + } + + #[test] + fn cb_bridge_duration() { + let cb_duration = cb_time::Duration::new(123456789); + let estimate_duration: EstimateDuration = cb_duration.into_estimate(); + assert_eq!(cb_duration.get(), estimate_duration.get()); + let cb_duration2 = cb_time::Duration::from_estimate(estimate_duration); + assert_eq!(cb_duration, cb_duration2); + } +} diff --git a/clock-bound-ff-tester/src/time/series.rs b/clock-bound-ff-tester/src/time/series.rs new file mode 100644 index 0000000..6b65ba9 --- /dev/null +++ b/clock-bound-ff-tester/src/time/series.rs @@ -0,0 +1,185 @@ +//! Abstraction around time series data points + +use serde::{Deserialize, Serialize}; + +use super::inner::{Diff, Time, Type}; + +/// One-dimensional array of data (great for time series) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "SeriesDeserialize")] +pub struct Series { + indices: Vec, + data: Vec, +} + +impl Series { + /// Create a new series + /// + /// Returns none if index and data lengths don't match + pub fn new(index: Vec, data: Vec) -> Option { + if index.len() != data.len() { + return None; + } + Some(Self { + indices: index, + data, + }) + } + + /// Returns the number of data points in this series + pub fn len(&self) -> usize { + self.indices.len() + } + + /// Returns `true` if this series has no data points + pub fn is_empty(&self) -> bool { + self.indices.is_empty() + } + + /// Get the indices of the series + pub fn indices(&self) -> &[X] { + &self.indices + } + + /// Get the data of the series + pub fn data(&self) -> &[Y] { + &self.data + } + + /// Iterator of index with data + pub fn iter(&self) -> impl Iterator { + self.indices.iter().zip(self.data.iter()) + } + + /// Construct from an iterator of tuples + fn from_iter(iter: I) -> Self + where + I: IntoIterator, + { + let iter = iter.into_iter(); + let capacity = iter.size_hint().0; + let mut index = Vec::with_capacity(capacity); + let mut data = Vec::with_capacity(capacity); + for (i, d) in iter { + index.push(i); + data.push(d); + } + Self { + indices: index, + data, + } + } +} + +impl FromIterator<(X, Y)> for Series { + fn from_iter>(iter: T) -> Self { + Self::from_iter(iter) + } +} + +impl Series, Y> { + /// Get the absolute time of a time series of durations + /// + /// Useful for ad-hoc conversion between a generic time series of durations and + /// a series with a known starting time + pub fn absolute_time_indexes(&self, start_time: Time) -> impl Iterator> { + self.indices.iter().map(move |offset| start_time + *offset) + } + + /// [`Series::iter`] but with [`Time`] values on the X axis + pub fn instant_iter(&self, start_time: Time) -> impl Iterator, &Y)> { + self.absolute_time_indexes(start_time).zip(self.data.iter()) + } +} + +// helper to guard against invariants when deserializing +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct SeriesDeserialize { + pub indices: Vec, + pub data: Vec, +} + +impl TryFrom> for Series { + type Error = Error; + + fn try_from(value: SeriesDeserialize) -> Result { + if value.indices.len() != value.data.len() { + return Err(Error { + index_len: value.indices.len(), + data_len: value.data.len(), + }); + } + Ok(Self { + indices: value.indices, + data: value.data, + }) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("offsets and time_steps length mismatch: index len: {index_len}. data len: {data_len}")] +struct Error { + index_len: usize, + data_len: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn series_creation_success() { + let index = vec![1, 2, 3]; + let data = vec!["a", "b", "c"]; + + let series = Series::new(index.clone(), data.clone()); + assert!(series.is_some()); + + let series = series.unwrap(); + assert_eq!(series.indices(), &index); + assert_eq!(series.data(), &data); + } + + #[test] + fn series_creation_failure() { + let index = vec![1, 2, 3]; + let data = vec!["a", "b"]; // Mismatched lengths + + let series = Series::new(index, data); + assert!(series.is_none()); + } + + #[test] + fn series_deserialization_success() { + let deser = SeriesDeserialize { + indices: vec![1, 2], + data: vec!["a", "b"], + }; + + let _ = Series::try_from(deser).unwrap(); + } + + #[test] + fn series_deserialization_failure() { + let deser = SeriesDeserialize { + indices: vec![1, 2, 3], + data: vec!["a", "b"], + }; + + let result: Result, _> = Series::try_from(deser); + assert!(result.is_err()); + + if let Err(err) = result { + assert_eq!(err.index_len, 3); + assert_eq!(err.data_len, 2); + } + } + + #[test] + fn serialization_loopback() { + let series = Series::new(vec![1, 2, 3], vec!["a", "b", "c"]).unwrap(); + let serialized = serde_json::to_string(&series).unwrap(); + let deserialized: SeriesDeserialize<_, _> = serde_json::from_str(&serialized).unwrap(); + assert_eq!(series, Series::try_from(deserialized).unwrap()); + } +} diff --git a/clock-bound-ff-tester/src/time/true_instant.rs b/clock-bound-ff-tester/src/time/true_instant.rs new file mode 100644 index 0000000..be0533c --- /dev/null +++ b/clock-bound-ff-tester/src/time/true_instant.rs @@ -0,0 +1,72 @@ +//! Everything is time. True time is the largely fictional/aspirational concept of what if we knew exactly what time it was, without error + +use super::inner::{FemtoType, Type}; + +use crate::time::inner::{Diff, Time}; + +use super::{EstimateDuration, EstimateInstant}; + +/// Marker type to signify a time as True Time +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct True; + +impl Type for True {} +impl FemtoType for True {} + +/// Representation of true time timestamps +/// +/// True time is, effectively, what all NTP clients are trying to determine. However getting an exact value +/// is impossible, as all things in life have an error (a Clock Error Bound in our terminology). +/// +/// When `ff-tester` is creating simulations, `ff-tester` is able to know what "true time" is for a scenario because +/// it is not constrained by the rules of the real world. +/// +/// This type's epoch is UTC. January 1, 1970 0:00:00 UTC (aka “UNIX timestamp”). Counted **without** leap seconds +/// +/// This type's inner value is an i128 number of **femto**seconds from epoch. +pub type TrueInstant = Time; + +/// The corresponding duration type for [`TrueInstant`] +pub type TrueDuration = Diff; + +pub trait AssumeTrue { + type TrueType; + fn assume_true(self) -> Self::TrueType; +} + +pub trait DemoteToEstimate { + type EstimateType; + fn demote_to_estimate(self) -> Self::EstimateType; +} + +impl AssumeTrue for EstimateInstant { + type TrueType = TrueInstant; + + fn assume_true(self) -> Self::TrueType { + TrueInstant::new(self.get()) + } +} + +impl AssumeTrue for EstimateDuration { + type TrueType = TrueDuration; + + fn assume_true(self) -> Self::TrueType { + TrueDuration::new(self.get()) + } +} + +impl DemoteToEstimate for TrueInstant { + type EstimateType = EstimateInstant; + + fn demote_to_estimate(self) -> Self::EstimateType { + EstimateInstant::new(self.get()) + } +} + +impl DemoteToEstimate for TrueDuration { + type EstimateType = EstimateDuration; + + fn demote_to_estimate(self) -> Self::EstimateType { + EstimateDuration::new(self.get()) + } +} diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 7421b8b..2a056fd 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -7,8 +7,13 @@ use std::{ ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}, }; +use libc::timeval; + /// Abstraction used to reuse basic time arithmetic, but allow for different types based on its usage -pub trait Type: crate::private::Sealed {} +pub trait Type {} + +/// Abstraction for time type whose tick unit is approximately one femtosecond +pub trait FemtoType: Type {} pub trait Clock { /// Read the current clock time. @@ -110,6 +115,113 @@ impl SubAssign> for Time { } } +impl Time { + pub const UNIX_EPOCH: Self = Self::new(0); + pub const MAX: Self = Self::new(i128::MAX); + pub const MIN: Self = Self::new(i128::MIN); + + /// Create a new `Instant` from the number of seconds since the Unix Epoch + pub const fn from_secs(secs: i128) -> Self { + Self::new(secs * FEMTOS_PER_SEC) + } + + /// Create a new `Instant` from the number of milliseconds since the Unix Epoch + pub const fn from_millis(millis: i128) -> Self { + Self::new(millis * FEMTOS_PER_MILLI) + } + + /// Create a new `Instant` from the number of microseconds since the Unix Epoch + pub const fn from_micros(micros: i128) -> Self { + Self::new(micros * FEMTOS_PER_MICRO) + } + + /// Create a new `Instant` from the number of nanoseconds since the Unix Epoch + pub const fn from_nanos(nanos: i128) -> Self { + Self::new(nanos * FEMTOS_PER_NANO) + } + + /// Create a new `Instant` from the number of picoseconds since the Unix Epoch + pub const fn from_picos(picos: i128) -> Self { + Self::new(picos * FEMTOS_PER_PICO) + } + + /// Create a new `Instant` from the number of femtoseconds since the Unix Epoch + pub const fn from_femtos(femtos: i128) -> Self { + Self::new(femtos) + } + + /// Create a new `Instant` from the number of minutes since the Unix Epoch + pub const fn from_minutes(minutes: i128) -> Self { + Self::new(minutes * FEMTOS_PER_SEC * SECS_PER_MINUTE) + } + + /// Create a new `Instant` from the number of hours since the Unix Epoch + pub const fn from_hours(hours: i128) -> Self { + Self::new(hours * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + } + + /// Create a new `Instant` from the number of days since the Unix Epoch + pub const fn from_days(days: i128) -> Self { + Self::new(days * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + } + + /// Construct from the number of seconds and nanos since the Unix Epoch + /// + /// # Panics + /// Panics if `nanos >= 1_000_000_000`, or value does not fit within the type + pub fn from_time(secs: i128, nanos: u32) -> Self { + assert!(nanos < 1_000_000_000, "nanos must be less than 1 second"); + let secs = Self::from_secs(secs); + let nanos = Diff::::from_nanos(i128::from(nanos)); + secs + nanos + } + + /// Returns the total number of femtoseconds since the Unix Epoch + pub const fn as_femtos(self) -> i128 { + self.get() + } + + /// Returns the total number of picoseconds since the Unix Epoch + pub const fn as_picos(self) -> i128 { + self.get() / FEMTOS_PER_PICO + } + + /// Returns the total number of nanoseconds since the Unix Epoch + pub const fn as_nanos(self) -> i128 { + self.get() / FEMTOS_PER_NANO + } + + /// Returns the total number of microseconds, truncated, since the Unix Epoch + pub const fn as_micros(self) -> i128 { + self.get() / FEMTOS_PER_MICRO + } + + /// Returns the total number of milliseconds, truncated, since the Unix Epoch + pub const fn as_millis(self) -> i128 { + self.get() / FEMTOS_PER_MILLI + } + + /// Returns the total number of seconds, truncated, since the Unix Epoch + pub const fn as_seconds(self) -> i128 { + self.get() / FEMTOS_PER_SEC + } + + /// Returns the total number of minutes, truncated, since the Unix Epoch + pub const fn as_minutes(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE) + } + + /// Returns the total number of hours, truncated, since the Unix Epoch + pub const fn as_hours(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + } + + /// Returns the total number of days, truncated, since the Unix Epoch + pub const fn as_days(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + } +} + /// Difference between 2 [`Time`] values /// /// It is not recommended to use this directly, but use the [`Duration`](super::Duration) or [`TscDiff`](super::TscDiff) types @@ -261,14 +373,195 @@ impl Neg for Diff { } } +impl Diff { + /// Create a new [`Diff`] from the number of seconds + pub const fn from_secs(secs: i128) -> Self { + Self::new(secs * FEMTOS_PER_SEC) + } + + /// Create a new [`Diff`] from the number of seconds in `f64` format + /// + /// Will truncate to the nearest femtosecond + #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] + #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] + pub const fn from_seconds_f64(secs: f64) -> Self { + Self::new((secs * FEMTOS_PER_SEC as f64) as i128) + } + + /// Create a new [`Diff`] from the number of milliseconds in `f64` format + /// + /// Will truncate to the nearest femtosecond + #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] + #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] + pub const fn from_millis_f64(millis: f64) -> Self { + Self::new((millis * FEMTOS_PER_MILLI as f64) as i128) + } + + /// Create a new [`Diff`] from the number of microseconds in `f64` format + /// + /// Will truncate to the nearest femtosecond + #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] + #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] + pub const fn from_micros_f64(micros: f64) -> Self { + Self::new((micros * FEMTOS_PER_MICRO as f64) as i128) + } + + /// Create a new [`Diff`] from the number of nanoseconds in `f64` format + /// + /// Will truncate to the nearest femtosecond + #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] + #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] + pub const fn from_nanos_f64(nanos: f64) -> Self { + Self::new((nanos * FEMTOS_PER_NANO as f64) as i128) + } + + /// Create a new [`Diff`] from the number of milliseconds + pub const fn from_millis(millis: i128) -> Self { + Self::new(millis * FEMTOS_PER_MILLI) + } + + /// Create a new [`Diff`] from the number of microseconds + pub const fn from_micros(micros: i128) -> Self { + Self::new(micros * FEMTOS_PER_MICRO) + } + + /// Create a new [`Diff`] from the number of nanoseconds + pub const fn from_nanos(nanos: i128) -> Self { + Self::new(nanos * FEMTOS_PER_NANO) + } + + /// Create a new [`Diff`] from the number of picoseconds + pub const fn from_picos(picos: i128) -> Self { + Self::new(picos * FEMTOS_PER_PICO) + } + + /// Create a new [`Diff`] from the number of femtoseconds + pub const fn from_femtos(femtos: i128) -> Self { + Self::new(femtos) + } + + /// Create a new [`Diff`] from the number of minutes + pub const fn from_minutes(minutes: i128) -> Self { + Self::new(minutes * FEMTOS_PER_SEC * SECS_PER_MINUTE) + } + + /// Create a new [`Diff`] from the number of hours + pub const fn from_hours(hours: i128) -> Self { + Self::new(hours * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + } + + /// Create a new [`Diff`] from the number of days + pub const fn from_days(days: i128) -> Self { + Self::new(days * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + } + + /// Construct from seconds and nanos + /// + /// # Panics + /// Panics if `nanos >= 1_000_000_000`, or value does not fit within the type + pub fn from_time(secs: i128, nanos: u32) -> Self { + assert!(nanos < 1_000_000_000, "nanos must be less than 1 second"); + + let secs = Self::from_secs(secs); + let nanos = Self::from_nanos(i128::from(nanos)); + secs + nanos + } + + /// Returns the total number of femtoseconds + pub const fn as_femtos(self) -> i128 { + self.get() + } + + /// Returns the total number of picoseconds, truncated + pub const fn as_picos(self) -> i128 { + self.get() / FEMTOS_PER_PICO + } + + /// Returns the total number of nanoseconds, truncated + pub const fn as_nanos(self) -> i128 { + self.get() / FEMTOS_PER_NANO + } + + /// Returns the total number of microseconds, truncated + pub const fn as_micros(self) -> i128 { + self.get() / FEMTOS_PER_MICRO + } + + /// Returns the total number of milliseconds, truncated + pub const fn as_millis(self) -> i128 { + self.get() / FEMTOS_PER_MILLI + } + + /// Returns the total number of seconds, truncated + pub const fn as_seconds(self) -> i128 { + self.get() / FEMTOS_PER_SEC + } + + /// Returns the total number of seconds as a f64 + #[expect(clippy::cast_precision_loss, reason = "division mitigates")] + pub const fn as_seconds_f64(self) -> f64 { + self.get() as f64 / FEMTOS_PER_SEC as f64 + } + + /// Returns the total number of minutes, truncated + pub const fn as_minutes(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE) + } + + /// Returns the total number of hours, truncated + pub const fn as_hours(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + } + + /// Returns the total number of days, truncated + pub const fn as_days(self) -> i128 { + self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + } + + /// Returns the `Duration` converted to a `timeval`. + /// It's used by `adjtimex`/`ntp_adjtime` in the returned timestamp or in `ADJ_SETOFFSET`. + /// + /// A `timeval` is equivalent to `timespec` in usage, but with normally less resolution.. + /// However, a caller may supply `ADJ_NANO` to tell the kernel we want to supply a nanosecond value inside + /// `tv_usec`, and so we do that to be consistent with our `clock_adjust` offset resolution). + /// Thus, this does not return a correct `timeval` by the specifications of the fields themselves, but instead + /// a `timeval` in the context of an `adjtimex` call with `ADJ_NANO` set. + #[allow( + clippy::cast_possible_truncation, + reason = "tv_sec truncation should be acceptable to i64::MAX for any of our use cases, and tv_usec should be between 0 and 1e9-1 so no truncation" + )] + pub fn to_timeval_nanos(self) -> timeval { + let mut tv = timeval { + tv_sec: self.as_seconds() as i64, + tv_usec: (self.as_nanos() % NANOS_PER_SECOND) as i64, + }; + // Normalize the timeval, as `tv_usec` cannot be negative (so we push the "negative" place into `tv_sec`) + if tv.tv_usec < 0 { + tv.tv_sec -= 1; + tv.tv_usec += 1_000_000_000_i64; + } + tv + } +} + +pub(crate) const FEMTOS_PER_SEC: i128 = 1_000_000_000_000_000; +pub(crate) const FEMTOS_PER_MILLI: i128 = 1_000_000_000_000; +pub(crate) const FEMTOS_PER_MICRO: i128 = 1_000_000_000; +pub(crate) const FEMTOS_PER_NANO: i128 = 1_000_000; +pub(crate) const FEMTOS_PER_PICO: i128 = 1_000; +pub(crate) const SECS_PER_MINUTE: i128 = 60; +pub(crate) const MINS_PER_HOUR: i128 = 60; +pub(crate) const HOURS_PER_DAY: i128 = 24; +pub(crate) const NANOS_PER_SECOND: i128 = 1_000_000_000; + #[cfg(test)] mod test { use super::*; + use crate::daemon::time::{Duration, Instant}; #[derive(Clone, Copy)] struct TestType; - impl crate::private::Sealed for TestType {} impl Type for TestType {} type TestTimestamp = Time; @@ -399,4 +692,166 @@ mod test { let midpoint = a.midpoint(b); assert_eq!(midpoint.instant, expected); } + + #[test] + fn secs() { + let time = Instant::from_secs(1); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_femtos(), 1_000_000_000_000_000); + } + + #[test] + fn nanos() { + let time = Instant::from_nanos(1); + assert_eq!(time.as_nanos(), 1); + } + + #[test] + fn millis() { + let time = Instant::from_millis(1); + assert_eq!(time.as_millis(), 1); + assert_eq!(time.as_nanos(), 1_000_000); + } + + #[test] + fn micros() { + let time = Instant::from_micros(1); + assert_eq!(time.as_micros(), 1); + assert_eq!(time.as_nanos(), 1_000); + } + + #[test] + fn rounding() { + let time = Instant::from_time(1, 500_000_000); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_nanos(), 1_500_000_000); + } + + #[test] + fn minutes() { + let time = Instant::from_minutes(1); + assert_eq!(time.as_minutes(), 1); + assert_eq!(time.as_nanos(), 60_000_000_000); + } + + #[test] + fn hours() { + let time = Instant::from_hours(1); + assert_eq!(time.as_hours(), 1); + assert_eq!(time.as_nanos(), 3_600_000_000_000); + } + + #[test] + fn days() { + let time = Instant::from_days(1); + assert_eq!(time.as_days(), 1); + assert_eq!(time.as_nanos(), 86_400_000_000_000); + } + + #[test] + fn duration_secs() { + let time = Instant::from_secs(1); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_nanos(), 1_000_000_000); + } + + #[test] + fn duration_nanos() { + let time = Instant::from_nanos(1); + assert_eq!(time.as_nanos(), 1); + } + + #[test] + fn duration_millis() { + let time = Duration::from_millis(1); + assert_eq!(time.as_millis(), 1); + assert_eq!(time.as_nanos(), 1_000_000); + } + + #[test] + fn duration_micros() { + let time = Duration::from_micros(1); + assert_eq!(time.as_micros(), 1); + assert_eq!(time.as_nanos(), 1_000); + } + + #[test] + fn duration_truncating() { + let time = Duration::from_nanos(1_500_000_000); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_nanos(), 1_500_000_000); + } + + #[test] + fn duration_minutes() { + let time = Duration::from_minutes(1); + assert_eq!(time.as_minutes(), 1); + assert_eq!(time.as_nanos(), 60_000_000_000); + } + + #[test] + fn duration_hours() { + let time = Duration::from_hours(1); + assert_eq!(time.as_hours(), 1); + assert_eq!(time.as_nanos(), 3_600_000_000_000); + } + + #[test] + fn duration_days() { + let time = Duration::from_days(1); + assert_eq!(time.as_days(), 1); + assert_eq!(time.as_nanos(), 86_400_000_000_000); + } + + #[test] + fn duration_constructor() { + let time = Duration::from_time(1, 500_000_000); + assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_nanos(), 1_500_000_000); + } + + #[test] + fn duration_seconds_f64_conversion() { + let duration = Duration::from_seconds_f64(1.5); + assert_eq!(duration.as_nanos(), 1_500_000_000); + approx::assert_abs_diff_eq!(duration.as_seconds_f64(), 1.5); + } + + #[test] + fn duration_millis_f64_conversion() { + let duration = Duration::from_millis_f64(1.5); + assert_eq!(duration.as_nanos(), 1_500_000); + } + + #[test] + fn duration_micros_f64_conversion() { + let duration = Duration::from_micros_f64(1.5); + assert_eq!(duration.as_nanos(), 1_500); + } + + #[test] + fn duration_nanos_f64_conversion() { + let duration = Duration::from_nanos_f64(1.5); + assert_eq!(duration.as_nanos(), 1); + } + + #[rstest::rstest] + #[case::positive(Duration::from_nanos(1_400_000_000), 1, 400_000_000)] + #[case::negative(Duration::from_nanos(-1_600_000_000), -2, 400_000_000)] + // Take time in seconds since unix epoch * 100 + #[case::bignum( + Duration::from_nanos(1_760_120_080_500_000_000), + 1_760_120_080, + 500_000_000 + )] + #[case::negative_bignum(Duration::from_nanos(-1_760_120_080_500_000_000), -1_760_120_081, 500_000_000)] + fn duration_to_timeval_micros( + #[case] duration: Duration, + #[case] tv_sec: i64, + #[case] tv_usec_nanos: i64, + ) { + let tv = duration.to_timeval_nanos(); + assert_eq!(tv.tv_sec, tv_sec); + assert_eq!(tv.tv_usec, tv_usec_nanos); + } } diff --git a/clock-bound/src/daemon/time/instant.rs b/clock-bound/src/daemon/time/instant.rs index 3c6f2f1..f94ad3f 100644 --- a/clock-bound/src/daemon/time/instant.rs +++ b/clock-bound/src/daemon/time/instant.rs @@ -1,15 +1,13 @@ //! A simplified time type for `ClockBound` -use libc::timeval; - use super::inner::{Diff, Time}; /// Marker type to signify a time as a timestamp #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] pub struct Utc; -impl crate::private::Sealed for Utc {} impl super::inner::Type for Utc {} +impl super::inner::FemtoType for Utc {} /// Representation of an absolute time timestamp /// @@ -23,460 +21,3 @@ pub type Instant = Time; /// The corresponding duration type for [`Instant`] pub type Duration = Diff; - -impl Instant { - pub const UNIX_EPOCH: Self = Self::new(0); - pub const MAX: Self = Self::new(i128::MAX); - pub const MIN: Self = Self::new(i128::MIN); - - /// Create a new `Instant` from the number of seconds since the Unix Epoch - pub const fn from_secs(secs: i128) -> Self { - Self::new(secs * FEMTOS_PER_SEC) - } - - /// Create a new `Instant` from the number of milliseconds since the Unix Epoch - pub const fn from_millis(millis: i128) -> Self { - Self::new(millis * FEMTOS_PER_MILLI) - } - - /// Create a new `Instant` from the number of microseconds since the Unix Epoch - pub const fn from_micros(micros: i128) -> Self { - Self::new(micros * FEMTOS_PER_MICRO) - } - - /// Create a new `Instant` from the number of nanoseconds since the Unix Epoch - pub const fn from_nanos(nanos: i128) -> Self { - Self::new(nanos * FEMTOS_PER_NANO) - } - - /// Create a new `Instant` from the number of picoseconds since the Unix Epoch - pub const fn from_picos(picos: i128) -> Self { - Self::new(picos * FEMTOS_PER_PICO) - } - - /// Create a new `Instant` from the number of femtoseconds since the Unix Epoch - pub const fn from_femtos(femtos: i128) -> Self { - Self::new(femtos) - } - - /// Create a new `Instant` from the number of minutes since the Unix Epoch - pub const fn from_minutes(minutes: i128) -> Self { - Self::new(minutes * FEMTOS_PER_SEC * SECS_PER_MINUTE) - } - - /// Create a new `Instant` from the number of hours since the Unix Epoch - pub const fn from_hours(hours: i128) -> Self { - Self::new(hours * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) - } - - /// Create a new `Instant` from the number of days since the Unix Epoch - pub const fn from_days(days: i128) -> Self { - Self::new(days * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) - } - - /// Construct from the number of seconds and nanos since the Unix Epoch - /// - /// # Panics - /// Panics if `nanos >= 1_000_000_000`, or value does not fit within the type - pub fn from_time(secs: i128, nanos: u32) -> Self { - assert!(nanos < 1_000_000_000, "nanos must be less than 1 second"); - let secs = Self::from_secs(secs); - let nanos = Duration::from_nanos(i128::from(nanos)); - secs + nanos - } - - /// Returns the total number of femtoseconds since the Unix Epoch - pub const fn as_femtos(self) -> i128 { - self.get() - } - - /// Returns the total number of picoseconds since the Unix Epoch - pub const fn as_picos(self) -> i128 { - self.get() / FEMTOS_PER_PICO - } - - /// Returns the total number of nanoseconds since the Unix Epoch - pub const fn as_nanos(self) -> i128 { - self.get() / FEMTOS_PER_NANO - } - - /// Returns the total number of microseconds, truncated, since the Unix Epoch - pub const fn as_micros(self) -> i128 { - self.get() / FEMTOS_PER_MICRO - } - - /// Returns the total number of milliseconds, truncated, since the Unix Epoch - pub const fn as_millis(self) -> i128 { - self.get() / FEMTOS_PER_MILLI - } - - /// Returns the total number of seconds, truncated, since the Unix Epoch - pub const fn as_seconds(self) -> i128 { - self.get() / FEMTOS_PER_SEC - } - - /// Returns the total number of minutes, truncated, since the Unix Epoch - pub const fn as_minutes(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE) - } - - /// Returns the total number of hours, truncated, since the Unix Epoch - pub const fn as_hours(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) - } - - /// Returns the total number of days, truncated, since the Unix Epoch - pub const fn as_days(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) - } -} - -impl Duration { - /// Create a new [`Duration`] from the number of seconds - pub const fn from_secs(secs: i128) -> Self { - Self::new(secs * FEMTOS_PER_SEC) - } - - /// Create a new [`Duration`] from the number of seconds in `f64` format - /// - /// Will truncate to the nearest femtosecond - #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] - #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] - pub const fn from_seconds_f64(secs: f64) -> Self { - Self::new((secs * FEMTOS_PER_SEC as f64) as i128) - } - - /// Create a new [`Duration`] from the number of milliseconds in `f64` format - /// - /// Will truncate to the nearest femtosecond - #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] - #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] - pub const fn from_millis_f64(millis: f64) -> Self { - Self::new((millis * FEMTOS_PER_MILLI as f64) as i128) - } - - /// Create a new [`Duration`] from the number of microseconds in `f64` format - /// - /// Will truncate to the nearest femtosecond - #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] - #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] - pub const fn from_micros_f64(micros: f64) -> Self { - Self::new((micros * FEMTOS_PER_MICRO as f64) as i128) - } - - /// Create a new [`Duration`] from the number of nanoseconds in `f64` format - /// - /// Will truncate to the nearest femtosecond - #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] - #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] - pub const fn from_nanos_f64(nanos: f64) -> Self { - Self::new((nanos * FEMTOS_PER_NANO as f64) as i128) - } - - /// Create a new [`Duration`] from the number of milliseconds - pub const fn from_millis(millis: i128) -> Self { - Self::new(millis * FEMTOS_PER_MILLI) - } - - /// Create a new [`Duration`] from the number of microseconds - pub const fn from_micros(micros: i128) -> Self { - Self::new(micros * FEMTOS_PER_MICRO) - } - - /// Create a new [`Duration`] from the number of nanoseconds - pub const fn from_nanos(nanos: i128) -> Self { - Self::new(nanos * FEMTOS_PER_NANO) - } - - /// Create a new [`Duration`] from the number of picoseconds - pub const fn from_picos(picos: i128) -> Self { - Self::new(picos * FEMTOS_PER_PICO) - } - - /// Create a new [`Duration`] from the number of femtoseconds - pub const fn from_femtos(femtos: i128) -> Self { - Self::new(femtos) - } - - /// Create a new [`Duration`] from the number of minutes - pub const fn from_minutes(minutes: i128) -> Self { - Self::new(minutes * FEMTOS_PER_SEC * SECS_PER_MINUTE) - } - - /// Create a new [`Duration`] from the number of hours - pub const fn from_hours(hours: i128) -> Self { - Self::new(hours * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) - } - - /// Create a new [`Duration`] from the number of days - pub const fn from_days(days: i128) -> Self { - Self::new(days * FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) - } - - /// Construct from seconds and nanos - /// - /// # Panics - /// Panics if `nanos >= 1_000_000_000`, or value does not fit within the type - pub fn from_time(secs: i128, nanos: u32) -> Self { - assert!(nanos < 1_000_000_000, "nanos must be less than 1 second"); - - let secs = Self::from_secs(secs); - let nanos = Self::from_nanos(i128::from(nanos)); - secs + nanos - } - - /// Returns the total number of femtoseconds - pub const fn as_femtos(self) -> i128 { - self.get() - } - - /// Returns the total number of picoseconds, truncated - pub const fn as_picos(self) -> i128 { - self.get() / FEMTOS_PER_PICO - } - - /// Returns the total number of nanoseconds, truncated - pub const fn as_nanos(self) -> i128 { - self.get() / FEMTOS_PER_NANO - } - - /// Returns the total number of microseconds, truncated - pub const fn as_micros(self) -> i128 { - self.get() / FEMTOS_PER_MICRO - } - - /// Returns the total number of milliseconds, truncated - pub const fn as_millis(self) -> i128 { - self.get() / FEMTOS_PER_MILLI - } - - /// Returns the total number of seconds, truncated - pub const fn as_seconds(self) -> i128 { - self.get() / FEMTOS_PER_SEC - } - - /// Returns the total number of seconds as a f64 - #[expect(clippy::cast_precision_loss, reason = "division mitigates")] - pub const fn as_seconds_f64(self) -> f64 { - self.get() as f64 / FEMTOS_PER_SEC as f64 - } - - /// Returns the total number of minutes, truncated - pub const fn as_minutes(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE) - } - - /// Returns the total number of hours, truncated - pub const fn as_hours(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) - } - - /// Returns the total number of days, truncated - pub const fn as_days(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) - } - - /// Returns the `Duration` converted to a `timeval`. - /// It's used by `adjtimex`/`ntp_adjtime` in the returned timestamp or in `ADJ_SETOFFSET`. - /// - /// A `timeval` is equivalent to `timespec` in usage, but with normally less resolution.. - /// However, a caller may supply `ADJ_NANO` to tell the kernel we want to supply a nanosecond value inside - /// `tv_usec`, and so we do that to be consistent with our `clock_adjust` offset resolution). - /// Thus, this does not return a correct `timeval` by the specifications of the fields themselves, but instead - /// a `timeval` in the context of an `adjtimex` call with `ADJ_NANO` set. - #[allow( - clippy::cast_possible_truncation, - reason = "tv_sec truncation should be acceptable to i64::MAX for any of our use cases, and tv_usec should be between 0 and 1e9-1 so no truncation" - )] - pub fn to_timeval_nanos(self) -> timeval { - let mut tv = timeval { - tv_sec: self.as_seconds() as i64, - tv_usec: (self.as_nanos() % NANOS_PER_SECOND) as i64, - }; - // Normalize the timeval, as `tv_usec` cannot be negative (so we push the "negative" place into `tv_sec`) - if tv.tv_usec < 0 { - tv.tv_sec -= 1; - tv.tv_usec += 1_000_000_000_i64; - } - tv - } -} - -pub(crate) const FEMTOS_PER_SEC: i128 = 1_000_000_000_000_000; -pub(crate) const FEMTOS_PER_MILLI: i128 = 1_000_000_000_000; -pub(crate) const FEMTOS_PER_MICRO: i128 = 1_000_000_000; -pub(crate) const FEMTOS_PER_NANO: i128 = 1_000_000; -pub(crate) const FEMTOS_PER_PICO: i128 = 1_000; -pub(crate) const NANOS_PER_SECOND: i128 = 1_000_000_000; -pub(crate) const SECS_PER_MINUTE: i128 = 60; -pub(crate) const MINS_PER_HOUR: i128 = 60; -pub(crate) const HOURS_PER_DAY: i128 = 24; - -#[cfg(test)] -mod test { - use rstest::rstest; - - use super::*; - - #[test] - fn secs() { - let time = Instant::from_secs(1); - assert_eq!(time.as_seconds(), 1); - assert_eq!(time.as_femtos(), 1_000_000_000_000_000); - } - - #[test] - fn nanos() { - let time = Instant::from_nanos(1); - assert_eq!(time.as_nanos(), 1); - } - - #[test] - fn millis() { - let time = Instant::from_millis(1); - assert_eq!(time.as_millis(), 1); - assert_eq!(time.as_nanos(), 1_000_000); - } - - #[test] - fn micros() { - let time = Instant::from_micros(1); - assert_eq!(time.as_micros(), 1); - assert_eq!(time.as_nanos(), 1_000); - } - - #[test] - fn rounding() { - let time = Instant::from_time(1, 500_000_000); - assert_eq!(time.as_seconds(), 1); - assert_eq!(time.as_nanos(), 1_500_000_000); - } - - #[test] - fn minutes() { - let time = Instant::from_minutes(1); - assert_eq!(time.as_minutes(), 1); - assert_eq!(time.as_nanos(), 60_000_000_000); - } - - #[test] - fn hours() { - let time = Instant::from_hours(1); - assert_eq!(time.as_hours(), 1); - assert_eq!(time.as_nanos(), 3_600_000_000_000); - } - - #[test] - fn days() { - let time = Instant::from_days(1); - assert_eq!(time.as_days(), 1); - assert_eq!(time.as_nanos(), 86_400_000_000_000); - } - - #[test] - fn duration_secs() { - let time = Instant::from_secs(1); - assert_eq!(time.as_seconds(), 1); - assert_eq!(time.as_nanos(), 1_000_000_000); - } - - #[test] - fn duration_nanos() { - let time = Instant::from_nanos(1); - assert_eq!(time.as_nanos(), 1); - } - - #[test] - fn duration_millis() { - let time = Duration::from_millis(1); - assert_eq!(time.as_millis(), 1); - assert_eq!(time.as_nanos(), 1_000_000); - } - - #[test] - fn duration_micros() { - let time = Duration::from_micros(1); - assert_eq!(time.as_micros(), 1); - assert_eq!(time.as_nanos(), 1_000); - } - - #[test] - fn duration_truncating() { - let time = Duration::from_nanos(1_500_000_000); - assert_eq!(time.as_seconds(), 1); - assert_eq!(time.as_nanos(), 1_500_000_000); - } - - #[test] - fn duration_minutes() { - let time = Duration::from_minutes(1); - assert_eq!(time.as_minutes(), 1); - assert_eq!(time.as_nanos(), 60_000_000_000); - } - - #[test] - fn duration_hours() { - let time = Duration::from_hours(1); - assert_eq!(time.as_hours(), 1); - assert_eq!(time.as_nanos(), 3_600_000_000_000); - } - - #[test] - fn duration_days() { - let time = Duration::from_days(1); - assert_eq!(time.as_days(), 1); - assert_eq!(time.as_nanos(), 86_400_000_000_000); - } - - #[test] - fn duration_constructor() { - let time = Duration::from_time(1, 500_000_000); - assert_eq!(time.as_seconds(), 1); - assert_eq!(time.as_nanos(), 1_500_000_000); - } - - #[test] - fn duration_seconds_f64_conversion() { - let duration = Duration::from_seconds_f64(1.5); - assert_eq!(duration.as_nanos(), 1_500_000_000); - approx::assert_abs_diff_eq!(duration.as_seconds_f64(), 1.5); - } - - #[test] - fn duration_millis_f64_conversion() { - let duration = Duration::from_millis_f64(1.5); - assert_eq!(duration.as_nanos(), 1_500_000); - } - - #[test] - fn duration_micros_f64_conversion() { - let duration = Duration::from_micros_f64(1.5); - assert_eq!(duration.as_nanos(), 1_500); - } - - #[test] - fn duration_nanos_f64_conversion() { - let duration = Duration::from_nanos_f64(1.5); - assert_eq!(duration.as_nanos(), 1); - } - - #[rstest] - #[case::positive(Duration::from_nanos(1_400_000_000), 1, 400_000_000)] - #[case::negative(Duration::from_nanos(-1_600_000_000), -2, 400_000_000)] - // Take time in seconds since unix epoch * 100 - #[case::bignum( - Duration::from_nanos(1_760_120_080_500_000_000), - 1_760_120_080, - 500_000_000 - )] - #[case::negative_bignum(Duration::from_nanos(-1_760_120_080_500_000_000), -1_760_120_081, 500_000_000)] - fn duration_to_timeval_micros( - #[case] duration: Duration, - #[case] tv_sec: i64, - #[case] tv_usec_nanos: i64, - ) { - let tv = duration.to_timeval_nanos(); - assert_eq!(tv.tv_sec, tv_sec); - assert_eq!(tv.tv_usec, tv_usec_nanos); - } -} diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 1687f97..51b0abd 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -18,7 +18,6 @@ const FREQUENCY_TO_TIMEX_SCALE: f64 = (1 << 16) as f64; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Tsc; -impl crate::private::Sealed for Tsc {} impl super::inner::Type for Tsc {} /// Abstract representation of a time stamp counter. diff --git a/clock-bound/src/lib.rs b/clock-bound/src/lib.rs index caf1d76..2af26fd 100644 --- a/clock-bound/src/lib.rs +++ b/clock-bound/src/lib.rs @@ -9,10 +9,3 @@ pub mod vmclock; #[cfg(feature = "daemon")] pub mod daemon; - -#[cfg(feature = "daemon")] // can open this up if we ever use this in the client -mod private { - // define a crate sealed trait - // https://rust-lang.github.io/api-guidelines/future-proofing.html#sealed-traits-protect-against-downstream-implementations-c-sealed - pub trait Sealed {} -} From a6712eef39673505c8eee99a80851a1ce3e76681 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 21 Oct 2025 13:55:28 -0400 Subject: [PATCH 033/177] Add clockbound binary. (#44) Gives a place to have sanity checking code in the final binary as well as gives an integration point for other components. --- clock-bound/Cargo.toml | 6 ++ clock-bound/src/bin/clockbound.rs | 9 +++ clock-bound/src/daemon.rs | 57 +++++++++++++++++++ .../src/daemon/clock_sync_algorithm.rs | 4 +- .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 6 +- 5 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 clock-bound/src/bin/clockbound.rs diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 875ee77..030139e 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -29,6 +29,7 @@ tokio = { version = "1.47.1", features = [ "net", "macros", "rt", + "rt-multi-thread", "sync", "time", ], optional = true } @@ -52,5 +53,10 @@ daemon = [ "dep:bytes", "dep:nom", "dep:thiserror", + "tracing-subscriber/env-filter", ] default = ["client", "daemon"] + +[[bin]] +name = "clockbound" +required-features = ["daemon"] diff --git a/clock-bound/src/bin/clockbound.rs b/clock-bound/src/bin/clockbound.rs new file mode 100644 index 0000000..4e06bdf --- /dev/null +++ b/clock-bound/src/bin/clockbound.rs @@ -0,0 +1,9 @@ +//! ClockBound daemon +use clock_bound::daemon::Daemon; + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() { + tracing_subscriber::fmt::init(); + let mut d = Daemon::construct(); + tokio::spawn(async move { d.run().await }).await.unwrap(); +} diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 503bd76..b08b99a 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -13,3 +13,60 @@ pub mod clock_sync_algorithm; pub mod time; pub mod event; + +use crate::daemon::clock_sync_algorithm::ClockSyncAlgorithm; +use tokio::sync::mpsc; + +pub struct Daemon { + _io_front_end: io::SourceIO, + clock_sync_algorithm: ClockSyncAlgorithm, + link_local_receiver: mpsc::Receiver, +} + +impl Daemon { + /// Construct and initialize a new daemon + pub fn construct() -> Self { + let (tx, rx) = mpsc::channel(2); + + let mut io_front_end = io::SourceIO::construct(); + + let clock_sync_algorithm = ClockSyncAlgorithm::builder() + .link_local(clock_sync_algorithm::source::LinkLocal::new()) + .build(); + + // FIXME, we are basically starting the application in the constructor + // We should be able to construct the link local and spawn it when `run` is called + // + // Also, this function doesn't return anything? + // + // Ideally we would construct the `io::LinkLocal` and just build the `io_front_end` with something like + // ``` + // let (tx, rx) = mpsc::channel(1); + // let link_local = io::LinkLocal::construct(rx, ..Args); + // let io_front_end = SourceIo::builder().link_local(tx, link_local).build(); + // ``` + io_front_end.create_link_local(tx); + + Self { + _io_front_end: io_front_end, + clock_sync_algorithm, + link_local_receiver: rx, + } + } + + /// Run the daemon + pub async fn run(&mut self) { + loop { + // TODO: add live migration watch and statements + tokio::select! { + Some(event) = self.link_local_receiver.recv() => { + self.handle_event(event); + } + } + } + } + + fn handle_event(&mut self, event: event::Ntp) { + self.clock_sync_algorithm.feed_link_local(event); + } +} diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 617d110..5c2dd68 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -29,7 +29,9 @@ pub struct ClockSyncAlgorithm { } impl ClockSyncAlgorithm { - fn feed_link_local(&mut self, event: event::Ntp) -> Option { + /// Feed event into the link local + /// TODO: make this function private and call into it from `fn feed` when we have a routable event + pub fn feed_link_local(&mut self, event: event::Ntp) -> Option { self.link_local.feed(event) } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index d5bae58..4f8ba00 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -37,8 +37,10 @@ impl Ntp { /// Feed an event into this algorithm /// /// Returns [`Some`] if the event has improved this source's [`ClockParameters`]. - pub fn feed(&mut self, _event: event::Ntp) -> Option { - todo!() + #[expect(clippy::needless_pass_by_value, reason = "impl will remove this lint")] + pub fn feed(&mut self, event: event::Ntp) -> Option { + tracing::debug!(?event, "feed"); + None } /// Get the current [`ClockParameters`] From 02027472e84af778fae0eb0696affaf8304a6cdb Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 21 Oct 2025 16:03:30 -0400 Subject: [PATCH 034/177] [ff] implement local buffer (#45) 3 issues will be handled in follow-up: - Push all, guard bad values on calculation - stray impl block - comment on local.rs EPOCH usage --- .../clock_sync_algorithm/ff/event_buffer.rs | 35 +++ .../ff/event_buffer/local.rs | 266 +++++++++++++++++- .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 4 +- .../clock_sync_algorithm/ring_buffer.rs | 258 ++++++++++++++++- .../clock_sync_algorithm/source/link_local.rs | 15 +- clock-bound/src/daemon/time/inner.rs | 3 + clock-bound/src/daemon/time/tsc.rs | 54 ++++ 7 files changed, 626 insertions(+), 9 deletions(-) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs index 2de9e89..7e5b3af 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs @@ -5,3 +5,38 @@ pub use local::Local; mod estimate; pub use estimate::Estimate; + +#[cfg(test)] +pub(crate) mod test_assets { + use crate::daemon::{event::TscRtt, time::TscCount}; + + // Helper struct to implement TscRtt for testing + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct TestEvent { + pub tsc_pre: u64, + pub tsc_post: u64, + } + + impl TscRtt for TestEvent { + fn tsc_post(&self) -> TscCount { + TscCount::new(self.tsc_post as i128) + } + + fn tsc_pre(&self) -> TscCount { + TscCount::new(self.tsc_pre as i128) + } + } + + impl TestEvent { + pub fn new(tsc_pre: u64, tsc_post: u64) -> Self { + Self { tsc_pre, tsc_post } + } + + pub fn pre_and_rtt(tsc_pre: u64, rtt: u64) -> Self { + Self { + tsc_pre, + tsc_post: tsc_pre + rtt, + } + } + } +} diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs index 53c8a4e..c9ecc7a 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs @@ -1,11 +1,271 @@ //! Local event buffer +use std::{fmt::Debug, num::NonZeroUsize}; -use std::marker::PhantomData; +use crate::daemon::{ + clock_sync_algorithm::RingBuffer, + event::TscRtt, + time::{Duration, Instant, TscCount, TscDiff, tsc::Period}, +}; /// A local buffer of events /// -/// TODO implement +/// This stores (as a maximum) the last 1024 seconds of clock sync events to stay within the +/// simple skew model (SKM) window. +/// +/// The capacity of the ring buffer is configurable, as different reference clock sources may +/// have differing requirements. +/// +/// ## Feeding data +/// The Local event buffer should have data fed in 2 different ways based on available information. +/// 1. If the TSC period is unknown, simply [`Self::feed`] in the data +/// 2. If the TSC period has an estimate, then `feed` and then [`Self::retain_high_quality_events`] to purge old values +/// +/// ## Starvation +/// The Local event buffer currently has a simple rejection strategy. Have a multiplier. When attempting to feed +/// in a new event, if the `RTT(event) >= multiplier * min_rtt`, we reject the sample. We can increase complexity +/// of this with time and exploration of data samples. +/// +/// However, this rule **can** cause starvation if the RTT is extremely noisy. That's why the threshold can be tuned +/// or even dynamically modified. Changes subject to experimentation. #[derive(Debug, Clone)] pub struct Local { - _inner: PhantomData, + /// The inner storage + inner: RingBuffer, + + /// Rejection threshold when feeding events + /// + /// If a value is less than the `min_rtt * rtt_threshold_multiplier`, + /// then we don't add it + rtt_threshold_multiplier: usize, +} + +impl Local { + /// The simple skew model window length + /// + /// This is the period of time where a CPUs offset is generally approximated + /// by a linear model + pub const SKM_WINDOW: Duration = Duration::from_secs(1024); + + /// Creates a new ring buffer + pub fn new(capacity: NonZeroUsize) -> Self { + Self { + inner: RingBuffer::new(capacity), + rtt_threshold_multiplier: 5, + } + } + + /// Construct with a `rtt_threshold` multiplier + pub fn with_rtt_threshold_multiplier( + capacity: NonZeroUsize, + rtt_threshold_multiplier: usize, + ) -> Self { + Self { + inner: RingBuffer::new(capacity), + rtt_threshold_multiplier, + } + } + + /// Returns an iterator of events from oldest to newest + pub fn iter(&self) -> impl DoubleEndedIterator { + self.inner.iter() + } + + /// Returns true if empty + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +impl Local { + /// Feed the ring buffer with a new event + /// + /// # Errors + /// - the event is older than events already in the buffer + /// - the event has too large of an rtt + pub fn feed(&mut self, event: T) -> Result<(), FeedError> { + let rtt = event.rtt(); + let Some(min_rtt_event) = self.inner.min_rtt() else { + // we empty + self.inner.push(event); + return Ok(()); + }; + + let threshold = min_rtt_event.rtt() * self.rtt_threshold_multiplier; + if rtt > threshold { + return Err(FeedError::RttExceedsThreshold { + max_diff: threshold, + event, + }); + } + self.push_if_newer(event) + } + + /// Adds a new event to the ring buffer + /// + /// Returns true if the event was added, false if it was rejected. + /// The value could be rejected if it is older (via TSC checks) than previous values. + fn push_if_newer(&mut self, event: T) -> Result<(), FeedError> { + if let Some(last) = self.inner.head() + && event.tsc_post() <= last.tsc_post() + { + return Err(FeedError::Old { + latest_tsc: last.tsc_post(), + event, + }); + } + + self.inner.push(event); + Ok(()) + } +} + +impl Local { + /// Retain high quality events in the SKM window + /// + /// This will delete all values that either: + /// - are older than the SKM window + /// - has an RTT too large + /// + /// Requires a period measurement to convert the TSC values to time durations. + /// + /// # Starvation warning + /// This will clear out anything older than the cutoff. This CAN leave the ring buffer EMPTY + #[expect(clippy::missing_panics_doc, reason = "min_rtt has an element")] + pub fn retain_high_quality_events(&mut self, period: Period, now_post_tsc: TscCount) { + if self.is_empty() { + return; + } + let max_rtt = self.inner.min_rtt().unwrap().rtt() * self.rtt_threshold_multiplier; + + let newest_time = now_post_tsc.uncorrected_time(period, Instant::UNIX_EPOCH); + + if newest_time < (Instant::EPOCH + Self::SKM_WINDOW) { + self.inner.expunge_old_and_delayed(TscCount::EPOCH, max_rtt); + } else { + let cutoff = newest_time - Self::SKM_WINDOW; + let cutoff_tsc = TscCount::from_uncorrected_time(cutoff, period, Instant::UNIX_EPOCH); + + self.inner.expunge_old_and_delayed(cutoff_tsc, max_rtt); + } + } +} + +impl AsRef> for Local { + fn as_ref(&self) -> &RingBuffer { + &self.inner + } +} + +#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +pub enum FeedError { + #[error( + "event is older than latest event. Buffer's latest event TSC {latest_tsc:?}, event: {event:?}" + )] + Old { latest_tsc: TscCount, event: T }, + #[error("event has too large of an rtt. Threshold rtt {max_diff:?}, event: {event:?}")] + RttExceedsThreshold { max_diff: TscDiff, event: T }, +} + +#[cfg(test)] +mod tests { + use super::super::test_assets::TestEvent; + use super::*; + use std::num::NonZeroUsize; + + #[test] + fn new_buffer() { + let buffer: Local = Local::new(NonZeroUsize::new(5).unwrap()); + assert!(buffer.as_ref().is_empty()) + } + + #[test] + fn feed_empty_buffer() { + let mut buffer: Local = Local::new(NonZeroUsize::new(5).unwrap()); + let event = TestEvent::new(5, 10); + + assert!(buffer.feed(event).is_ok()); + assert_eq!(buffer.iter().count(), 1); + } + + #[test] + fn feed_rtt_threshold() { + let mut buffer: Local = Local::new(NonZeroUsize::new(5).unwrap()); + + // First event with small RTT + let event1 = TestEvent::new(10, 20); // rtt is 10 + assert!(buffer.feed(event1).is_ok()); + + // Second event with RTT exceeding threshold (10 * 5 = 50) + let event2 = TestEvent::new(30, 81); + let err = buffer.feed(event2).unwrap_err(); + assert!(matches!(err, FeedError::RttExceedsThreshold { .. })); + } + + #[test] + fn push_stale() { + let mut buffer: Local = Local::new(NonZeroUsize::new(5).unwrap()); + + // Add first event + let event1 = TestEvent::new(100, 110); + buffer.feed(event1).unwrap(); + + // Try to add older event + let event2 = TestEvent::new(50, 60); + let err = buffer.feed(event2).unwrap_err(); + assert!(matches!(err, FeedError::Old { .. })); + } + + #[test] + fn push_newer() { + let mut buffer: Local = Local::new(NonZeroUsize::new(5).unwrap()); + + // Add first event + let event1 = TestEvent::new(100, 110); + buffer.feed(event1).unwrap(); + + // Add newer event + let event3 = TestEvent::new(150, 180); + buffer.feed(event3).unwrap(); + } + + #[test] + fn retain_high_quality_events() { + let mut buffer: Local = Local::new(NonZeroUsize::new(5).unwrap()); + let period = Period::from_seconds(1e-9); // 1 ns per tick + + // Add events spanning more than 1024 seconds + let events = vec![ + TestEvent::new(0, 10), + TestEvent::new(500_000_000_000, 500_000_000_000 + 10), // 500 seconds + TestEvent::new(1_500_000_000_000, 1_500_000_000_000 + 10), // 1500 seconds + ]; + + for event in events { + buffer.feed(event).unwrap(); + } + + buffer.retain_high_quality_events(period, TscCount::new(1_500_000_000_000)); + + // Should only retain events within last 1024 seconds + for event in buffer.iter() { + assert!(event.tsc_post().get() >= 500_000_000_000); + } + } + + #[test] + fn buffer_capacity() { + let mut buffer: Local = Local::new(NonZeroUsize::new(3).unwrap()); + + // Add more events than capacity + for i in 0..5 { + let event = TestEvent::new(i * 100, i * 100 + 10); + assert!(buffer.feed(event).is_ok()); + } + + // Buffer should only contain last 3 events + assert_eq!(buffer.iter().count(), 3); + for (i, event) in buffer.iter().enumerate() { + assert_eq!(event.tsc_pre().get(), ((i + 2) * 100) as i128); + } + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 4f8ba00..9293960 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -8,7 +8,7 @@ use crate::daemon::{clock_parameters::ClockParameters, event, time::tsc::Period} /// Feed forward time synchronization algorithm for a single NTP source #[derive(Debug, Clone)] pub struct Ntp { - /// Events within the current SKM (within 1000 seconds) + /// Events within the current SKM (within 1024 seconds) local: event_buffer::Local, /// Best RTT values of each SKM over the last week estimate: event_buffer::Estimate, @@ -23,7 +23,7 @@ impl Ntp { /// /// `local_capacity` should be the number of data-points to span an SKM window. /// For example, if the source is expected to sample once every second, the `local_capacity` - /// should have a max value of 1000. + /// should have a max value of 1024. pub fn new(_local_capacity: NonZeroUsize) -> Self { todo!() // Self { diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs index 9de88a8..42fd5dc 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs @@ -2,6 +2,11 @@ use std::{collections::VecDeque, fmt::Debug, num::NonZeroUsize}; +use crate::daemon::{ + event::TscRtt, + time::{TscCount, TscDiff}, +}; + /// A fixed-size ring buffer /// /// This is largely a wrapper around `VecDeque` while adding protections @@ -89,16 +94,106 @@ impl RingBuffer { /// iterate from the tail to the head (oldest to newest) /// /// Use `rev` on the iterator if you want to search the other way - pub fn iter(&self) -> impl Iterator { + pub fn iter(&self) -> impl DoubleEndedIterator { self.buffer.iter() } } +impl RingBuffer { + /// Return the value with the lowest rtt in the `ring_buffer` + pub fn min_rtt(&self) -> Option<&T> { + self.buffer.iter().min_by_key(|v| v.rtt()) + } + + /// Return the value with the lowest rtt in the specified quarter of the ring buffer. + /// + /// This returns `Some` as long as the buffer is not empty. + #[expect(clippy::missing_panics_doc, reason = "unwraps have checks")] + pub fn min_rtt_in_quarter(&self, quarter: Quarter) -> Option<&T> { + if self.is_empty() { + return None; + } + + // Identify the element range + let (start_idx, end_idx) = match quarter { + Quarter::Oldest => { + let start_idx = 0; + // unwraps okay. buffer isn't empty + let start_pre_tsc = self.tail().unwrap().tsc_pre(); + let end_pre_tsc = self.head().unwrap().tsc_pre(); + let end_pre_tsc = start_pre_tsc + (end_pre_tsc - start_pre_tsc) / 4; + let mut end_idx = 0; + for event in self.iter() { + if event.tsc_pre() > end_pre_tsc { + break; + } + end_idx += 1; + } + (start_idx, end_idx) + } + Quarter::Newest => { + let end_idx = self.len(); + // unwraps okay. buffer isn't empty + let start_pre_tsc = self.tail().unwrap().tsc_pre(); + let end_pre_tsc = self.head().unwrap().tsc_pre(); + let start_pre_tsc = end_pre_tsc - (end_pre_tsc - start_pre_tsc) / 4; + let mut start_idx = self.len(); + for event in self.iter().rev() { + if event.tsc_pre() < start_pre_tsc { + break; + } + start_idx -= 1; + } + (start_idx, end_idx) + } + }; + + // TODO: this is a second iteration of the buffer. Potential optimization is to find min on first pass + self.iter() + .skip(start_idx) + .take(end_idx - start_idx) + .min_by_key(|v| v.rtt()) + } +} + +impl RingBuffer { + /// Purge undesired values + /// + /// Removes values from the buffers that: + /// - Are before a certain TSC + /// - are greater than a specified rtt + pub fn expunge_old_and_delayed(&mut self, before: TscCount, max_rtt: TscDiff) { + self.buffer.retain(|event| { + // Now, I would normally like to NOT have any logging in a low level function like this + // But I don't have a way of returning the dropped values without allocating. + // + // Logging at a debug level + if event.tsc_post() >= before && event.rtt() <= max_rtt { + true + } else { + tracing::debug!(?event, "Purging stale event"); + false + } + }); + } +} + +/// Specify the quarter in [`RingBuffer::min_rtt_in_quarter`] to use +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Quarter { + Oldest, + Newest, +} + #[cfg(test)] mod tests { use super::*; use rstest::rstest; + use crate::daemon::{ + clock_sync_algorithm::ff::event_buffer::test_assets::TestEvent, time::TscDiff, + }; + #[test] fn new_buffer() { let buffer: RingBuffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); @@ -219,4 +314,165 @@ mod tests { assert_eq!(buffer.pop(), Some(3)); assert_eq!(buffer.pop(), None); } + + #[test] + fn min_rtt() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + + let events = vec![ + TestEvent::pre_and_rtt(100, 30), + TestEvent::pre_and_rtt(200, 10), + TestEvent::pre_and_rtt(300, 20), + ]; + + for event in events { + buffer.push(event); + } + + let min_rtt = buffer.min_rtt().unwrap(); + assert_eq!(min_rtt.rtt(), TscDiff::new(10)); + } + + #[test] + fn min_rtt_in_quarter_newest() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(4).unwrap()); + + let events = vec![ + TestEvent::pre_and_rtt(100, 40), + TestEvent::pre_and_rtt(200, 20), + TestEvent::pre_and_rtt(300, 30), + TestEvent::pre_and_rtt(400, 50), + ]; + + for event in events { + buffer.push(event); + } + + let min_rtt = buffer.min_rtt_in_quarter(Quarter::Newest).unwrap(); + assert_eq!(min_rtt.rtt(), TscDiff::new(50)); + } + + #[test] + fn min_rtt_in_quarter_oldest() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(4).unwrap()); + + let events = vec![ + TestEvent::pre_and_rtt(100, 60), + TestEvent::pre_and_rtt(200, 20), + TestEvent::pre_and_rtt(300, 30), + TestEvent::pre_and_rtt(400, 40), + ]; + + for event in events { + buffer.push(event); + } + + let min_rtt = buffer.min_rtt_in_quarter(Quarter::Oldest).unwrap(); + assert_eq!(min_rtt.rtt(), TscDiff::new(60)); + } + + #[test] + fn min_rtt_in_quarter_2_values() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(4).unwrap()); + + let events = vec![ + TestEvent::pre_and_rtt(100, 60), + TestEvent::pre_and_rtt(200, 20), + ]; + + for event in events { + buffer.push(event); + } + + let min_rtt = buffer.min_rtt_in_quarter(Quarter::Oldest).unwrap(); + assert_eq!(min_rtt.rtt(), TscDiff::new(60)); + + let min_rtt = buffer.min_rtt_in_quarter(Quarter::Newest).unwrap(); + assert_eq!(min_rtt.rtt(), TscDiff::new(20)); + } + + #[test] + fn purge_earlier_than() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + + let events = vec![ + TestEvent::pre_and_rtt(100, 10), + TestEvent::pre_and_rtt(200, 20), + TestEvent::pre_and_rtt(300, 30), + ]; + + for event in events { + buffer.push(event); + } + + buffer.expunge_old_and_delayed(TscCount::new(250), TscDiff::new(30)); + assert_eq!(buffer.len(), 1); + assert_eq!(buffer.head().unwrap().tsc_post(), TscCount::new(330)); + } + + #[test] + fn empty_buffer_operations() { + let mut buffer: RingBuffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + + assert_eq!(buffer.min_rtt(), None); + assert_eq!(buffer.min_rtt_in_quarter(Quarter::Newest), None); + assert_eq!(buffer.min_rtt_in_quarter(Quarter::Oldest), None); + + // Purging an empty buffer should not panic + buffer.expunge_old_and_delayed(TscCount::new(100), TscDiff::new(50)); + assert!(buffer.is_empty()); + } + + #[test] + fn expunge_large_rtt() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); + + let events = vec![ + TestEvent::pre_and_rtt(100, 10), + TestEvent::pre_and_rtt(200, 20), + TestEvent::pre_and_rtt(300, 30), + ]; + + for event in events { + buffer.push(event); + } + assert_eq!(buffer.len(), 3); + buffer.expunge_old_and_delayed(TscCount::new(50), TscDiff::new(9)); + assert_eq!(buffer.len(), 0); + } + + #[test] + fn single_element_buffer() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(1).unwrap()); + + let event = TestEvent::pre_and_rtt(100, 10); + buffer.push(event); + + assert!(buffer.is_full()); + assert_eq!(buffer.len(), 1); + + // Both quarters should return the same element + let newest = buffer.min_rtt_in_quarter(Quarter::Newest).unwrap().rtt(); + let oldest = buffer.min_rtt_in_quarter(Quarter::Oldest).unwrap().rtt(); + assert_eq!(newest, oldest); + } + + #[test] + fn overflow_behavior() { + let mut buffer = RingBuffer::new(NonZeroUsize::new(2).unwrap()); + + let events = vec![ + TestEvent::pre_and_rtt(100, 10), + TestEvent::pre_and_rtt(200, 20), + TestEvent::pre_and_rtt(300, 30), + ]; + + for event in events { + buffer.push(event); + } + + assert_eq!(buffer.len(), 2); + assert_eq!(buffer.tail().unwrap().tsc_pre(), TscCount::new(200)); + assert_eq!(buffer.head().unwrap().tsc_pre(), TscCount::new(300)); + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs index 67e8a68..4891127 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs @@ -3,8 +3,9 @@ use std::num::NonZeroUsize; use crate::daemon::clock_parameters::ClockParameters; -use crate::daemon::clock_sync_algorithm::ff; +use crate::daemon::clock_sync_algorithm::ff::{self, event_buffer}; use crate::daemon::event; +use crate::daemon::time::Duration; /// A Link Local reference clock source /// @@ -15,8 +16,16 @@ pub struct LinkLocal { } impl LinkLocal { - // Poll every 2 seconds. Capacity is 1000 / 2 = 500 - const CAPACITY: NonZeroUsize = NonZeroUsize::new(500).unwrap(); + const POLL_INTERVAL: Duration = Duration::from_secs(2); + + // Poll every 2 seconds. Capacity is 1024 / 2 = 512 + const CAPACITY: NonZeroUsize = { + let capacity = + event_buffer::Local::<()>::SKM_WINDOW.as_seconds() / Self::POLL_INTERVAL.as_seconds(); + assert!(capacity > 0); + #[expect(clippy::cast_sign_loss)] + NonZeroUsize::new(capacity as usize).unwrap() + }; /// Create a new Link Local reference clock source pub fn new() -> Self { diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 2a056fd..c702b90 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -48,6 +48,9 @@ impl From> for i128 { // type guarded to be sealed construction impl Time { + /// Zero valued epoch + pub const EPOCH: Self = Self::new(0); + /// constructor pub const fn new(instant: i128) -> Self { Self { diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 51b0abd..cdfd53b 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -2,6 +2,8 @@ #![expect(clippy::cast_possible_truncation)] #![expect(clippy::cast_precision_loss)] +use crate::daemon::time::Instant; + use super::Duration; use super::inner::{Diff, Time}; use std::ops::Neg; @@ -35,6 +37,35 @@ pub type TscCount = Time; /// Corresponding duration type for [`TscCount`] pub type TscDiff = Diff; +impl TscCount { + /// Get an uncorrected time value + /// + /// `Cu(t) = TSC(t) * p + K`, where + /// - `Cu(t)` is the uncorrected time value, and return value + /// - `TSC(t)` is the raw TSC value, aka self + /// - `p` is the period of the TSC + /// - `K` is the "epoch" of the TSC. Aka the UTC time at TSC(0) + /// + /// # Precision loss + /// Precision loss can occur if Self is `> 1e15`. On modern processors, this is a year of runtime + pub fn uncorrected_time(self, p: Period, k: Instant) -> Instant { + k + Duration::from_seconds_f64(p.get() * self.get() as f64) + } + + /// Calculate tick count from an uncorrected clock + /// + /// `Cu(t) = TSC(t) * p + K`, therefore + /// + /// `TSC(t) = (Cu(t) - K) / p` + /// + /// See [`TscCount::uncorrected_time`] for variable definitions + pub fn from_uncorrected_time(t: Instant, p: Period, k: Instant) -> Self { + let diff = t - k; + let ticks = (diff.as_seconds_f64() / p.get()).round() as i128; + Self::new(ticks) + } +} + /// A frequency in Hz /// /// ## Note on lossy-ness @@ -455,4 +486,27 @@ mod tests { let period = Period::from_seconds(1e-9); assert_eq!(period.to_string(), "1e-9s"); } + + #[test] + fn uncorrected_time() { + let tsc = TscCount::new(1_000_000_000); + let p = Period::from_seconds(1.0e-9); + let k = Instant::from_days(365); + + let uncorrected = tsc.uncorrected_time(p, k); + assert_eq!( + uncorrected, + Instant::from_days(365) + Duration::from_secs(1) + ); + } + + #[test] + fn from_uncorrected_time() { + let p = Period::from_seconds(1.0e-9); + let k = Instant::from_days(365); + + let uncorrected = Instant::from_days(365) + Duration::from_secs(1); + let tsc = TscCount::from_uncorrected_time(uncorrected, p, k); + assert_eq!(tsc.get(), 1_000_000_000); + } } From 8d86cbf47ec0b39d210595c0c3004bf80b3f4f56 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 21 Oct 2025 16:26:53 -0400 Subject: [PATCH 035/177] [ff-tester] add events module (#43) * [ff-tester] add events module * Revision: Clean up docs and normalize on tsc_pre|post --------- Co-authored-by: Mohammed Kabir Co-authored-by: Thoth Gunter --- Cargo.lock | 1 + clock-bound-ff-tester/Cargo.toml | 3 +- clock-bound-ff-tester/src/events.rs | 49 ++++++++ clock-bound-ff-tester/src/events/v1.rs | 88 ++++++++++++++ clock-bound-ff-tester/src/events/v1/ntp.rs | 75 ++++++++++++ .../src/events/v1/oscillator.rs | 113 ++++++++++++++++++ clock-bound-ff-tester/src/events/v1/phc.rs | 59 +++++++++ .../src/events/v1/vmclock.rs | 30 +++++ clock-bound-ff-tester/src/lib.rs | 2 + 9 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 clock-bound-ff-tester/src/events.rs create mode 100644 clock-bound-ff-tester/src/events/v1.rs create mode 100644 clock-bound-ff-tester/src/events/v1/ntp.rs create mode 100644 clock-bound-ff-tester/src/events/v1/oscillator.rs create mode 100644 clock-bound-ff-tester/src/events/v1/phc.rs create mode 100644 clock-bound-ff-tester/src/events/v1/vmclock.rs diff --git a/Cargo.lock b/Cargo.lock index f1cff57..9dd4c01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -284,6 +284,7 @@ name = "clock-bound-ff-tester" version = "2.0.3" dependencies = [ "approx", + "bon", "clap", "clock-bound", "rstest 0.25.0", diff --git a/clock-bound-ff-tester/Cargo.toml b/clock-bound-ff-tester/Cargo.toml index efb256b..bad08c3 100644 --- a/clock-bound-ff-tester/Cargo.toml +++ b/clock-bound-ff-tester/Cargo.toml @@ -13,12 +13,13 @@ repository.workspace = true version.workspace = true [dependencies] +bon = "3.8.1" clap = { version = "4.5", features = ["derive"] } clock-bound = { path = "../clock-bound", features = ["daemon"] } serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.145" thiserror = { version = "2.0" } [dev-dependencies] approx = "0.5" rstest = "0.25" -serde_json = "1.0.145" diff --git a/clock-bound-ff-tester/src/events.rs b/clock-bound-ff-tester/src/events.rs new file mode 100644 index 0000000..36b47c6 --- /dev/null +++ b/clock-bound-ff-tester/src/events.rs @@ -0,0 +1,49 @@ +//! Events used by `ff-tester`. For creating simulations and for capturing real events. +//! +//! These structs are serializable and versioned to allow for easy breaking changes while continuing to support older captures when desired. +//! +//! The focus for this module is on serialization only. While it may hold a large number of types that may look tempting to implement +//! mathematical functions on, it's best to put that logic into a separate crate that is focused only on that. Rationale is that +//! adding fields to `serde` data types constitutes a potentially breaking change, whereas we would want to not be limited when working +//! on clock transformations. + +use serde::{Deserialize, Serialize}; + +pub mod v1; + +/// The type for serializing or deserializing scenarios throughout the lifecycle of `ff-tester` +/// +/// Versioned enum to enable breaking changes and backwards compatibility +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Scenario { + /// See [`v1`] for more information about what this entails. + V1(v1::Scenario), +} + +/// Serialize using json to a writer +/// +/// # Errors +/// +/// Errors if unable to write +pub fn serialize_to_writer_pretty( + scenario: &Scenario, + writer: W, +) -> Result<(), serde_json::Error> { + serde_json::to_writer_pretty(writer, scenario) +} + +/// Deserialize using json from a reader +/// +/// # Errors +/// Returns an error if failed to deserialize +pub fn deserialize_from_reader(reader: R) -> Result { + serde_json::from_reader(reader) +} + +/// Deserialize from a json string +/// +/// # Errors +/// Returns an error if failed to deserialize +pub fn deserialize_from_str(json: &str) -> Result { + serde_json::from_str(json) +} diff --git a/clock-bound-ff-tester/src/events/v1.rs b/clock-bound-ff-tester/src/events/v1.rs new file mode 100644 index 0000000..02d5169 --- /dev/null +++ b/clock-bound-ff-tester/src/events/v1.rs @@ -0,0 +1,88 @@ +//! Version 1 of data serialized by `ff-tester` + +mod ntp; +use std::collections::HashMap; + +use crate::time::TscCount; +pub use ntp::{ClientSystemTimes as NtpClientSystemTimes, Ntp}; + +mod phc; +pub use phc::{ClientSystemTimes as PhcClientSystemTimes, Phc}; +use serde::{Deserialize, Serialize}; + +mod vmclock; +pub use vmclock::VMClock; + +mod oscillator; +pub use oscillator::{Oscillator, OscillatorOffsets}; + +/// A high level scenario +/// +/// This struct holds all time events that occurred (like NTP packet exchanges), oscillator data, as well as applicable metadata +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Scenario { + /// All time synchronization events that occurred in this scenario + pub events: Vec, + /// The oscillator used in this scenario + /// + /// Likely None if this is a real capture. But maybe one day... + pub oscillator: Option, + /// Metadata about the Scenario + /// + /// Simple key-value store + pub metadata: HashMap, +} + +/// A representation of a time synchronization event. +/// +/// An event is characterized of when it was **received** by the local system, hence the tsc timestamp. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Event { + /// The event information, changes based on the event kind + pub variants: EventKind, + /// The local TSC timestamp of when the event was sent + /// + /// Clarifications on it's meaning for different event types + /// - NTP packet: The tsc timestamp when the NTP request was sent on the local system + /// - PHC read: the tsc timestamp read **before** getting the PHC time reading + #[serde(alias = "client_raw_send_time")] + pub client_tsc_pre_time: TscCount, + /// The local TSC timestamp of when the event was received + /// + /// Clarifications on it's meaning for different event types + /// - NTP packet: The tsc timestamp when the NTP reply was received on the local system + /// - PHC read: the tsc timestamp read **after** getting the PHC time reading + /// - PPS pulse: The tsc timestamp when the pulse was received on the local system + #[serde(alias = "client_raw_recv_time")] + pub client_tsc_post_time: TscCount, +} + +/// Different possible event types and their associated data +/// +/// Used to represent the different mechanisms to synchronize time. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum EventKind { + /// NTP request-response + Ntp(Ntp), + /// PHC TSC -> PHC -> TSC event + Phc(Phc), + /// VMClock read + VMClock(VMClock), +} + +impl EventKind { + pub fn ntp(&self) -> Option<&Ntp> { + if let EventKind::Ntp(ntp) = self { + Some(ntp) + } else { + None + } + } + pub fn phc(&self) -> Option<&Phc> { + if let EventKind::Phc(phc) = self { + Some(phc) + } else { + None + } + } +} diff --git a/clock-bound-ff-tester/src/events/v1/ntp.rs b/clock-bound-ff-tester/src/events/v1/ntp.rs new file mode 100644 index 0000000..610098b --- /dev/null +++ b/clock-bound-ff-tester/src/events/v1/ntp.rs @@ -0,0 +1,75 @@ +//! An NTP event + +use crate::time::{EstimateDuration, EstimateInstant}; +use serde::{Deserialize, Serialize}; + +/// An NTP event +/// +/// Represents the NTP packet round trip as defined in RFC 5905. +/// +/// NOTE: This is NOT a perfect representation of a packet. Instants and durations in this struct +/// are stored via a count of femtoseconds. However, the NTP packet format stores in a "fixed point" +/// type which tends to only have non-whole microsecond step values. We are choosing this trade-off +/// to reduce complexity in generating events. The logic for converting into NTP-specific values +/// will be codified elsewhere. +/// +/// NOTE: Additionally, this type also allows storing smaller and larger values than the NTP packet +/// allows. Let's see if this affects us. We can always change later. +/// +/// The client's tsc timestamps, e.g., `client_tsc_pre_time` and `client_tsc_post_time`, are +/// handled separately from this struct. See [`Event`](super::Event) +/// +/// Below is an example of how this is used within a single NTP packet request-response +/// ```text +/// server +/// ───────────────2─────3──────────────────────────────► time +/// // \\ +/// // \ +/// // \\ +/// // \\ +/// // \\ client +/// ─────0───1───────────────4─────5───────────────────► time +/// +/// Where: +/// 0: `client_tsc_pre_time` - Client TSC read prior to sending an NTP request (tsc timestamp stored as `Event::client_tsc_pre_time`) +/// 1: `client_system_send_time` - Client system clock read when sending an NTP request +/// 2: `server_system_recv_time` - Server receives an NTP request and stores its recv system time +/// 3: `server_system_send_time` - Server sends an NTP response with its send system time stored +/// 4: `client_system_recv_time` - Client system clock read when receiving an NTP response +/// 5: `client_tsc_post_time` - Client TSC read after receiving an NTP response (tsc timestamp stored as `Event::client_tsc_post_time`) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Ntp { + /// The server's system time when the server receives the request + /// + /// This is the value the NTP server writes to the "Receive Timestamp" field of the NTP packet + pub server_system_recv_time: EstimateInstant, + /// The server's system time when the server sends a response + /// + /// This is the value the NTP server writes to the "Transmit Timestamp" field of the NTP packet + pub server_system_send_time: EstimateInstant, + /// The client's system timestamps within the Ntp packet exchange + /// + /// These are grouped separately as they are not critical in `ff-tester`. + /// For instance, while they are valuable in data collection, they are not generated + /// in NTP simulations + #[serde(flatten)] + pub client_system_times: Option, + /// The root delay, as the NTP server writes to the "Root Delay" field of the NTP packet + pub root_delay: EstimateDuration, + /// The root dispersion, as the NTP server writes to the "Root Dispersion" field of the NTP packet + pub root_dispersion: EstimateDuration, + /// Event source, e.g. the IP address of the NTP server + /// + /// Just has to be a unique identifier + pub source_id: String, +} + +/// The client's system timestamps within an Ntp packet exchange +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientSystemTimes { + /// The client's system time when the client sends a request + pub client_system_send_time: EstimateInstant, + /// The client's system time when the client receives the response + pub client_system_recv_time: EstimateInstant, +} diff --git a/clock-bound-ff-tester/src/events/v1/oscillator.rs b/clock-bound-ff-tester/src/events/v1/oscillator.rs new file mode 100644 index 0000000..137e138 --- /dev/null +++ b/clock-bound-ff-tester/src/events/v1/oscillator.rs @@ -0,0 +1,113 @@ +//! Struct for serializing local oscillator models +//! +//! We model oscillators via their offset from true time as it changes over time. The exact mechanism of +//! how these offsets are GENERATED is out of scope of this crate. Instead this crate focuses on the REPRESENTATION +//! of an oscillator model, and how to serialize and deserialize it. + +use crate::time::{Frequency, Series, TrueDuration, TrueInstant, TscCount}; +use serde::{Deserialize, Serialize}; + +/// The simulated clock model is a representation of the relationship of True Time to the local hardware oscillator offset (as a True Duration) +/// +/// This type enables 2 major aspects. +/// 1. It shows the offset (and skew) of the local hardware oscillator based off true time, and +/// 2. it enables conversions between tsc timestamps and true time +/// +/// ## Offsets and skews +/// The skew is the error of the clock frequency from its nominal value. The skew can be influenced by many real world phenomena, like +/// temperature, hardware age, etc. +/// +/// The offset is the present time error of a clock from the true value. +/// +/// The `time_steps` and `offset` values shows how the offset values change over time. +/// +/// The `skew` is the error in the clocks frequency which effectively leads to offsets. The `skew` is the derivative (slope) of this dataset +/// +/// ## Conversions between tsc timestamps and true time +/// +/// The data serialized enables the creation of other data-sets (without duplicating or otherwise over-constraining the data). +/// +/// A [`TscCount`] at a [`TrueInstant`] can be calculated as +/// ```text +/// tsc_timestamp[i] = start_time + ((time_steps[i] - offset[i]) * clock_frequency) + tsc_timestamp_start +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)] +pub struct Oscillator { + /// The start time of the clock model + pub system_start_time: TrueInstant, + /// The corresponding tsc timestamp to the `start_time` + /// + /// When a scenario starts, the first offset should happen at `start_time`, + /// and that will correspond to the tsc timestamp at `tsc_timestamp_start` + #[serde(alias = "raw_timestamp_start")] + pub tsc_timestamp_start: TscCount, + /// The nominal clock frequency in Hz + /// + /// This corresponds to a skew of 0 ppm in this graph. + pub clock_frequency: Frequency, + /// Data series of true duration time steps to their offsets + /// + /// The index of this data series are the simulated time step offsets. + /// The data of this data series are the offsets from true time of the local oscillator + pub oscillator_offsets: OscillatorOffsets, +} + +impl Oscillator { + /// Start Time + pub fn start_time(&self) -> TrueInstant { + self.system_start_time + } + + /// TSC Timestamp Start + pub fn tsc_timestamp_start(&self) -> TscCount { + self.tsc_timestamp_start + } + + /// Clock Frequency + pub fn clock_frequency(&self) -> Frequency { + self.clock_frequency + } + + /// The offset series + pub fn oscillator_offsets(&self) -> &OscillatorOffsets { + &self.oscillator_offsets + } +} + +/// A representation of a time series of oscillator offsets +/// +/// The X axis of this time series is the true time, +/// The Y axis shows the offset of the oscillator from true time +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OscillatorOffsets { + pub inner: Series, +} + +impl OscillatorOffsets { + /// Create a new oscillator offset series + pub fn new(inner: Series) -> Self { + Self { inner } + } + + /// Return the time series indices + pub fn indices(&self) -> &[TrueDuration] { + self.inner.indices() + } + + /// Return the offsets of the time series + pub fn offsets(&self) -> &[TrueDuration] { + self.inner.data() + } +} + +impl AsRef> for OscillatorOffsets { + fn as_ref(&self) -> &Series { + &self.inner + } +} + +impl From> for OscillatorOffsets { + fn from(inner: Series) -> Self { + Self { inner } + } +} diff --git a/clock-bound-ff-tester/src/events/v1/phc.rs b/clock-bound-ff-tester/src/events/v1/phc.rs new file mode 100644 index 0000000..943649f --- /dev/null +++ b/clock-bound-ff-tester/src/events/v1/phc.rs @@ -0,0 +1,59 @@ +//! A PTP Hardware Clock (PHC) event + +use crate::time::{EstimateDuration, EstimateInstant}; +use serde::{Deserialize, Serialize}; + +/// A PHC Event +/// +/// Represents the PHC clock synchronization event structure. +/// Where the PHC clock synchronization algorithm reads the TSC, PHC, +/// and TSC in that order before correcting the system clock. +/// +/// The client's tsc timestamps, e.g., `client_tsc_pre_time` and `client_tsc_post_time`, are +/// handled separately from this struct. See [`Event`](super::Event) +/// +/// Below is an example of how this is used within a single PHC read sequence +/// ```text +/// PHC +/// ────────────────3─────────────────────────────► time +/// // \\ +/// // \ +/// // \\ +/// // \\ +/// // \\ client +/// ─────0───1──────────4────5───────────────────► time +/// +/// Where: +/// 0: `client_tsc_pre_time` - Client TSC read prior to reading the PHC (tsc timestamp stored as `Event::client_tsc_pre_time`) +/// 1: `client_system_pre_time` - Client system clock read before reading the PHC +/// 2: `phc_time` - PHC timestamp when read by the client +/// 3: `client_system_post_time` - Client system clock read after reading the PHC +/// 4: `client_tsc_post_time` - Client TSC read post reading the PHC (tsc timestamp stored as `Event::client_tsc_post_time`) +/// ``` +/// +/// Note, that the generic PHC device has no inherent concept of clock error bound, however the EC2 PHC device exposes the +/// clock error bound through a sysfs device file. If this is supported, then PHC value can have the associated clock error bound +/// as well +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Phc { + /// The PHC timestamp when read by the client + pub phc_time: EstimateInstant, + #[serde(flatten)] + /// The client's system timestamps surrounding PHC reads + /// + /// These are grouped separately as they are not critical in `ff-tester`. + pub client_system_times: Option, + /// Clock error measurement of the reading (from a separate sysfs file, if it exists) + pub clock_error_bound: Option, + /// Event source identifier, e.g. the "ptp0" + pub source_id: String, +} + +/// The client's system timestamps surrounding PHC reads +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientSystemTimes { + /// The client's system time before the client reads the PHC + pub client_system_pre_time: EstimateInstant, + /// The client's system time after the client reads the PHC + pub client_system_post_time: EstimateInstant, +} diff --git a/clock-bound-ff-tester/src/events/v1/vmclock.rs b/clock-bound-ff-tester/src/events/v1/vmclock.rs new file mode 100644 index 0000000..0e458b7 --- /dev/null +++ b/clock-bound-ff-tester/src/events/v1/vmclock.rs @@ -0,0 +1,30 @@ +//! VMClock support + +use crate::time::{EstimateDuration, EstimateInstant, Period, TscCount}; +use serde::{Deserialize, Serialize}; + +/// A VMClock with time support +/// +/// Represents the time and clock frequency values passed from the linux hypervisor. +/// +/// For more info see: +/// +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct VMClock { + /// The TSC counter value at [`vmclock_time`](Self::vmclock_time) + pub tsc_timestamp: TscCount, + /// The clock period estimate at the time of [`tsc_timestamp`](Self::tsc_timestamp) in seconds + pub clock_period: Period, + /// The maximum error value of the [`clock_period`](Self::clock_period) value in seconds + /// + /// Can be None if VMClock does not support this value + pub clock_period_max_error: Option, + /// The timestamp (nanoseconds since epoch) + pub vmclock_time: EstimateInstant, + /// The maximum error of the [`vmclock_time`](Self::vmclock_time) value + /// + /// This value is expected to be None while `ClockBound 3.0` is in alpha + pub vmclock_time_max_error: Option, + /// A unique identifier + pub source_id: String, +} diff --git a/clock-bound-ff-tester/src/lib.rs b/clock-bound-ff-tester/src/lib.rs index 11bddb2..d12a583 100644 --- a/clock-bound-ff-tester/src/lib.rs +++ b/clock-bound-ff-tester/src/lib.rs @@ -1,3 +1,5 @@ //! Feed Forward Time sync algorithm tester pub mod time; + +pub mod events; From e99963d3a44ac43b9f1d53ff9d91cc3a37e122bd Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Wed, 22 Oct 2025 09:49:18 -0400 Subject: [PATCH 036/177] Miscellaneous fixes and corrections (#26) * Added a todo macro call to Clockdisruption branch. * Updated doc-strings and variable names specific to io (BIND_ADDRESS, channel mappings) to reduce confusion Several doc-strings in the source io component were outdated leading to confusion. The commit update them as well as updates variable names that also cause confusion. * Updated dependency chart to set optional dependencies as optional --- clock-bound/src/daemon/io.rs | 10 ++++++---- clock-bound/src/daemon/io/ntp.rs | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 475a7fc..e4d4adb 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -23,7 +23,7 @@ mod tsc; /// `SourceIO` contains the interface from which new IO tasks can be spawned, as well as an interface /// to send control commands to the specific IO tasks. pub struct SourceIO { - /// A mapping between the time source type and the task handle. + /// A mapping between the time source and the control sender. sources: HashMap>, /// Contains the channel used to communicate clock disruption events. clock_disruption_channels: ClockDisruptionChannels, @@ -58,9 +58,11 @@ impl SourceIO { }; spawn(async move { - let socket = UdpSocket::bind(ntp::BIND_ADDRESS).await.unwrap(); - let mut link_local = LinkLocal::construct(socket, communication_channels); - link_local.run().await; + let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) + .await + .unwrap(); + let mut linklocal = LinkLocal::construct(socket, communication_channels); + linklocal.run().await; }); ctrl_sender }); diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index ed4d5d1..bd193a7 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -21,7 +21,7 @@ use crate::daemon::{ pub mod packet; pub use packet::Packet; -pub const BIND_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); +pub const UNSPECIFIED_SOCKET_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); const LINK_LOCAL_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(169, 254, 169, 123), 123); const INTERVAL_DURATION: Duration = Duration::from_secs(1); const LINK_LOCAL_TIMEOUT: Duration = Duration::from_millis(100); @@ -133,6 +133,7 @@ impl LinkLocal { } _ = self.communication_channels.clock_disruption_receiver.as_mut().unwrap().changed() => { // Clock Disruption logic here + todo!("Clock disruption logic has yet to be implemented."); } } } From 7a5dba7484f18d79e83c0fe3fdcbdb077122afdb Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 22 Oct 2025 10:14:00 -0400 Subject: [PATCH 037/177] Local buffer now accepts all, and will filter during calculation. (#46) * Local buffer now accepts all, and will filter during calculation. Also fixed: - stray duplicate impl block - EPOCH logic in event_buffer::local * doc fix * Revision: Typo fix --- .../ff/event_buffer/local.rs | 71 +++++-------------- .../clock_sync_algorithm/ring_buffer.rs | 31 ++------ 2 files changed, 21 insertions(+), 81 deletions(-) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs index c9ecc7a..55872eb 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs @@ -4,7 +4,7 @@ use std::{fmt::Debug, num::NonZeroUsize}; use crate::daemon::{ clock_sync_algorithm::RingBuffer, event::TscRtt, - time::{Duration, Instant, TscCount, TscDiff, tsc::Period}, + time::{Duration, TscCount, tsc::Period}, }; /// A local buffer of events @@ -18,11 +18,11 @@ use crate::daemon::{ /// ## Feeding data /// The Local event buffer should have data fed in 2 different ways based on available information. /// 1. If the TSC period is unknown, simply [`Self::feed`] in the data -/// 2. If the TSC period has an estimate, then `feed` and then [`Self::retain_high_quality_events`] to purge old values +/// 2. If the TSC period has an estimate, then `feed` and then [`Self::expunge_old_events`] to purge old values /// /// ## Starvation -/// The Local event buffer currently has a simple rejection strategy. Have a multiplier. When attempting to feed -/// in a new event, if the `RTT(event) >= multiplier * min_rtt`, we reject the sample. We can increase complexity +/// The Local event buffer currently has a simple rejection strategy. Have a multiplier. When attempting to calculate +/// parameters from a new event, if the `RTT(event) >= multiplier * min_rtt`, we reject the sample. We can increase complexity /// of this with time and exploration of data samples. /// /// However, this rule **can** cause starvation if the RTT is extremely noisy. That's why the threshold can be tuned @@ -81,22 +81,7 @@ impl Local { /// /// # Errors /// - the event is older than events already in the buffer - /// - the event has too large of an rtt pub fn feed(&mut self, event: T) -> Result<(), FeedError> { - let rtt = event.rtt(); - let Some(min_rtt_event) = self.inner.min_rtt() else { - // we empty - self.inner.push(event); - return Ok(()); - }; - - let threshold = min_rtt_event.rtt() * self.rtt_threshold_multiplier; - if rtt > threshold { - return Err(FeedError::RttExceedsThreshold { - max_diff: threshold, - event, - }); - } self.push_if_newer(event) } @@ -117,36 +102,28 @@ impl Local { self.inner.push(event); Ok(()) } -} -impl Local { - /// Retain high quality events in the SKM window - /// - /// This will delete all values that either: - /// - are older than the SKM window - /// - has an RTT too large + /// Delete events older than the SKM Window /// /// Requires a period measurement to convert the TSC values to time durations. /// /// # Starvation warning /// This will clear out anything older than the cutoff. This CAN leave the ring buffer EMPTY - #[expect(clippy::missing_panics_doc, reason = "min_rtt has an element")] - pub fn retain_high_quality_events(&mut self, period: Period, now_post_tsc: TscCount) { + pub fn expunge_old_events(&mut self, period: Period, now_post_tsc: TscCount) { if self.is_empty() { return; } - let max_rtt = self.inner.min_rtt().unwrap().rtt() * self.rtt_threshold_multiplier; - - let newest_time = now_post_tsc.uncorrected_time(period, Instant::UNIX_EPOCH); + // We need to calculate the corresponding TSC for an SKM window ago + let cutoff_tsc = now_post_tsc - (Self::SKM_WINDOW / period); - if newest_time < (Instant::EPOCH + Self::SKM_WINDOW) { - self.inner.expunge_old_and_delayed(TscCount::EPOCH, max_rtt); - } else { - let cutoff = newest_time - Self::SKM_WINDOW; - let cutoff_tsc = TscCount::from_uncorrected_time(cutoff, period, Instant::UNIX_EPOCH); - - self.inner.expunge_old_and_delayed(cutoff_tsc, max_rtt); + // If cutoff tsc is negative, that means we booted within an SKM window seconds. + // Log and bail + if cutoff_tsc <= TscCount::EPOCH { + tracing::debug!(%period, ?now_post_tsc, "We booted less than 1 window ago. Not deleting events."); + return; } + + self.inner.expunge_old(cutoff_tsc); } } @@ -162,8 +139,6 @@ pub enum FeedError { "event is older than latest event. Buffer's latest event TSC {latest_tsc:?}, event: {event:?}" )] Old { latest_tsc: TscCount, event: T }, - #[error("event has too large of an rtt. Threshold rtt {max_diff:?}, event: {event:?}")] - RttExceedsThreshold { max_diff: TscDiff, event: T }, } #[cfg(test)] @@ -187,20 +162,6 @@ mod tests { assert_eq!(buffer.iter().count(), 1); } - #[test] - fn feed_rtt_threshold() { - let mut buffer: Local = Local::new(NonZeroUsize::new(5).unwrap()); - - // First event with small RTT - let event1 = TestEvent::new(10, 20); // rtt is 10 - assert!(buffer.feed(event1).is_ok()); - - // Second event with RTT exceeding threshold (10 * 5 = 50) - let event2 = TestEvent::new(30, 81); - let err = buffer.feed(event2).unwrap_err(); - assert!(matches!(err, FeedError::RttExceedsThreshold { .. })); - } - #[test] fn push_stale() { let mut buffer: Local = Local::new(NonZeroUsize::new(5).unwrap()); @@ -244,7 +205,7 @@ mod tests { buffer.feed(event).unwrap(); } - buffer.retain_high_quality_events(period, TscCount::new(1_500_000_000_000)); + buffer.expunge_old_events(period, TscCount::new(1_500_000_000_000)); // Should only retain events within last 1024 seconds for event in buffer.iter() { diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs index 42fd5dc..df080cb 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs @@ -2,10 +2,7 @@ use std::{collections::VecDeque, fmt::Debug, num::NonZeroUsize}; -use crate::daemon::{ - event::TscRtt, - time::{TscCount, TscDiff}, -}; +use crate::daemon::{event::TscRtt, time::TscCount}; /// A fixed-size ring buffer /// @@ -162,13 +159,13 @@ impl RingBuffer { /// Removes values from the buffers that: /// - Are before a certain TSC /// - are greater than a specified rtt - pub fn expunge_old_and_delayed(&mut self, before: TscCount, max_rtt: TscDiff) { + pub fn expunge_old(&mut self, before: TscCount) { self.buffer.retain(|event| { // Now, I would normally like to NOT have any logging in a low level function like this // But I don't have a way of returning the dropped values without allocating. // // Logging at a debug level - if event.tsc_post() >= before && event.rtt() <= max_rtt { + if event.tsc_post() >= before { true } else { tracing::debug!(?event, "Purging stale event"); @@ -405,7 +402,7 @@ mod tests { buffer.push(event); } - buffer.expunge_old_and_delayed(TscCount::new(250), TscDiff::new(30)); + buffer.expunge_old(TscCount::new(250)); assert_eq!(buffer.len(), 1); assert_eq!(buffer.head().unwrap().tsc_post(), TscCount::new(330)); } @@ -419,28 +416,10 @@ mod tests { assert_eq!(buffer.min_rtt_in_quarter(Quarter::Oldest), None); // Purging an empty buffer should not panic - buffer.expunge_old_and_delayed(TscCount::new(100), TscDiff::new(50)); + buffer.expunge_old(TscCount::new(100)); assert!(buffer.is_empty()); } - #[test] - fn expunge_large_rtt() { - let mut buffer = RingBuffer::new(NonZeroUsize::new(3).unwrap()); - - let events = vec![ - TestEvent::pre_and_rtt(100, 10), - TestEvent::pre_and_rtt(200, 20), - TestEvent::pre_and_rtt(300, 30), - ]; - - for event in events { - buffer.push(event); - } - assert_eq!(buffer.len(), 3); - buffer.expunge_old_and_delayed(TscCount::new(50), TscDiff::new(9)); - assert_eq!(buffer.len(), 0); - } - #[test] fn single_element_buffer() { let mut buffer = RingBuffer::new(NonZeroUsize::new(1).unwrap()); From 26b4df2c7c27d4cb7c25b6871e3687c069fba5b3 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 23 Oct 2025 11:10:52 -0400 Subject: [PATCH 038/177] [ff-tester] Simulations ported over and tests passing (#47) * [ff-tester] Simulations ported over and tests passing Had to bring over string parsing logic. Feature gated. --------- Co-authored-by: Mohammed Kabir Co-authored-by: Thoth Gunter * Revision: doc cleanups from mekabir review * Revision: more doc and lint fixes --------- Co-authored-by: Mohammed Kabir Co-authored-by: Thoth Gunter --- Cargo.lock | 342 ++++- clock-bound-ff-tester/Cargo.toml | 15 +- .../src/bin/oscillator_generator.rs | 465 +++++++ .../src/bin/scenario_generator.rs | 583 +++++++++ clock-bound-ff-tester/src/lib.rs | 2 + clock-bound-ff-tester/src/simulation.rs | 14 + clock-bound-ff-tester/src/simulation/delay.rs | 97 ++ .../src/simulation/generator.rs | 94 ++ .../src/simulation/interpolation.rs | 420 ++++++ clock-bound-ff-tester/src/simulation/ntp.rs | 24 + .../ntp/dispersion_increase_linear.rs | 459 +++++++ .../src/simulation/ntp/multi_source.rs | 480 +++++++ .../src/simulation/ntp/perfect.rs | 281 ++++ .../src/simulation/ntp/round_trip_delays.rs | 356 +++++ .../src/simulation/ntp/series.rs | 1100 ++++++++++++++++ .../ntp/variable_network_delay_source.rs | 321 +++++ .../src/simulation/oscillator.rs | 1164 +++++++++++++++++ clock-bound-ff-tester/src/simulation/phc.rs | 5 + .../src/simulation/phc/round_trip_delays.rs | 247 ++++ .../simulation/phc/variable_delay_source.rs | 341 +++++ clock-bound-ff-tester/src/simulation/stats.rs | 26 + .../src/simulation/stats/gamma.rs | 115 ++ .../src/simulation/stats/normal.rs | 250 ++++ .../src/simulation/stats/string_parse.rs | 91 ++ .../src/simulation/stats/truncated.rs | 105 ++ .../src/simulation/vmclock.rs | 305 +++++ .../src/time/estimate_instant.rs | 2 +- clock-bound/Cargo.toml | 2 + clock-bound/src/daemon/time/clocks.rs | 3 +- clock-bound/src/daemon/time/inner.rs | 105 +- .../src/daemon/time/inner/string_parse.rs | 165 +++ clock-bound/src/daemon/time/timex.rs | 2 +- clock-bound/src/daemon/time/tsc.rs | 166 ++- 33 files changed, 8068 insertions(+), 79 deletions(-) create mode 100644 clock-bound-ff-tester/src/bin/oscillator_generator.rs create mode 100644 clock-bound-ff-tester/src/bin/scenario_generator.rs create mode 100644 clock-bound-ff-tester/src/simulation.rs create mode 100644 clock-bound-ff-tester/src/simulation/delay.rs create mode 100644 clock-bound-ff-tester/src/simulation/generator.rs create mode 100644 clock-bound-ff-tester/src/simulation/interpolation.rs create mode 100644 clock-bound-ff-tester/src/simulation/ntp.rs create mode 100644 clock-bound-ff-tester/src/simulation/ntp/dispersion_increase_linear.rs create mode 100644 clock-bound-ff-tester/src/simulation/ntp/multi_source.rs create mode 100644 clock-bound-ff-tester/src/simulation/ntp/perfect.rs create mode 100644 clock-bound-ff-tester/src/simulation/ntp/round_trip_delays.rs create mode 100644 clock-bound-ff-tester/src/simulation/ntp/series.rs create mode 100644 clock-bound-ff-tester/src/simulation/ntp/variable_network_delay_source.rs create mode 100644 clock-bound-ff-tester/src/simulation/oscillator.rs create mode 100644 clock-bound-ff-tester/src/simulation/phc.rs create mode 100644 clock-bound-ff-tester/src/simulation/phc/round_trip_delays.rs create mode 100644 clock-bound-ff-tester/src/simulation/phc/variable_delay_source.rs create mode 100644 clock-bound-ff-tester/src/simulation/stats.rs create mode 100644 clock-bound-ff-tester/src/simulation/stats/gamma.rs create mode 100644 clock-bound-ff-tester/src/simulation/stats/normal.rs create mode 100644 clock-bound-ff-tester/src/simulation/stats/string_parse.rs create mode 100644 clock-bound-ff-tester/src/simulation/stats/truncated.rs create mode 100644 clock-bound-ff-tester/src/simulation/vmclock.rs create mode 100644 clock-bound/src/daemon/time/inner/string_parse.rs diff --git a/Cargo.lock b/Cargo.lock index 9dd4c01..9cb3c25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[package]] name = "byteorder" version = "1.5.0" @@ -263,7 +269,7 @@ dependencies = [ "rstest 0.26.1", "serde", "tempfile", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "tracing-subscriber", @@ -283,14 +289,23 @@ dependencies = [ name = "clock-bound-ff-tester" version = "2.0.3" dependencies = [ + "anyhow", "approx", "bon", "clap", "clock-bound", + "mockall", + "nalgebra", + "num-traits", + "rand", + "rand_chacha", "rstest 0.25.0", "serde", "serde_json", - "thiserror", + "statrs", + "tempfile", + "thiserror 2.0.17", + "varpro", ] [[package]] @@ -372,6 +387,12 @@ dependencies = [ "syn", ] +[[package]] +name = "distrs" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3b3318a6ce94bae6f71c71dfbb5c91059ea2afa3c2ac86d8fb9b1f6ea5de83" + [[package]] name = "downcast" version = "0.11.0" @@ -463,14 +484,25 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.3" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", ] [[package]] @@ -535,9 +567,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", "hashbrown", @@ -582,12 +614,30 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "levenberg-marquardt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384ced02a6ac2eb8879d8bd86b46b88e133fa09ffe60d8307263b013ee3ceff2" +dependencies = [ + "cfg-if", + "nalgebra", + "num-traits", + "rustc_version", +] + [[package]] name = "libc" version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "link-local" version = "2.0.3" @@ -618,6 +668,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.7.6" @@ -649,7 +709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.59.0", ] @@ -679,6 +739,35 @@ dependencies = [ "syn", ] +[[package]] +name = "nalgebra" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26aecdf64b707efd1310e3544d709c5c0ac61c13756046aaaba41be5c4f66a3b" +dependencies = [ + "approx", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "rand", + "rand_distr", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "nix" version = "0.26.4" @@ -710,6 +799,45 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -717,6 +845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -740,6 +869,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -752,6 +887,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.3" @@ -821,11 +965,57 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "regex" -version = "1.11.3" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -835,9 +1025,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -955,6 +1145,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "semver" version = "1.0.27" @@ -1019,6 +1218,19 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "slab" version = "0.4.11" @@ -1041,6 +1253,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "nalgebra", + "num-traits", + "rand", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1065,7 +1289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1077,13 +1301,33 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1136,18 +1380,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.6" +version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ "indexmap", "toml_datetime", @@ -1157,9 +1401,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow", ] @@ -1238,6 +1482,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -1256,6 +1506,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "varpro" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6c9b6e39df7efd76d02809389db80f63f1e81b6326759db04a9620c6264f24a" +dependencies = [ + "distrs", + "levenberg-marquardt", + "nalgebra", + "num-traits", + "thiserror 1.0.69", +] + [[package]] name = "vmclock-updater" version = "2.0.3" @@ -1276,15 +1539,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -1353,6 +1607,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1591,3 +1855,23 @@ name = "wit-bindgen" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/clock-bound-ff-tester/Cargo.toml b/clock-bound-ff-tester/Cargo.toml index bad08c3..69370a3 100644 --- a/clock-bound-ff-tester/Cargo.toml +++ b/clock-bound-ff-tester/Cargo.toml @@ -13,13 +13,26 @@ repository.workspace = true version.workspace = true [dependencies] +anyhow = "1.0.100" bon = "3.8.1" clap = { version = "4.5", features = ["derive"] } -clock-bound = { path = "../clock-bound", features = ["daemon"] } +clock-bound = { path = "../clock-bound", features = [ + "daemon", + "time-string-parse", +] } +num-traits = "0.2.19" +rand = "0.8.5" +# rand_chacha used by statsrs +rand_chacha = "0.3.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.145" +statrs = "0.18.0" thiserror = { version = "2.0" } [dev-dependencies] approx = "0.5" +mockall = "0.13.1" +nalgebra = "0.33" rstest = "0.25" +varpro = "0.11.0" +tempfile = "3.20" diff --git a/clock-bound-ff-tester/src/bin/oscillator_generator.rs b/clock-bound-ff-tester/src/bin/oscillator_generator.rs new file mode 100644 index 0000000..c53fd7e --- /dev/null +++ b/clock-bound-ff-tester/src/bin/oscillator_generator.rs @@ -0,0 +1,465 @@ +//! Oscillator Generator CLI Tool +//! +//! This tool generates simple oscillator models for simulation. It supports: +//! - Simple oscillator: Creates oscillators with constant skew and offset +//! - Sine wave oscillator: Creates oscillators with sinusoidal offset patterns +//! +//! The generated oscillator models can be saved to a file or output to stdout in JSON format. + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use clock_bound_ff_tester::simulation::oscillator::{Noise, Oscillator}; +use clock_bound_ff_tester::time::{Frequency, Skew, TrueDuration, TrueInstant, TscCount}; +use rand::SeedableRng; + +/// Main command-line interface for the oscillator generator +/// +/// This struct defines the top-level CLI interface with subcommands for +/// different oscillator types and global options. +#[derive(Clone, Debug, Parser)] +pub struct Cli { + #[command(subcommand)] + /// Subcommand for this CLI + pub command: Commands, +} + +/// Available subcommands for oscillator generation +/// +/// Each variant corresponds to a different type of oscillator model +/// that can be generated using this tool. +#[derive(Clone, Debug, Subcommand)] +pub enum Commands { + /// Generate a simple oscillator model + /// + /// Creates an oscillator with constant skew and offset + Simple(Simple), + + /// Generate a sinusoidal oscillator model + /// + /// Creates an oscillator with a sinusoidal offset pattern that oscillates + /// with a specified period and amplitude. + Sine(Sine), +} + +/// Configuration for noise parameters +/// +/// Defines the parameters for adding random noise to oscillator models +#[derive(Clone, Debug, Parser)] +pub struct NoiseArgs { + #[arg(long)] + /// Enable noise simulation in the oscillator model + pub noise: bool, + + #[arg(long, default_value = "0s", requires = "noise")] + /// Mean value of the noise distribution (e.g. 50microseconds) + pub noise_mean: TrueDuration, + + #[arg(long, required_if_eq("noise", "true"), requires = "noise")] + /// Standard deviation of the noise distribution (e.g. 30microseconds) + /// Required when noise is enabled + pub noise_std_dev: Option, + + #[arg(long, default_value = "1s", requires = "noise")] + /// Time interval between noise samples + pub noise_step: TrueDuration, + + #[arg(long, requires = "noise")] + /// Seed for reproducible noise generation (omit to use default RNG behavior) + pub noise_seed: Option, +} + +/// Configuration for simple oscillator models +/// +/// Defines parameters specific to simple oscillators +#[derive(Clone, Debug, Parser)] +#[command(version, about, long_about = None)] +pub struct Simple { + #[arg(long, default_value = "0ppm")] + /// The skew of the local oscillator (parts per million) + pub skew: Skew, + + #[arg(long, default_value = "0us")] + /// The starting local oscillator offset from true time + pub start_offset: TrueDuration, + + #[command(flatten)] + /// Common oscillator parameters + pub common: CommonArgs, + + #[command(flatten)] + /// Noise parameters + pub noise: NoiseArgs, +} + +/// Configuration for sinusoidal oscillator models +/// +/// Defines parameters specific to sine wave oscillators +#[derive(Clone, Debug, Parser)] +#[command(version, about, long_about = None)] +pub struct Sine { + #[arg(long, default_value = "5minutes")] + /// The period of the oscillator walk oscillation + pub period: TrueDuration, + + #[arg(long, default_value = "40microseconds")] + /// The offset at the peak of the sine wave oscillation + pub amplitude: TrueDuration, + + #[arg(long, default_value = "1s")] + /// Sample period of the oscillator model + pub sample_period: TrueDuration, + + #[command(flatten)] + /// Common oscillator parameters + pub common: CommonArgs, + + #[command(flatten)] + /// Noise parameters + pub noise: NoiseArgs, +} + +/// Parameters common to all oscillator types +/// +/// These parameters define the basic properties shared by all oscillator models +#[derive(Clone, Debug, Parser)] +pub struct CommonArgs { + #[arg(short, long, default_value = "1ghz")] + /// The nominal (or starting) clock frequency of the local oscillator + pub clock_frequency: Frequency, + + #[arg(long, default_value = "9000days")] + /// The start time of the scenario + pub start_time: TrueInstant, + + #[arg(long, default_value = "5minutes")] + /// The duration of the scenario + pub duration: TrueDuration, + + #[arg(long, default_value = "0")] + /// The tsc timestamp at the start of the scenario + pub tsc_timestamp_start: TscCount, + + #[arg(short, long)] + /// Optional output file name, defaults to stdout + pub output: Option, +} + +/// Main entry point for the oscillator generator tool +/// +/// Parses command-line arguments, creates a simple oscillator model, +/// and outputs the model to the specified file or to stdout. +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let (oscillator, output) = match &cli.command { + Commands::Simple(args) => handle_simple_command(args), + Commands::Sine(args) => handle_sine_command(args), + }; + + serialize_oscillator(&oscillator, output.as_ref()) +} + +/// Creates a noise model if noise is enabled +fn create_noise_model(args: &NoiseArgs) -> Option { + if !args.noise { + return None; + } + + // noise_std_dev will always be Some if --noise is true due to required_if_eq + let std_dev = args + .noise_std_dev + .expect("noise_std_dev should be set when noise is enabled"); + + let rng = match args.noise_seed { + None => { + // Use RNG seeded from os entropy + Box::new(rand_chacha::ChaCha12Rng::from_rng(rand::rngs::OsRng).unwrap()) + } + Some(seed) => { + // Use the specified seed + Box::new(rand_chacha::ChaCha12Rng::seed_from_u64(seed)) + } + }; + + // Create the noise model + Some( + Noise::builder() + .rng(rng) + .mean(args.noise_mean) + .std_dev(std_dev) + .step_size(args.noise_step) + .build(), + ) +} + +/// Handles the Simple oscillator command +fn handle_simple_command(args: &Simple) -> (Oscillator, Option) { + let noise_opt = create_noise_model(&args.noise); + + ( + Oscillator::create_simple() + .clock_frequency(args.common.clock_frequency) + .start_time(args.common.start_time) + .duration(args.common.duration) + .tsc_timestamp_start(args.common.tsc_timestamp_start) + .skew(args.skew) + .starting_oscillator_offset(args.start_offset) + .maybe_noise(noise_opt) + .call(), + args.common.output.clone(), + ) +} + +/// Handles the Sine oscillator command +fn handle_sine_command(args: &Sine) -> (Oscillator, Option) { + let noise_opt = create_noise_model(&args.noise); + + ( + Oscillator::create_sin() + .clock_frequency(args.common.clock_frequency) + .start_time(args.common.start_time) + .duration(args.common.duration) + .tsc_timestamp_start(args.common.tsc_timestamp_start) + .period(args.period) + .amplitude(args.amplitude) + .sample_period(args.sample_period) + .maybe_noise(noise_opt) + .call(), + args.common.output.clone(), + ) +} + +/// Outputs the oscillator model to file or stdout +fn serialize_oscillator(oscillator: &Oscillator, output: Option<&PathBuf>) -> anyhow::Result<()> { + if let Some(output_path) = output.as_ref() { + let mut file = std::fs::File::create(output_path)?; + serde_json::to_writer_pretty(&mut file, &oscillator.inner)?; + println!("Wrote to {}", output_path.display()); + } else { + let mut out = std::io::stdout().lock(); + serde_json::to_writer_pretty(&mut out, &oscillator.inner)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Tests CLI argument parsing for simple oscillator configuration + #[test] + fn oscillator_simple_cli_parsing() { + let args = vec![ + "test", + "simple", + "--skew", + "15ppm", + "--start-offset", + "25us", + "--clock-frequency", + "2ghz", + "--duration", + "15minutes", + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Commands::Simple(simple_args) = &cli.command { + assert_eq!(simple_args.skew, Skew::from_ppm(15.0)); + assert_eq!(simple_args.start_offset, TrueDuration::from_micros(25)); + assert_eq!(simple_args.common.clock_frequency, Frequency::from_ghz(2.0)); + assert_eq!(simple_args.common.duration, TrueDuration::from_minutes(15)); + } else { + panic!("Wrong command parsed") + } + } + + /// Tests default values for simple oscillator configuration + #[test] + fn oscillator_simple_default_values() { + let args = vec!["test", "simple"]; + let cli = Cli::try_parse_from(args).unwrap(); + + if let Commands::Simple(simple_args) = &cli.command { + assert_eq!(simple_args.skew, Skew::from_ppm(0.0)); + assert_eq!(simple_args.start_offset, TrueDuration::from_micros(0)); + assert_eq!(simple_args.common.clock_frequency, Frequency::from_ghz(1.0)); + assert_eq!(simple_args.common.duration, TrueDuration::from_minutes(5)); + assert_eq!(simple_args.common.tsc_timestamp_start, TscCount::new(0)); + assert_eq!(simple_args.common.output, None); + + // Check default noise parameters + assert!(!simple_args.noise.noise); + assert_eq!(simple_args.noise.noise_mean, TrueDuration::from_secs(0)); + assert_eq!(simple_args.noise.noise_std_dev, None); + assert_eq!(simple_args.noise.noise_step, TrueDuration::from_secs(1)); + assert_eq!(simple_args.noise.noise_seed, None); + } else { + panic!("Wrong command parsed") + } + } + + /// Tests CLI argument parsing for simple oscillator with noise + #[test] + fn oscillator_simple_with_noise_cli_parsing() { + let args = vec![ + "test", + "simple", + "--noise", + "--noise-mean", + "5us", + "--noise-std-dev", + "10us", + "--noise-step", + "5ms", + "--noise-seed", + "123", + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Commands::Simple(simple_args) = &cli.command { + assert!(simple_args.noise.noise); + assert_eq!(simple_args.noise.noise_mean, TrueDuration::from_micros(5)); + assert_eq!( + simple_args.noise.noise_std_dev, + Some(TrueDuration::from_micros(10)) + ); + assert_eq!(simple_args.noise.noise_step, TrueDuration::from_millis(5)); + assert_eq!(simple_args.noise.noise_seed, Some(123)); + } else { + panic!("Wrong command parsed") + } + } + + /// Tests that noise parameters require the noise flag + #[test] + fn oscillator_simple_noise_params_require_noise_flag() { + // Test with --noise-mean but without --noise + let args = vec!["test", "simple", "--noise-mean", "5us"]; + + let result = Cli::try_parse_from(args); + assert!( + result.is_err(), + "Should error when specifying noise params without --noise flag" + ); + + // Test with --noise-std-dev but without --noise + let args = vec!["test", "simple", "--noise-std-dev", "10us"]; + + let result = Cli::try_parse_from(args); + assert!( + result.is_err(), + "Should error when specifying noise params without --noise flag" + ); + + // Test with --noise-step but without --noise + let args = vec!["test", "simple", "--noise-step", "5ms"]; + + let result = Cli::try_parse_from(args); + assert!( + result.is_err(), + "Should error when specifying noise params without --noise flag" + ); + + // Test with --noise-seed but without --noise + let args = vec!["test", "simple", "--noise-seed", "123"]; + + let result = Cli::try_parse_from(args); + assert!( + result.is_err(), + "Should error when specifying noise-seed without --noise flag" + ); + } + + /// Tests CLI argument parsing for sine oscillator configuration + #[test] + fn oscillator_sine_cli_parsing() { + let args = vec![ + "test", + "sine", + "--period", + "10minutes", + "--amplitude", + "50microseconds", + "--sample-period", + "2s", + "--clock-frequency", + "2ghz", + "--duration", + "15minutes", + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Commands::Sine(sine_args) = &cli.command { + assert_eq!(sine_args.period, TrueDuration::from_minutes(10)); + assert_eq!(sine_args.amplitude, TrueDuration::from_micros(50)); + assert_eq!(sine_args.sample_period, TrueDuration::from_secs(2)); + assert_eq!(sine_args.common.clock_frequency, Frequency::from_ghz(2.0)); + assert_eq!(sine_args.common.duration, TrueDuration::from_minutes(15)); + } else { + panic!("Wrong command parsed") + } + } + + /// Tests CLI argument parsing for sine oscillator with noise + #[test] + fn oscillator_sine_with_noise_cli_parsing() { + let args = vec![ + "test", + "sine", + "--noise", + "--noise-mean", + "2500ns", + "--noise-std-dev", + "7500ns", + "--noise-step", + "20ms", + "--noise-seed", + "456", + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + + if let Commands::Sine(sine_args) = &cli.command { + assert!(sine_args.noise.noise); + assert_eq!(sine_args.noise.noise_mean, TrueDuration::from_nanos(2500)); + assert_eq!( + sine_args.noise.noise_std_dev, + Some(TrueDuration::from_nanos(7500)) + ); + assert_eq!(sine_args.noise.noise_step, TrueDuration::from_millis(20)); + assert_eq!(sine_args.noise.noise_seed, Some(456)); + } else { + panic!("Wrong command parsed") + } + } + + /// Tests default values for sine oscillator configuration + #[test] + fn sine_default_values() { + let args = vec!["test", "sine"]; + let cli = Cli::try_parse_from(args).unwrap(); + + if let Commands::Sine(sine_args) = &cli.command { + assert_eq!(sine_args.period, TrueDuration::from_minutes(5)); + assert_eq!(sine_args.amplitude, TrueDuration::from_micros(40)); + assert_eq!(sine_args.sample_period, TrueDuration::from_secs(1)); + assert_eq!(sine_args.common.clock_frequency, Frequency::from_ghz(1.0)); + assert_eq!(sine_args.common.duration, TrueDuration::from_minutes(5)); + assert_eq!(sine_args.common.tsc_timestamp_start, TscCount::new(0)); + assert_eq!(sine_args.common.output, None); + + // Check default noise parameters + assert!(!sine_args.noise.noise); + assert_eq!(sine_args.noise.noise_mean, TrueDuration::from_secs(0)); + assert_eq!(sine_args.noise.noise_std_dev, None); + assert_eq!(sine_args.noise.noise_step, TrueDuration::from_secs(1)); + assert_eq!(sine_args.noise.noise_seed, None); + } else { + panic!("Wrong command parsed") + } + } +} diff --git a/clock-bound-ff-tester/src/bin/scenario_generator.rs b/clock-bound-ff-tester/src/bin/scenario_generator.rs new file mode 100644 index 0000000..cd7bc9c --- /dev/null +++ b/clock-bound-ff-tester/src/bin/scenario_generator.rs @@ -0,0 +1,583 @@ +//! Scenario Generator CLI Tool +//! +//! This tool generates NTP scenarios by combining oscillator models with +//! network configuration. It reads a serialized oscillator file, adds NTP +//! source parameters, and outputs a complete scenario. + +use std::{fs::File, io::BufReader, path::PathBuf}; + +use anyhow::{Context, Ok}; +use clap::{Parser, Subcommand}; +use clock_bound_ff_tester::events::{self, Scenario as TesterScenario}; +use clock_bound_ff_tester::simulation::{ + delay::{Delay, DelayRng, TimeUnit}, + generator::GeneratorExt, + ntp::{VariableNetworkDelayGenerator, VariableRoundTripDelays}, + oscillator::{FullModel, Oscillator}, + phc, + stats::{self, DiracDistribution, GammaDistribution}, + vmclock, +}; +use clock_bound_ff_tester::time::{EstimateDuration, Period, TrueDuration}; +use rand::rngs::OsRng; +use std::str::FromStr; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DelayArgs { + Constant(TrueDuration), + GammaDistribution(stats::GammaDistribution), + Period(Period), +} + +impl DelayArgs { + fn to_boxed_delay(self) -> Box { + match self { + DelayArgs::Constant(v) => Box::new(Delay::new( + #[allow(clippy::cast_precision_loss)] + DiracDistribution::new(v.as_nanos() as f64).unwrap(), + TimeUnit::Nanos, + )), + DelayArgs::GammaDistribution(d) => Box::new(Delay::new(d, TimeUnit::Secs)), + DelayArgs::Period(p) => Box::new(Delay::new( + DiracDistribution::new(p.get()).unwrap(), + TimeUnit::Secs, + )), + } + } +} + +impl FromStr for DelayArgs { + type Err = String; + + fn from_str(s: &str) -> Result { + if s.starts_with("Constant") { + // Strips Constant prefix, + // strips parenthesis then parses the + // TrueDuration from the given string. + let s = s.strip_prefix("Constant").unwrap(); + let s = s + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + .ok_or("Did not find parenthesis.")?; + + let rv = TrueDuration::from_str(s).unwrap(); + return Result::Ok(DelayArgs::Constant(rv)); + } else if s.starts_with("Gamma") { + // Strips Gamma prefix, + // parses the Gamma distribution parameters from the given string. + let s = s.strip_prefix("Gamma").unwrap(); + let d = GammaDistribution::from_str(s).unwrap(); + return Result::Ok(DelayArgs::GammaDistribution(d)); + } else if s.starts_with("Period") { + // Strips Period prefix, + // parses the Period from the given string. + let s = s.strip_prefix("Period").unwrap(); + let s = s + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + .ok_or("Did not find parenthesis.")?; + let p = Period::from_str(s).unwrap(); + return Result::Ok(DelayArgs::Period(p)); + } + Err(format!("Unexpected prefix. Input: {s}")) + } +} + +/// Command-line interface for the scenario generator +#[derive(Clone, Debug, Parser)] +pub struct Cli { + /// Path to input oscillator JSON file + #[arg(long)] + pub oscillator_file: PathBuf, + + /// Optional output file name, defaults to stdout + #[arg(short, long)] + pub output: Option, + + #[command(subcommand)] + pub command: Commands, +} + +/// Available generator types +#[derive(Clone, Debug, Subcommand)] +pub enum Commands { + /// Generate an NTP scenario + Ntp(Ntp), + /// Generate a PHC scenario + Phc(Phc), + /// Generate a VMClock scenario + VMClock(VMClock), +} + +/// Configuration for NTP scenario generator +#[derive(Debug, Clone, Parser)] +pub struct Ntp { + /// The forward network delay of NTP packets. + /// + /// Inputs can be constant or from a distribution. + /// + /// To use the gamma distribution Include the shape, rate and loc parameters + /// in that order in the following format. + /// Gamma{`shape`,`rate`,`loc`} + /// + /// When using a distribution the associated unit are microseconds. + #[arg(long, default_value = "Constant(25microseconds)")] + pub forward_network: DelayArgs, + + /// The backward network delay of NTP packets. + /// + /// Inputs can be constant or from a distribution. + /// + /// To use the gamma distribution Include the shape, rate and loc parameters + /// in that order in the following format. + /// Gamma{`shape`,`rate`,`loc`} + /// + /// When using a distribution the associated unit are microseconds. + #[arg(long, default_value = "Constant(30microseconds)")] + pub backward_network: DelayArgs, + + /// The NTP server processing time. + /// + /// Inputs can be constant or from a distribution. + /// + /// To use the gamma distribution Include the shape, rate and loc parameters + /// in that order in the following format. + /// Gamma{`shape`,`rate`,`loc`} + /// + /// When using a distribution the associated unit are microseconds. + #[arg(long, default_value = "Constant(15microseconds)")] + pub server_delay: DelayArgs, + + /// How often the local client polls the NTP server + #[arg(long, default_value = "16s")] + pub poll_period: EstimateDuration, + + /// Identifier for this NTP source + #[arg(long, required = true)] + pub id: String, +} + +/// Configuration for the PHC scenario generator +#[derive(Debug, Clone, Parser)] +pub struct Phc { + /// The forward network delay of the PHC (`ref_clock`) read. + /// + /// Inputs can be constant or from a distribution. + /// + /// To use the gamma distribution Include the shape, rate and loc parameters + /// in that order in the following format. + /// Gamma{`shape`,`rate`,`loc`} + /// + /// When using a distribution the associated unit are microseconds. + #[arg(long, default_value = "Constant(5microseconds)")] + pub forward_network: DelayArgs, + + /// The backward network delay of the PHC (`ref_clock`) read. + /// + /// Inputs can be constant or from a distribution. + /// + /// To use the gamma distribution Include the shape, rate and loc parameters + /// in that order in the following format. + /// Gamma{`shape`,`rate`,`loc`} + /// + /// When using a distribution the associated unit are microseconds. + #[arg(long, default_value = "Constant(8microseconds)")] + pub backward_network: DelayArgs, + + /// The clock error bound of the PHC value + /// Inputs can be constant or from a distribution + #[arg(long, alias = "ceb")] + pub clock_error_bound: Option, + + /// How often the local client polls the NTP server + #[arg(long, default_value = "50s")] + pub poll_period: EstimateDuration, + + /// Identifier for this PHC source + #[arg(long, required = true)] + pub id: String, +} + +/// Configuration for generating a single VMClock generator +#[derive(Debug, Clone, Parser)] +pub struct VMClock { + /// How often the VMClock gets updated + #[arg(long, default_value = "50s")] + pub update_period: EstimateDuration, + + /// The period error distribution + #[arg(long, alias = "period-error")] + pub period_max_error: Option, + + /// The lag value of the VMClock time + #[arg(long, default_value = "Constant(1milliseconds)")] + pub vmclock_time_lag: DelayArgs, + + /// Identifier for this VMClock source + #[arg(long)] + pub id: String, +} + +/// Main function of the scenario generator tool +/// +/// This function: +/// 1. Parses command-line arguments +/// 2. Deserializes an oscillator model from the specified file +/// 3. Creates a full model from the oscillator +/// 4. Sets up an generator with the specified parameters +/// 5. Generates a scenario with events +/// 6. Serializes the scenario to a file or stdout +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let oscillator = deserialize_oscillator(&cli.oscillator_file).with_context(|| { + format!( + "Failed to load oscillator from file '{}'", + cli.oscillator_file.display() + ) + })?; + let full_model = FullModel::calculate_from_oscillator(oscillator); + + let scenario = match cli.command { + Commands::Ntp(ntp) => { + let mut generator = create_ntp_generator( + ntp.forward_network.to_boxed_delay(), + ntp.server_delay.to_boxed_delay(), + ntp.backward_network.to_boxed_delay(), + ntp.id, + ntp.poll_period, + &full_model, + ); + generator.create_scenario(full_model) + } + Commands::Phc(phc) => { + let delays = phc::round_trip_delays::VariableRoundTripDelays::builder() + .forward_network(phc.forward_network.to_boxed_delay()) + .backward_network(phc.backward_network.to_boxed_delay()) + .build(); + let mut generator = phc::variable_delay_source::Generator::builder() + .poll_period(phc.poll_period) + .id(phc.id) + .oscillator(&full_model) + .network_delays(delays) + .maybe_clock_error_bounds(phc.clock_error_bound.map(DelayArgs::to_boxed_delay)) + .build(); + + generator.create_scenario(full_model) + } + Commands::VMClock(vmc) => { + let props = vmclock::Props { + update_period: vmc.update_period, + clock_period_max_error: vmc.period_max_error.map(DelayArgs::to_boxed_delay), + vmclock_time_lag: vmc.vmclock_time_lag.to_boxed_delay(), + }; + + let mut generator = vmclock::Generator::builder() + .props(props) + .id(vmc.id) + .oscillator(&full_model) + .rng(Box::new(OsRng)) + .build(); + + generator.create_scenario(full_model) + } + }; + + serialize_scenario(cli.output.as_ref(), &scenario).with_context(|| { + format!( + "Failed to serialize scenario{}", + cli.output.as_ref().map_or_else( + || " to stdout".to_string(), + |p| format!(" to '{}'", p.display()) + ) + ) + })?; + + Ok(()) +} + +fn deserialize_oscillator(path: &PathBuf) -> Result { + let file = File::open(path) + .with_context(|| format!("Failed to open oscillator file '{}'", path.display()))?; + let reader = BufReader::new(file); + let inner: clock_bound_ff_tester::events::v1::Oscillator = serde_json::from_reader(reader) + .with_context(|| format!("Failed to parse oscillator JSON from '{}'", path.display()))?; + let oscillator = Oscillator::from(inner); + Ok(oscillator) +} + +fn create_ntp_generator( + forward_network: Box, + backward_network: Box, + server_delay: Box, + id: String, + poll_period: EstimateDuration, + oscillator: &FullModel, +) -> VariableNetworkDelayGenerator { + let round_trip_model = VariableRoundTripDelays::builder() + .forward_network(forward_network) + .backward_network(backward_network) + .server(server_delay) + .build(); + + VariableNetworkDelayGenerator::builder() + .id(id) + .poll_period(poll_period) + .network_delays(round_trip_model) + .oscillator(oscillator) + .build() +} + +fn serialize_scenario( + path: Option<&PathBuf>, + scenario: &TesterScenario, +) -> Result<(), anyhow::Error> { + if let Some(output) = path.as_ref() { + let mut file = File::create(output) + .with_context(|| format!("Failed to create output file '{}'", output.display()))?; + crate::events::serialize_to_writer_pretty(scenario, &mut file).with_context(|| { + format!( + "Failed to serialize scenario to file '{}'", + output.display() + ) + })?; + println!("Wrote scenario to {}", output.display()); + } else { + let mut out = std::io::stdout().lock(); + crate::events::serialize_to_writer_pretty(scenario, &mut out) + .context("Failed to serialize scenario to stdout")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clock_bound_ff_tester::time::{Frequency, TscCount}; + use serde_json::{Value, json}; + use std::io::{Read, Write}; + use tempfile::NamedTempFile; + + /// Creates a sine oscillator file for testing + /// with parameters similar to the example output + fn create_sine_oscillator_file() -> anyhow::Result { + // This matches the sine oscillator output with 20s duration and 1s sample period + let mut oscillator_json = json!({ + "system_start_time": 1_748_583_788_901_252_492_i128, + "tsc_timestamp_start": 0, + "clock_frequency": 1_000_000_000.0_f64, + "oscillator_offsets": { + "inner": { + "indices": [ + 0_i128, 1_000_000_000_i128, 2_000_000_000_i128, 3_000_000_000_i128, 4_000_000_000_i128, + 5_000_000_000_i128, 6_000_000_000_i128, 7_000_000_000_i128, 8_000_000_000_i128, 9_000_000_000_i128, + 10_000_000_000_i128, 11_000_000_000_i128, 12_000_000_000_i128, 13_000_000_000_i128, 14_000_000_000_i128, + 15_000_000_000_i128, 16_000_000_000_i128, 17_000_000_000_i128, 18_000_000_000_i128, 19_000_000_000_i128 + ], + "data": [ + 0, 837, 1675, 2511, 3347, + 4181, 5013, 5843, 6670, 7495, + 8316, 9134, 9947, 10756, 11561, + 12360, 13154, 13942, 14724, 15500 + ] + } + } + }); + + // transform from nanoseconds to femtoseconds + let indices = oscillator_json["oscillator_offsets"]["inner"]["indices"] + .as_array_mut() + .unwrap(); + for i in indices.iter_mut() { + *i = Value::from(i.as_i64().unwrap() * 1_000_000); + } + let data = oscillator_json["oscillator_offsets"]["inner"]["data"] + .as_array_mut() + .unwrap(); + for d in data.iter_mut() { + *d = Value::from(d.as_i64().unwrap() * 1_000_000); + } + + let mut temp_file = NamedTempFile::new()?; + write!(temp_file, "{oscillator_json}")?; + Ok(temp_file) + } + + #[test] + fn deserialize_oscillator_basic() -> anyhow::Result<()> { + let oscillator_file = create_sine_oscillator_file()?; + let oscillator = deserialize_oscillator(&oscillator_file.path().to_path_buf())?; + + assert_eq!(oscillator.clock_frequency(), Frequency::from_ghz(1.0)); + assert_eq!(oscillator.tsc_timestamp_start(), TscCount::new(0)); + + // Verify the offsets are correctly loaded - 20 samples matching example + let offsets = oscillator.offset_series().as_ref(); + assert_eq!(offsets.len(), 20); + assert_eq!(offsets.indices()[0].as_nanos(), 0); + assert_eq!(offsets.indices()[19].as_nanos(), 19_000_000_000); + + // Check first and last + assert_eq!(offsets.data()[0].as_nanos(), 0); + assert_eq!(offsets.data()[19].as_nanos(), 15500); + + Ok(()) + } + + #[test] + fn scenario_generator_basic_functionality() { + // Create a scenario using the same parameters as in sample + let oscillator_file = create_sine_oscillator_file().unwrap(); + let oscillator = deserialize_oscillator(&oscillator_file.path().to_path_buf()).unwrap(); + let full_model = FullModel::calculate_from_oscillator(oscillator); + let mut generator = create_ntp_generator( + Box::new(Delay::new( + DiracDistribution::new(25.0).unwrap(), + TimeUnit::Micros, + )), + Box::new(Delay::new( + DiracDistribution::new(15.0).unwrap(), + TimeUnit::Micros, + )), + Box::new(Delay::new( + DiracDistribution::new(30.0).unwrap(), + TimeUnit::Micros, + )), + "sine".to_string(), + EstimateDuration::from_secs(16), + &full_model, + ); + let scenario = generator.create_scenario(full_model); + + // Test file serialization + let temp_file = NamedTempFile::new().unwrap(); + let path = Some(temp_file.path().to_path_buf()); + + serialize_scenario(path.as_ref(), &scenario).unwrap(); + + // Read and parse the serialized JSON to verify structure + let mut content = String::new(); + let mut file = File::open(temp_file.path()).unwrap(); + _ = file.read_to_string(&mut content).unwrap(); + let json_value: Value = serde_json::from_str(&content).unwrap(); + + // Verify the correct top-level structure with "V0" key + assert!(json_value.get("V1").is_some()); + + // Check the events array + let events = &json_value["V1"]["events"]; + assert!(events.is_array()); + assert!(!events.as_array().unwrap().is_empty()); + + // Verify first event structure + let first_event = &events[0]; + assert!(first_event["variants"]["Ntp"].is_object()); + assert!(first_event["client_tsc_post_time"].is_number()); + assert!(first_event["client_tsc_pre_time"].is_number()); + + // Check that NTP event fields exist + let ntp_event = &first_event["variants"]["Ntp"]; + assert!(ntp_event["server_system_recv_time"].is_number()); + assert!(ntp_event["server_system_send_time"].is_number()); + assert_eq!(ntp_event["root_delay"], 0); + assert_eq!(ntp_event["root_dispersion"], 0); + assert_eq!(ntp_event["source_id"], "sine"); + + // Check oscillator scenario structure + let oscillator = &json_value["V1"]["oscillator"]; + assert!(oscillator["system_start_time"].is_number()); + assert_eq!(oscillator["tsc_timestamp_start"], 0); + assert_eq!(oscillator["clock_frequency"], 1_000_000_000.0); + + // Verify oscillator offsets + let offsets = &oscillator["oscillator_offsets"]["inner"]; + assert!(offsets["indices"].is_array()); + assert!(offsets["data"].is_array()); + assert_eq!(offsets["indices"].as_array().unwrap().len(), 20); + assert_eq!(offsets["data"].as_array().unwrap().len(), 20); + + // Metadata + assert!(json_value["V1"]["metadata"].is_object()); + } + + #[test] + fn cli_parsing() { + // with minimal arguments + let args = [ + "scenario_generator", + "--oscillator-file", + "oscillator.json", + "ntp", + "--id", + "test-source", + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + assert_eq!(cli.oscillator_file, PathBuf::from("oscillator.json")); + assert_eq!(cli.output, None); + let Commands::Ntp(cli) = cli.command else { + panic!("Expected NTP command"); + }; + assert_eq!(cli.id, "test-source"); + assert_eq!( + cli.forward_network, + DelayArgs::Constant(TrueDuration::from_micros(25)) + ); + assert_eq!(cli.poll_period, EstimateDuration::from_secs(16)); + + // with all arguments + let args = [ + "scenario_generator", + "--oscillator-file", + "oscillator.json", + "--output", + "output.json", + "ntp", + "--forward-network", + "Constant(40microseconds)", + "--backward-network", + "Constant(50microseconds)", + "--server-delay", + "Constant(10microseconds)", + "--poll-period", + "8s", + "--id", + "custom-source", + ]; + + let cli = Cli::try_parse_from(args).unwrap(); + assert_eq!(cli.oscillator_file, PathBuf::from("oscillator.json")); + assert_eq!(cli.output, Some(PathBuf::from("output.json"))); + let Commands::Ntp(cli) = cli.command else { + panic!("Expected NTP command"); + }; + assert_eq!( + cli.forward_network, + DelayArgs::Constant(TrueDuration::from_micros(40)) + ); + assert_eq!( + cli.backward_network, + DelayArgs::Constant(TrueDuration::from_micros(50)) + ); + assert_eq!( + cli.server_delay, + DelayArgs::Constant(TrueDuration::from_micros(10)) + ); + assert_eq!(cli.poll_period, EstimateDuration::from_secs(8)); + assert_eq!(cli.id, "custom-source"); + } + + #[test] + fn cli_required_arguments() { + // Missing required oscillator file + let args = ["scenario_generator", "--id", "test-source"]; + + let result = Cli::try_parse_from(args); + _ = result.unwrap_err(); + + // Missing required id + let args = ["scenario_generator", "--oscillator-file", "oscillator.json"]; + + let result = Cli::try_parse_from(args); + _ = result.unwrap_err(); + } +} diff --git a/clock-bound-ff-tester/src/lib.rs b/clock-bound-ff-tester/src/lib.rs index d12a583..a87d7a8 100644 --- a/clock-bound-ff-tester/src/lib.rs +++ b/clock-bound-ff-tester/src/lib.rs @@ -3,3 +3,5 @@ pub mod time; pub mod events; + +pub mod simulation; diff --git a/clock-bound-ff-tester/src/simulation.rs b/clock-bound-ff-tester/src/simulation.rs new file mode 100644 index 0000000..4fe12bb --- /dev/null +++ b/clock-bound-ff-tester/src/simulation.rs @@ -0,0 +1,14 @@ +//! Simulation logic for `FF-tester` +//! +//! "Oh, My bad. Yeah, so time is just an infinite series of fleeting moments +//! progressing indefinitely and unstoppably at a near-constant rate. It's how the real +//! world experiences s***" - Neil Kohney, The Other End + +pub mod delay; +pub mod generator; +pub mod interpolation; +pub mod ntp; +pub mod oscillator; +pub mod phc; +pub mod stats; +pub mod vmclock; diff --git a/clock-bound-ff-tester/src/simulation/delay.rs b/clock-bound-ff-tester/src/simulation/delay.rs new file mode 100644 index 0000000..e91c734 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/delay.rs @@ -0,0 +1,97 @@ +//! Building blocks for all your delay needs. + +use crate::simulation::stats::Distribution; +use crate::time::TrueDuration; +use rand::Rng; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TimeUnit { + Secs, + Millis, + Micros, + Nanos, +} + +/// Building block that allows the user to define a network delay using distribution models. +#[derive(Debug, Clone, PartialEq)] +pub struct Delay { + /// The statistical distribution model the delay will be pulled from. + pub distribution: T, + /// The units of measurement which to apply to the sampled values. + pub unit: TimeUnit, +} + +impl Delay { + pub fn get_value(&self, rng: &mut R) -> TrueDuration { + let rng_value = self.distribution.sample(rng); + match self.unit { + TimeUnit::Secs => TrueDuration::from_seconds_f64(rng_value), + TimeUnit::Millis => TrueDuration::from_millis_f64(rng_value), + TimeUnit::Micros => TrueDuration::from_micros_f64(rng_value), + TimeUnit::Nanos => TrueDuration::from_nanos_f64(rng_value), + } + } + + pub fn new(distribution: T, unit: TimeUnit) -> Self { + Self { distribution, unit } + } +} + +/// Convenience trait intended to make `Generator` and cli integration easier. +pub trait DelayRng: std::fmt::Debug { + fn get_value(&self, rng: &mut dyn rand::RngCore) -> TrueDuration; + + fn get_value_tsc(&self, rng: &mut dyn rand::RngCore) -> (f64, TimeUnit); +} +impl DelayRng for Delay { + fn get_value(&self, rng: &mut dyn rand::RngCore) -> TrueDuration { + self.get_value(rng) + } + + fn get_value_tsc(&self, rng: &mut dyn rand::RngCore) -> (f64, TimeUnit) { + let rng_value = self.distribution.sample(rng); + (rng_value, self.unit) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::simulation::stats; + use rand_chacha; + use rand_chacha::rand_core::SeedableRng; + use rstest::rstest; + + #[rstest] + #[case(TimeUnit::Secs)] + #[case(TimeUnit::Millis)] + #[case(TimeUnit::Micros)] + #[case(TimeUnit::Nanos)] + fn validate_delay_units(#[case] unit: TimeUnit) { + // expectations + // + // This is intended to check the mapping between delay values and the units of time we set. + // We use the Dirac distribution for this test because there is no variance. Sampling the Dirac + // distribution always returns the same value. + let v = 10.0; + let delay = Delay { + distribution: stats::DiracDistribution::new(v).unwrap(), + unit, + }; + + let mut rng = rand_chacha::ChaCha12Rng::from_seed(Default::default()); + let delay_value = delay.get_value(&mut rng); + #[allow(clippy::cast_possible_truncation)] + let expected_value = match unit { + TimeUnit::Secs => TrueDuration::from_secs(v as i128), + TimeUnit::Millis => TrueDuration::from_millis(v as i128), + TimeUnit::Micros => TrueDuration::from_micros(v as i128), + TimeUnit::Nanos => TrueDuration::from_nanos(v as i128), + }; + + assert_eq!(expected_value, delay_value); + + let (_, out_unit) = delay.get_value_tsc(&mut rng); + assert_eq!(out_unit, unit); + } +} diff --git a/clock-bound-ff-tester/src/simulation/generator.rs b/clock-bound-ff-tester/src/simulation/generator.rs new file mode 100644 index 0000000..3abf041 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/generator.rs @@ -0,0 +1,94 @@ +//! Event generator traits. + +use crate::events::{ + Scenario, + v1::{self, Event}, +}; +use crate::time::TscCount; + +use crate::simulation::oscillator::FullModel; + +use std::collections::HashMap; + +/// An Event generator +/// +/// An instance of a struct that implements this can be seen as a singular clock source. +pub trait Generator { + /// Check when the next event will be generated by this event generator. + /// + /// This is used as a guarding check before calling [`Generator::generate`], and allows + /// orchestrators of multiple generators to decide which generator will generate the *soonest* event + /// in the current simulation. + /// + /// If the generator *would* generate before the `local_oscillator` is ready, currently implementations + /// should return the earliest `TscCount` that exists within the `oscillator` input. + fn next_event_ready(&self, oscillator: &FullModel) -> Option; + + /// Generate the next event for this generator. + /// + /// # Panics + /// This call is allowed to panic if [`Generator::next_event_ready`] would return `None`. + /// + /// Callers of this trait should ensure `next_event_ready` returns a `Some` value before each + /// invocation of [`Generator::generate`]. Furthermore, they should ensure the same `local_oscillator` is passed into + /// [`Generator::next_event_ready`] and [`Generator::generate`] without mutations to the `local_oscillator`. + fn generate(&mut self, oscillator: &FullModel) -> Event; +} + +/// Convenience functions for types that implement [`Generator`] +pub trait GeneratorExt: Generator { + /// Generate a full scenario from a [`FullModel`] + /// + /// This will take the scenario as it currently exists, and exhaust it after running. + /// The output scenario will contains ALL of the events of this generator. + fn create_scenario(&mut self, full_model: FullModel) -> Scenario { + let mut events = Vec::new(); + while self.next_event_ready(&full_model).is_some() { + events.push(self.generate(&full_model)); + } + + let scenario = v1::Scenario { + events, + oscillator: Some(full_model.to_oscillator()), + metadata: HashMap::new(), + }; + + Scenario::V1(scenario) + } +} + +impl GeneratorExt for T {} + +#[cfg(test)] +mod test { + use crate::events::Scenario; + use crate::time::{EstimateDuration, Frequency, TrueDuration, TrueInstant}; + + use crate::simulation::generator::GeneratorExt; + use crate::simulation::ntp::PerfectGenerator; + use crate::simulation::oscillator::{FullModel, Oscillator}; + + use super::*; + + #[test] + fn create_scenario() { + let mut generator = PerfectGenerator::builder() + .id(String::from("test")) + .poll_period(EstimateDuration::from_secs(8)) + .build(); + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(364)) + .duration(TrueDuration::from_minutes(5)) + .call(); + + let full_model = FullModel::calculate_from_oscillator(oscillator); + + let scenario = generator.create_scenario(full_model.clone()); + assert!(generator.next_event_ready(&full_model).is_none()); + let Scenario::V1(scenario) = scenario; + + assert_eq!(scenario.oscillator.unwrap(), full_model.to_oscillator()); + assert_eq!(scenario.events.len(), 37); + } +} diff --git a/clock-bound-ff-tester/src/simulation/interpolation.rs b/clock-bound-ff-tester/src/simulation/interpolation.rs new file mode 100644 index 0000000..c5da291 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/interpolation.rs @@ -0,0 +1,420 @@ +//! simple interpolation logic +//! +//! If you zoom in far enough *every curve looks linear. + +use std::ops::{Add, Sub}; + +use crate::time::{Frequency, Series, TrueDuration}; +use num_traits::AsPrimitive; + +use crate::simulation::oscillator::FrequencyModel; + +/// linear interpolation function +/// +/// y - y0 y1 - y0 +/// ------ = ------- +/// x - x0 x1 - x0 +/// +/// Therefore: +/// +/// y = y0 + (x - x0) * (y1 - y0) / (x1 - x0) +/// +/// # NOTE ABOUT PRECISION +/// Floating points are less likely to overflow, but have less precision than using the fixed-point clock-bound duration types. +/// This function compensates by ensuring the differences are done as integer types (this reduces the magnitude of the integer, +/// which minimizes possible precision loss when rounding into floats). +/// +/// The downside is this function has no "pure integer" way of doing things. But IMO, that kind of goes out the window +/// once you include arbitrary division. +#[bon::builder] // probably has a performance hit, but this helps correctness +pub fn linear(x: T, x0: T, x1: T, y0: U, y1: U) -> U +where + T: Sub + Copy + AsPrimitive, + U: Sub + Add + Copy + AsPrimitive, + f64: num_traits::AsPrimitive, +{ + let delta_y = (x - x0).as_() * (y1 - y0).as_() / (x1 - x0).as_(); + y0 + delta_y.as_() +} + +#[bon::builder] +pub fn linear_rounded(x: T, x0: T, x1: T, y0: U, y1: U) -> U +where + T: Sub + Copy + AsPrimitive, + U: Sub + Add + Copy + AsPrimitive, + f64: num_traits::AsPrimitive, +{ + let delta_y = (x - x0).as_() * (y1 - y0).as_() / (x1 - x0).as_(); + let delta_y = delta_y.round(); + y0 + delta_y.as_() +} + +/// Interpolate based off of a time series of data +pub trait SeriesInterpolation { + /// The X axis data type. Usually a time type + type X; + + /// The Y axis data type, representing the actual data of the time series + type Y; + + /// Approximate a time value of a series by using linear interpolation + fn approximate(&self, x: Self::X) -> Option; + + /// Approximate the time that a data point occurred at + /// + /// # NOTE + /// While X is normally monotonically increasing, there is usually less of a requirement + /// that the Y axis is the same. If the Y axis is NOT monotonically increasing, this will + /// return the FIRST valid interpolation value found. + fn reverse_approximate(&self, y: Self::Y) -> Option; +} + +impl SeriesInterpolation for FrequencyModel { + type X = TrueDuration; + type Y = Frequency; + + fn approximate(&self, x: Self::X) -> Option { + let inner = &self.inner; + // easy short circuits + let first = inner.indices().first()?; + if x < *first { + return None; + } + + let last = inner.indices().last()?; + if x > *last { + return None; + } + + let (idx, x_window) = inner + .indices() + .windows(2) + .enumerate() + .find(|(_, w)| w[1] >= x)?; + + let y_window = &inner.data()[idx..idx + 2]; + + // short circuit + if x_window[0] == x { + return Some(y_window[0]); + } + if x_window[1] == x { + return Some(y_window[1]); + } + + let res = linear() + .x(x.as_femtos()) + .x0(x_window[0].as_femtos()) + .x1(x_window[1].as_femtos()) + .y0(y_window[0].get()) + .y1(y_window[1].get()) + .call(); + + Some(Frequency::from_hz(res)) + } + + /// This function is NOT reliable for frequency + fn reverse_approximate(&self, _y: Self::Y) -> Option { + panic!("reverse approximate on frequency is UNRELIABLE at best, WRONG at worst") + } +} + +impl SeriesInterpolation for Series +where + X: PartialOrd + Copy + Into + From, + Y: PartialOrd + Copy + Into + From, +{ + type X = X; + type Y = Y; + + /// A linear interpolation for for [`Series`] + /// + /// Given a [`Series`], aka a time series of data. Get they approximate + /// y value at a certain x point. It does this by searching along the + /// Series for a window where `x[i] <= x <[i + 1]`, and then using a linear + /// interpolation to find the corresponding `y` value. + /// + /// Below is the best visual representation with my ability to represent diagonal lines + /// in ASCII + /// + /// ```text + /// │ Y axis of series + /// │ + /// │ + /// │ + /// │ o + /// y1 ──┼────────────────────────── o + /// | . | + /// │ . │ + /// y│◄────────────────── * | + /// | . | | + /// | . | | + /// y0 ──┼──────────────o | | + /// │ │ | │ + /// │ │ | │ + /// │ o │ | │ + /// │ │ | │ + /// │ │ | │ + /// │ │ | │ + /// └──────────────┼────────────┼──────── X axis of series + /// │ | │ + /// │ | │ + /// + /// x0 x x1 + /// + /// o = Series datapoint; * = interpolated datapoint + /// ``` + fn approximate(&self, x: X) -> Option { + // easy short circuits + let first = self.indices().first()?; + if x < *first { + return None; + } + + let last = self.indices().last()?; + if x > *last { + return None; + } + + let (idx, x_window) = self + .indices() + .windows(2) + .enumerate() + .find(|(_, w)| w[1] >= x)?; + + let y_window = &self.data()[idx..idx + 2]; + + // short circuit + if x_window[0] == x { + return Some(y_window[0]); + } + + if x_window[1] == x { + return Some(y_window[1]); + } + + let res = linear_rounded() + .x(x.into()) + .x0(x_window[0].into()) + .x1(x_window[1].into()) + .y0(y_window[0].into()) + .y1(y_window[1].into()) + .call(); + + Some(Self::Y::from(res)) + } + + fn reverse_approximate(&self, y: Self::Y) -> Option { + let (idx, y_window) = self + .data() + .windows(2) + .enumerate() + .find(|(_, w)| (w[0]..=w[1]).contains(&y))?; + + let x_window = &self.indices()[idx..idx + 2]; + + // short circuit + if y_window[0] == y { + return Some(x_window[0]); + } + + if y_window[1] == y { + return Some(x_window[1]); + } + + // we are finding x from a y value, hence the reversed values + let res = linear_rounded() + .x(y.into()) + .x0(y_window[0].into()) + .x1(y_window[1].into()) + .y0(x_window[0].into()) + .y1(x_window[1].into()) + .call(); + + Some(Self::X::from(res)) + } +} + +#[cfg(test)] +mod test { + use crate::time::TrueDuration; + use rstest::rstest; + + use super::*; + + #[test] + fn linear_interpolation_basic() { + // Test with simple linear case + let result = linear().x(5.0f64).x0(0.0).x1(10.0).y0(0.0).y1(10.0).call(); + approx::assert_abs_diff_eq!(result, 5.0); + } + + #[test] + fn linear_interpolation_midpoint() { + // Test exact middle point + let result = linear().x(5.0f64).x0(0.0).x1(10.0).y0(20.0).y1(30.0).call(); + approx::assert_abs_diff_eq!(result, 25.0); + } + + #[test] + fn linear_interpolation_negative_values() { + // Test with negative values + let result = linear() + .x(-5.0f64) + .x0(-10.0) + .x1(0.0) + .y0(-100.0) + .y1(100.0) + .call(); + approx::assert_abs_diff_eq!(result, 0.0); + } + + #[test] + fn linear_interpolation_exact_bounds() { + // Test at exact boundary points + let x1 = linear().x(10.0f64).x0(0.0).x1(10.0).y0(5.0).y1(15.0).call(); + let x0 = linear().x(0.0f64).x0(0.0).x1(10.0).y0(5.0).y1(15.0).call(); + + approx::assert_abs_diff_eq!(x1, 15.0); + approx::assert_abs_diff_eq!(x0, 5.0); + } + + #[test] + fn linear_interpolation_outside_bounds() { + // Test extrapolation outside the bounds + let result = linear().x(15.0f64).x0(0.0).x1(10.0).y0(0.0).y1(10.0).call(); + approx::assert_abs_diff_eq!(result, 15.0); + } + + #[test] + fn linear_interpolation_floating_point_precision() { + // Test with very small numbers to check floating point precision + let result = linear().x(0.5f64).x0(0.0).x1(1.0).y0(0.0).y1(1.0).call(); + approx::assert_abs_diff_eq!(result, 0.5); + } + + #[test] + fn linear_interpolation_floating_point_y_equals() { + let result: f64 = linear().x(0.5f64).x0(0.0).x1(1.0).y0(1.0).y1(1.0).call(); + assert!((result - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn linear_interpolation_floating_point_x_equals() { + let result: f64 = linear().x(0f64).x0(0.0).x1(0.0).y0(0.0).y1(1.0).call(); + assert!(result.is_nan()); + } + + #[rstest] + #[case(5, 50)] + #[case(8, 80)] + fn interpolation_basic(#[case] x: i128, #[case] y: i128) { + let index = vec![TrueDuration::from_nanos(0), TrueDuration::from_nanos(10)]; + let data = vec![TrueDuration::from_nanos(0), TrueDuration::from_nanos(100)]; + let series = Series::new(index, data).unwrap(); + + let result = series.approximate(TrueDuration::from_nanos(x)); + let reverse_result = series.reverse_approximate(TrueDuration::from_nanos(y)); + assert_eq!(result, Some(TrueDuration::from_nanos(y))); + assert_eq!(reverse_result, Some(TrueDuration::from_nanos(x))); + } + + #[rstest] + #[case(0, 5)] + #[case(10, 25)] + fn series_interpolation_exact_points(#[case] x: i128, #[case] y: i128) { + let index = vec![TrueDuration::from_nanos(0), TrueDuration::from_nanos(10)]; + let data = vec![TrueDuration::from_nanos(5), TrueDuration::from_nanos(25)]; + let series = Series::new(index, data).unwrap(); + + // Test exact points + assert_eq!( + series.approximate(TrueDuration::from_nanos(x)), + Some(TrueDuration::from_nanos(y)) + ); + + assert_eq!( + series.reverse_approximate(TrueDuration::from_nanos(y)), + Some(TrueDuration::from_nanos(x)) + ); + } + + #[rstest] + #[case(-5)] + #[case(115)] + fn series_interpolation_out_of_bounds(#[case] x: i128) { + let index = vec![TrueDuration::from_nanos(0), TrueDuration::from_nanos(10)]; + let data = vec![TrueDuration::from_nanos(0), TrueDuration::from_nanos(100)]; + let series = Series::new(index, data).unwrap(); + + // Test point before the series + assert_eq!(series.approximate(TrueDuration::from_nanos(x)), None); + assert_eq!( + series.reverse_approximate(TrueDuration::from_nanos(x)), + None + ); + } + + #[rstest] + #[case(5, 50)] + #[case(15, 75)] + fn series_interpolation_multiple_points(#[case] x: i128, #[case] expected_y: i128) { + let index = vec![ + TrueDuration::from_nanos(0), + TrueDuration::from_nanos(10), + TrueDuration::from_nanos(20), + ]; + let data = vec![ + TrueDuration::from_nanos(0), + TrueDuration::from_nanos(100), + TrueDuration::from_nanos(50), + ]; + let series = Series::new(index, data).unwrap(); + + assert_eq!( + series.approximate(TrueDuration::from_nanos(x)), + Some(TrueDuration::from_nanos(expected_y)) + ); + } + + #[test] + fn frequency_model_interpolation() { + let times = vec![ + TrueDuration::from_nanos(0), + TrueDuration::from_nanos(100), + TrueDuration::from_nanos(200), + ]; + let freqs = vec![ + Frequency::from_hz(1.0), + Frequency::from_hz(2.0), + Frequency::from_hz(3.0), + ]; + let series = Series::new(times.clone(), freqs.clone()).unwrap(); + let model = FrequencyModel { inner: series }; + + // Test middle point interpolation + let mid_point = TrueDuration::from_nanos(50); + let result = model.approximate(mid_point).unwrap(); + approx::assert_abs_diff_eq!(result.get(), 1.5); + + // Test exact points + approx::assert_abs_diff_eq!( + model + .approximate(TrueDuration::from_nanos(0)) + .unwrap() + .get(), + 1.0 + ); + approx::assert_abs_diff_eq!( + model + .approximate(TrueDuration::from_nanos(100)) + .unwrap() + .get(), + 2.0 + ); + + // Test out of bounds + assert_eq!(model.approximate(TrueDuration::from_nanos(-50)), None); + assert_eq!(model.approximate(TrueDuration::from_nanos(300)), None); + } +} diff --git a/clock-bound-ff-tester/src/simulation/ntp.rs b/clock-bound-ff-tester/src/simulation/ntp.rs new file mode 100644 index 0000000..5c2cfbc --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/ntp.rs @@ -0,0 +1,24 @@ +//! NTP related stuff + +mod dispersion_increase_linear; +mod multi_source; +mod perfect; +mod series; +mod variable_network_delay_source; + +use crate::simulation::generator::Generator; + +pub use dispersion_increase_linear::{ + Generator as DispersionIncreaseLinearGenerator, Props as DispersionIncreaseLinearGeneratorProps, +}; +pub use multi_source::{ + Generator as MultiSourceGenerator, GeneratorBuilder as MultiSourceGeneratorBuilder, +}; +pub use perfect::{Generator as PerfectGenerator, Props as PerfectGeneratorProps}; +pub use series::{Generator as SeriesGenerator, Props as SeriesGeneratorProps}; +pub use variable_network_delay_source::{ + Generator as VariableNetworkDelayGenerator, Props as VariableNetworkDelayGeneratorProps, +}; + +mod round_trip_delays; +pub use round_trip_delays::{RoundTripDelays, VariableRoundTripDelays}; diff --git a/clock-bound-ff-tester/src/simulation/ntp/dispersion_increase_linear.rs b/clock-bound-ff-tester/src/simulation/ntp/dispersion_increase_linear.rs new file mode 100644 index 0000000..ecca86a --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/ntp/dispersion_increase_linear.rs @@ -0,0 +1,459 @@ +//! Generator that simulates an NTP server exhibiting a period of increasing dispersion + +use crate::simulation::oscillator::FullModel; + +use super::{SeriesGenerator, round_trip_delays::RoundTripDelays}; +use crate::events::v1::Event; +use crate::time::{EstimateDuration, Series, TrueDuration, TscCount}; +use thiserror; + +/// Error types specific to dispersion increase scenario configuration +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error( + "Dispersion increase start time must be at least {min:?} into the simulation to allow for at least one poll {poll_period:?} to occur, found {actual:?}." + )] + DispersionIncreaseStartTooEarly { + min: TrueDuration, + poll_period: EstimateDuration, + actual: TrueDuration, + }, + + #[error( + "Dispersion increase duration must be at least {min:?} to span at least one poll period {poll_period:?}, found {actual:?}." + )] + DispersionIncreaseDurationTooShort { + min: TrueDuration, + poll_period: EstimateDuration, + actual: TrueDuration, + }, + + #[error( + "Maximum root dispersion must be greater than initial dispersion, found initial: {initial:?}, max: {max:?}." + )] + InvalidDispersionRange { + initial: EstimateDuration, + max: EstimateDuration, + }, + + #[error("Series generator error: {0}")] + SeriesGenerator(#[from] super::series::Error), +} + +/// Configuration properties for dispersion increase scenario +#[derive(Debug, Clone)] +pub struct Props { + /// Time when the server's dispersion starts increasing + pub dispersion_increase_start_time: TrueDuration, + + /// Duration the dispersion continues to grow + pub dispersion_increase_duration: TrueDuration, + + /// Root dispersion before increase starts + pub initial_root_dispersion: EstimateDuration, + + /// Maximum root dispersion + pub max_root_dispersion: EstimateDuration, + + /// Root delay value + pub initial_root_delay: EstimateDuration, + + /// Initial round trip delay components + pub initial_rtt_delays: RoundTripDelays, +} + +/// NTP generator that simulates a server experiencing a dispersion increase +pub struct Generator { + inner: SeriesGenerator, + id: String, + props: Props, +} + +#[bon::bon] +impl Generator { + /// Creates a new generator that simulates an NTP server exhibiting a period of root dispersion increase + /// + /// # Arguments + /// * `poll_period` - Time between successive NTP polls + /// * `id` - Identifier for the NTP source + /// * `dispersion_increase_start_time` - Time when the root dispersion increase started + /// * `dispersion_increase_duration` - Duration of root dispersion growth + /// * `initial_root_dispersion` - Root dispersion before the growth starts + /// * `max_root_dispersion` - Maximum root dispersion + /// * `initial_root_delay` - Root delay value (remains constant in this model) + /// * `initial_rtt_delays` - Round trip delays for forward, server, and backward paths (remains constant in this model) + /// + /// # Returns + /// A Result containing either the configured generator or an Error if validation fails + #[builder] + pub fn new( + poll_period: EstimateDuration, + id: String, + #[builder(default = TrueDuration::from_minutes(5))] + dispersion_increase_start_time: TrueDuration, + #[builder(default = TrueDuration::from_minutes(30))] + dispersion_increase_duration: TrueDuration, + #[builder(default = EstimateDuration::from_micros(100))] + initial_root_dispersion: EstimateDuration, + #[builder(default = EstimateDuration::from_millis(10))] + max_root_dispersion: EstimateDuration, + #[builder(default = EstimateDuration::from_micros(200))] + initial_root_delay: EstimateDuration, + #[builder(default = RoundTripDelays {forward_network: TrueDuration::from_micros(50), server: TrueDuration::from_micros(50), backward_network: TrueDuration::from_micros(50)} )] + initial_rtt_delays: RoundTripDelays, + ) -> Result { + Generator::validate_dispersion_increase_start_time( + dispersion_increase_start_time, + poll_period, + )?; + Generator::validate_dispersion_increase_duration( + dispersion_increase_duration, + poll_period, + )?; + Generator::validate_dispersion_range(initial_root_dispersion, max_root_dispersion)?; + + let props = Props { + dispersion_increase_start_time, + dispersion_increase_duration, + initial_root_dispersion, + max_root_dispersion, + initial_root_delay, + initial_rtt_delays, + }; + + let root_dispersions = Generator::generate_root_dispersion_series(&props); + let root_delays = Generator::generate_root_delay_series(&props); + let round_trip_delays = Generator::generate_round_trip_delays_series(&props); + + let inner = match SeriesGenerator::builder() + .poll_period(poll_period) + .id(id.clone()) + .root_delays(root_delays) + .root_dispersions(root_dispersions) + .round_trip_delays(&round_trip_delays) + .build() + { + Ok(generator) => generator, + Err(e) => return Err(Error::SeriesGenerator(e)), + }; + + Ok(Self { inner, id, props }) + } + + /// Getter for this generator's source identifier + pub fn id(&self) -> &str { + &self.id + } + + /// Getter for this generator's props + pub fn props(&self) -> &Props { + &self.props + } + + /// Generates a root dispersion series that models a period of dispersion growth + /// + /// The series defines the following pattern: + /// 1. Initial constant root dispersion + /// 2. Linear increase + /// 3. Immediate drop + /// 4. Return to baseline root dispersion + fn generate_root_dispersion_series(props: &Props) -> Series { + // Calculate simulation end time + let simulation_end = props.dispersion_increase_start_time + + props.dispersion_increase_duration + + TrueDuration::from_minutes(5); + + // Define key points in the root dispersion pattern + let points = vec![ + // Initial baseline dispersion + (TrueDuration::from_secs(0), props.initial_root_dispersion), + // Dispersion remains constant until the increase starts + ( + props.dispersion_increase_start_time, + props.initial_root_dispersion, + ), + // Dispersion reaches maximum + ( + props.dispersion_increase_start_time + props.dispersion_increase_duration, + props.max_root_dispersion, + ), + // Dispersion drops when the dispersion increase period ends + ( + props.dispersion_increase_start_time + + props.dispersion_increase_duration + + TrueDuration::from_secs(1), + props.initial_root_dispersion, + ), + // Continue with reduced dispersion until the end + (simulation_end, props.initial_root_dispersion), + ]; + + points.into_iter().collect() + } + + /// Generates a constant root delay series + fn generate_root_delay_series(props: &Props) -> Series { + let simulation_end = props.dispersion_increase_start_time + + props.dispersion_increase_duration + + TrueDuration::from_minutes(5); + + let points = vec![ + (TrueDuration::from_secs(0), props.initial_root_delay), + (simulation_end, props.initial_root_delay), + ]; + + points.into_iter().collect() + } + + /// Generates a constanct round trip delays series + fn generate_round_trip_delays_series(props: &Props) -> Series { + let simulation_end = props.dispersion_increase_start_time + + props.dispersion_increase_duration + + TrueDuration::from_minutes(5); + + let normal_rtt = RoundTripDelays::builder() + .forward_network(props.initial_rtt_delays.forward_network) + .backward_network(props.initial_rtt_delays.backward_network) + .server(props.initial_rtt_delays.server) + .build(); + + let points = vec![ + (TrueDuration::from_secs(0), normal_rtt.clone()), + (simulation_end, normal_rtt), + ]; + + points.into_iter().collect() + } + + fn validate_dispersion_increase_start_time( + dispersion_increase_start_time: TrueDuration, + poll_period: EstimateDuration, + ) -> Result<(), Error> { + if dispersion_increase_start_time < TrueDuration::from_nanos(poll_period.as_nanos() + 1) { + return Err(Error::DispersionIncreaseStartTooEarly { + min: TrueDuration::from_nanos(poll_period.as_nanos() + 1), + poll_period, + actual: dispersion_increase_start_time, + }); + } + Ok(()) + } + + fn validate_dispersion_increase_duration( + dispersion_increase_duration: TrueDuration, + poll_period: EstimateDuration, + ) -> Result<(), Error> { + if dispersion_increase_duration < TrueDuration::from_nanos(poll_period.as_nanos() * 2) { + return Err(Error::DispersionIncreaseDurationTooShort { + min: TrueDuration::from_nanos(poll_period.as_nanos() * 2), + poll_period, + actual: dispersion_increase_duration, + }); + } + Ok(()) + } + + fn validate_dispersion_range( + initial_root_dispersion: EstimateDuration, + max_root_dispersion: EstimateDuration, + ) -> Result<(), Error> { + if max_root_dispersion <= initial_root_dispersion { + return Err(Error::InvalidDispersionRange { + initial: initial_root_dispersion, + max: max_root_dispersion, + }); + } + Ok(()) + } +} + +impl super::Generator for Generator { + fn next_event_ready(&self, oscillator: &FullModel) -> Option { + self.inner.next_event_ready(oscillator) + } + + fn generate(&mut self, oscillator: &FullModel) -> Event { + self.inner.generate(oscillator) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::simulation::{ntp::Generator as _, oscillator::Oscillator}; + use crate::time::{Frequency, Skew, TrueInstant}; + use rstest::{fixture, rstest}; + + #[fixture] + fn constant_skew_oscillator_model() -> FullModel { + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .tsc_timestamp_start(TscCount::new(10_000)) + .skew(Skew::from_ppm(-10.0)) + .starting_oscillator_offset(TrueDuration::from_micros(200)) + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[fixture] + fn poll_period() -> i128 { + 16 + } + + #[fixture] + fn generator(poll_period: i128) -> Generator { + Generator::builder() + .poll_period(EstimateDuration::from_secs(poll_period)) + .id("dispersion_increase_period_test".to_string()) + .build() + .unwrap() + } + + #[test] + fn generator_builder_defaults() { + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id("dispersion_increase_period_test".to_string()) + .build() + .unwrap(); + + assert_eq!(generator.id(), "dispersion_increase_period_test"); + } + + #[test] + fn validation_input_errors() { + // Test dispersion increase start time is too early + let result = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id("test".to_string()) + .dispersion_increase_start_time(TrueDuration::from_secs(1)) + .build(); + + assert!(matches!( + result, + Err(Error::DispersionIncreaseStartTooEarly { .. }) + )); + + // Test dispersion increase duration is too short + let result = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id("test".to_string()) + .dispersion_increase_duration(TrueDuration::from_secs(1)) + .build(); + + assert!(matches!( + result, + Err(Error::DispersionIncreaseDurationTooShort { .. }) + )); + + // Test invalid dispersion inputs + let result = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id("test".to_string()) + .initial_root_dispersion(EstimateDuration::from_millis(10)) + .max_root_dispersion(EstimateDuration::from_micros(100)) + .build(); + + assert!(matches!(result, Err(Error::InvalidDispersionRange { .. }))); + } + + #[rstest] + fn generate_parameter_series(generator: Generator) { + #[allow(clippy::used_underscore_binding)] + let props = &generator.props; + + // Test root dispersion series generation + let root_disp_series = Generator::generate_root_dispersion_series(props); + assert_eq!(root_disp_series.len(), 5); + assert_eq!(root_disp_series.data()[0], props.initial_root_dispersion); + assert_eq!(root_disp_series.data()[1], props.initial_root_dispersion); + assert_eq!(root_disp_series.data()[2], props.max_root_dispersion); + assert_eq!(root_disp_series.data()[3], props.initial_root_dispersion); + assert_eq!(root_disp_series.data()[4], props.initial_root_dispersion); + + // Test root delay series generation + let root_delay_series = Generator::generate_root_delay_series(props); + assert_eq!(root_delay_series.len(), 2); + assert_eq!(root_delay_series.data()[0], props.initial_root_delay); + assert_eq!(root_delay_series.data()[1], props.initial_root_delay); + + // Test round trip delays series generation + let rtt_series = Generator::generate_round_trip_delays_series(props); + assert_eq!(rtt_series.len(), 2); + assert_eq!( + rtt_series.data()[0].forward_network, + props.initial_rtt_delays.forward_network + ); + assert_eq!( + rtt_series.data()[1].forward_network, + props.initial_rtt_delays.forward_network + ); + assert_eq!( + rtt_series.data()[0].backward_network, + props.initial_rtt_delays.backward_network + ); + assert_eq!( + rtt_series.data()[1].backward_network, + props.initial_rtt_delays.backward_network + ); + assert_eq!(rtt_series.data()[0].server, props.initial_rtt_delays.server); + assert_eq!(rtt_series.data()[1].server, props.initial_rtt_delays.server); + } + + #[rstest] + fn generate_events_before_inrease_during_increase_after_drop( + constant_skew_oscillator_model: FullModel, + mut generator: Generator, + ) { + // Generate events until we have samples from before increase, during increase, and after drop + let mut events = Vec::new(); + while generator + .next_event_ready(&constant_skew_oscillator_model) + .is_some() + { + events.push(generator.generate(&constant_skew_oscillator_model)); + } + + assert!( + events.len() >= 3, + "Should have at least 3 events, got {}", + events.len() + ); + + // Extract root dispersion values from all events + let dispersions: Vec = events + .iter() + .map(|e| { + let ntp = e.variants.ntp().unwrap(); + ntp.root_dispersion + }) + .collect(); + + let initial_dispersion = dispersions[0]; + + let max_dispersion = *dispersions.iter().max().unwrap(); + + // Check that we see the expected pattern: + // 1. Dispersion starts at initial value + assert_eq!(initial_dispersion, EstimateDuration::from_micros(100)); + + // 2. Dispersion increases for a period + assert!( + max_dispersion > EstimateDuration::from_nanos(initial_dispersion.as_nanos() * 2), + "Max dispersion {max_dispersion:?} should be significantly higher than initial {initial_dispersion:?}" + ); + + // 3. Final dispersion should return to initial value + let final_dispersion = dispersions.last().unwrap(); + let delta = EstimateDuration::from_nanos( + ((*final_dispersion).as_nanos() - initial_dispersion.as_nanos()).abs(), + ); + + assert!( + delta < EstimateDuration::from_micros(10), + "Final dispersion {final_dispersion:?} should be close to initial {initial_dispersion:?}, delta was {delta:?}" + ); + } +} diff --git a/clock-bound-ff-tester/src/simulation/ntp/multi_source.rs b/clock-bound-ff-tester/src/simulation/ntp/multi_source.rs new file mode 100644 index 0000000..241845b --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/ntp/multi_source.rs @@ -0,0 +1,480 @@ +//! A generator that can be composed of multiple generators to simulate multiple NTP sources. +//! +//! This module provides functionality to combine multiple NTP generators into a single +//! generator that produces events in chronological order, according to the local clock, across all sources. + +use std::sync::Mutex; + +use crate::events::v1; + +/// A generator that combines multiple NTP generators into a single source of events +/// +/// This generator handles multiple underlying NTP generators, always selecting +/// the generator with the earliest next event to produce events in chronological order, according +/// to the local clock. +/// The simulation ends when any of the underlying generators is exhausted. +pub struct Generator { + generators: Vec>, + next_generator_index: Mutex>, +} + +impl Generator { + /// Returns a reference to all underlying generators + /// + /// This allows inspection of the component generators without modifying them. + pub fn generators(&self) -> &[Box] { + &self.generators + } +} + +/// Builder for creating a multi-source NTP generator +/// +/// Provides a convenient way to add multiple generators and create a combined generator. +#[derive(Default)] +pub struct GeneratorBuilder { + generators: Vec>, +} + +impl GeneratorBuilder { + /// Creates a new empty generator builder + pub fn new() -> Self { + Self { + generators: Vec::new(), + } + } + + /// Adds a generator to the multi-source generator + /// + /// Generators are queried in the order they are added, but events are + /// produced in chronological order based on their timestamps. + pub fn add_generator(&mut self, generator: Box) { + self.generators.push(generator); + } + + /// Builds a multi-source generator from the added generators + /// + /// Creates a generator that will combine all the added sources. + /// If no generators have been added, the resulting generator will + /// always return None from `next_event_ready`. + pub fn build(self) -> Generator { + Generator { + generators: self.generators, + next_generator_index: Mutex::new(None), + } + } +} + +impl super::Generator for Generator { + fn next_event_ready( + &self, + oscillator: &crate::simulation::oscillator::FullModel, + ) -> Option { + let mut soonest_next_event_tstamp = None; + let mut index_soonest_next_event_generator = None; + + for (i, generator) in self.generators.iter().enumerate() { + if let Some(next_event_tstamp) = generator.next_event_ready(oscillator) { + if let Some(tstamp) = soonest_next_event_tstamp { + if next_event_tstamp < tstamp { + soonest_next_event_tstamp = Some(next_event_tstamp); + index_soonest_next_event_generator = Some(i); + } + } else { + soonest_next_event_tstamp = Some(next_event_tstamp); + index_soonest_next_event_generator = Some(i); + } + } else { + // Simulation is over when any generator is exhausted + *self.next_generator_index.lock().unwrap() = None; + return None; + } + } + + *self.next_generator_index.lock().unwrap() = index_soonest_next_event_generator; + soonest_next_event_tstamp + } + + fn generate(&mut self, oscillator: &crate::simulation::oscillator::FullModel) -> v1::Event { + if self.next_generator_index.lock().unwrap().is_none() { + let _ = self.next_event_ready(oscillator); + } + + let next_generator_index = self + .next_generator_index + .get_mut() + .unwrap() + .take() + .expect("All generators should have a generatable next event if next_event_ready."); + + let next_event_generator = &mut self.generators[next_generator_index]; + (*next_event_generator).generate(oscillator) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::events::v1::{Event, EventKind, Ntp}; + use crate::simulation::ntp::{ + DispersionIncreaseLinearGenerator, Generator as NtpGenerator, SeriesGenerator, + perfect::Generator as PerfectGenerator, round_trip_delays::RoundTripDelays, + }; + use crate::simulation::oscillator::{FullModel, Oscillator}; + use crate::time::{ + EstimateDuration, EstimateInstant, Frequency, Series, Skew, TrueDuration, TrueInstant, + TscCount, + }; + use mockall::{mock, predicate}; + use rstest::{fixture, rstest}; + + mock! { + NtpSource {} + impl NtpGenerator for NtpSource { + fn next_event_ready(&self, oscillator: &FullModel) -> Option; + fn generate(&mut self, oscillator: &FullModel) -> Event; + } + } + + #[fixture] + fn constant_skew_oscillator_model() -> FullModel { + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .tsc_timestamp_start(TscCount::new(10_000)) + .skew(Skew::from_ppm(-10.0)) + .starting_oscillator_offset(TrueDuration::from_micros(200)) + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[fixture] + fn perfect_generator() -> Box { + Box::new( + PerfectGenerator::builder() + .poll_period(EstimateDuration::from_secs(25)) + .id("perfect".to_string()) + .build(), + ) + } + + #[fixture] + fn series_generator() -> Box { + let root_delays: Series = vec![ + ( + TrueDuration::from_secs(0), + EstimateDuration::from_micros(100), + ), + ( + TrueDuration::from_secs(60), + EstimateDuration::from_micros(150), + ), + ( + TrueDuration::from_secs(120), + EstimateDuration::from_micros(200), + ), + ] + .into_iter() + .collect(); + + let root_dispersions: Series = vec![ + ( + TrueDuration::from_secs(0), + EstimateDuration::from_micros(150), + ), + ( + TrueDuration::from_secs(60), + EstimateDuration::from_micros(200), + ), + ( + TrueDuration::from_secs(120), + EstimateDuration::from_micros(250), + ), + ] + .into_iter() + .collect(); + + let rtds: Series = vec![ + ( + TrueDuration::from_secs(0), + RoundTripDelays::builder() + .forward_network(TrueDuration::from_micros(50)) + .backward_network(TrueDuration::from_micros(50)) + .server(TrueDuration::from_micros(30)) + .build(), + ), + ( + TrueDuration::from_secs(60), + RoundTripDelays::builder() + .forward_network(TrueDuration::from_micros(60)) + .backward_network(TrueDuration::from_micros(60)) + .server(TrueDuration::from_micros(40)) + .build(), + ), + ( + TrueDuration::from_secs(120), + RoundTripDelays::builder() + .forward_network(TrueDuration::from_micros(70)) + .backward_network(TrueDuration::from_micros(70)) + .server(TrueDuration::from_micros(50)) + .build(), + ), + ] + .into_iter() + .collect(); + + Box::new( + SeriesGenerator::builder() + .poll_period(EstimateDuration::from_secs(8)) + .id("series".to_string()) + .root_delays(root_delays) + .root_dispersions(root_dispersions) + .round_trip_delays(&rtds) + .build() + .unwrap(), + ) + } + + #[fixture] + fn dispersion_generator() -> Box { + Box::new( + DispersionIncreaseLinearGenerator::builder() + .poll_period(EstimateDuration::from_secs(13)) + .id("dispersion".to_string()) + .dispersion_increase_start_time(TrueDuration::from_minutes(10)) + .dispersion_increase_duration(TrueDuration::from_minutes(30)) + .initial_root_dispersion(EstimateDuration::from_micros(100)) + .max_root_dispersion(EstimateDuration::from_millis(10)) + .build() + .unwrap(), + ) + } + + fn create_mock_event(source_id: &str) -> Event { + Event { + variants: EventKind::Ntp(Ntp { + server_system_recv_time: EstimateInstant::from_secs(0), + server_system_send_time: EstimateInstant::from_secs(0), + root_delay: EstimateDuration::from_secs(0), + root_dispersion: EstimateDuration::from_secs(0), + source_id: source_id.to_string(), + client_system_times: None, + }), + client_tsc_pre_time: TscCount::new(0), + client_tsc_post_time: TscCount::new(0), + } + } + + #[rstest] + fn builder_with_no_generators(constant_skew_oscillator_model: FullModel) { + let builder = GeneratorBuilder::new(); + let generator = builder.build(); + + assert!( + generator + .next_event_ready(&constant_skew_oscillator_model) + .is_none() + ); + } + + #[rstest] + fn builder_with_multiple_generator_types( + perfect_generator: Box, + series_generator: Box, + dispersion_generator: Box, + ) { + let mut builder = GeneratorBuilder::new(); + builder.add_generator(perfect_generator); + builder.add_generator(series_generator); + builder.add_generator(dispersion_generator); + + let generator = builder.build(); + assert_eq!(generator.generators.len(), 3); + } + + #[rstest] + fn generators_accessor( + perfect_generator: Box, + series_generator: Box, + dispersion_generator: Box, + ) { + let mut builder = GeneratorBuilder::new(); + builder.add_generator(perfect_generator); + builder.add_generator(series_generator); + builder.add_generator(dispersion_generator); + + let generator = builder.build(); + let generators = generator.generators(); + + assert_eq!(generators.len(), 3); + } + + #[rstest] + fn next_event_ready_chooses_earliest_generator(constant_skew_oscillator_model: FullModel) { + // Create mock generators with different poll periods + let mut mock1 = MockNtpSource::new(); + let mut mock2 = MockNtpSource::new(); + let mut mock3 = MockNtpSource::new(); + + _ = mock1 + .expect_next_event_ready() + .with(predicate::always()) + .times(1) + .return_const(Some(TscCount::new(16_000_000_000))); + + _ = mock2 + .expect_next_event_ready() + .with(predicate::always()) + .times(1) + .return_const(Some(TscCount::new(8_000_000_000))); + + _ = mock3 + .expect_next_event_ready() + .with(predicate::always()) + .times(1) + .return_const(Some(TscCount::new(12_000_000_000))); + + let mut builder = GeneratorBuilder::new(); + builder.add_generator(Box::new(mock1)); + builder.add_generator(Box::new(mock2)); + builder.add_generator(Box::new(mock3)); + + let generator = builder.build(); + + // The next event should be from the second generator (8 seconds) + let next_timestamp = generator.next_event_ready(&constant_skew_oscillator_model); + assert!(next_timestamp.is_some()); + assert_eq!(next_timestamp.unwrap(), TscCount::new(8_000_000_000)); + } + + #[rstest] + fn generate_uses_correct_generator_with_mixed_types( + constant_skew_oscillator_model: FullModel, + perfect_generator: Box, + series_generator: Box, + dispersion_generator: Box, + ) { + // Poll periods: perfect=16s, series=8s, dispersion=12s + let mut builder = GeneratorBuilder::new(); + builder.add_generator(perfect_generator); // poll period 25s + builder.add_generator(series_generator); // poll period 8s + builder.add_generator(dispersion_generator); // poll period 13s + + let mut generator = builder.build(); + + // The first event should be from series generator (8 seconds) + let _ = generator.next_event_ready(&constant_skew_oscillator_model); + let event = generator.generate(&constant_skew_oscillator_model); + let ntp = event.variants.ntp().unwrap(); + assert_eq!(ntp.source_id, "series"); + + // The second event should be from dispersion increase generator (13 seconds) + let _ = generator.next_event_ready(&constant_skew_oscillator_model); + let event = generator.generate(&constant_skew_oscillator_model); + let ntp = event.variants.ntp().unwrap(); + assert_eq!(ntp.source_id, "dispersion"); + + // The third event should be from dispersion increase generator (2 x 8 seconds = 16 seconds) + let _ = generator.next_event_ready(&constant_skew_oscillator_model); + let event = generator.generate(&constant_skew_oscillator_model); + let ntp = event.variants.ntp().unwrap(); + assert_eq!(ntp.source_id, "series"); + // + // The fourth event should be from dispersion increase generator (3 x 8 seconds = 24 seconds) + let _ = generator.next_event_ready(&constant_skew_oscillator_model); + let event = generator.generate(&constant_skew_oscillator_model); + let ntp = event.variants.ntp().unwrap(); + assert_eq!(ntp.source_id, "series"); + + // The fifth event should also be from perfect generator (25 seconds) + let _ = generator.next_event_ready(&constant_skew_oscillator_model); + let event = generator.generate(&constant_skew_oscillator_model); + let ntp = event.variants.ntp().unwrap(); + assert_eq!(ntp.source_id, "perfect"); + + // The sixth event should be from dispersion increase generator (2 x 13 seconds = 26 + // seconds) + let _ = generator.next_event_ready(&constant_skew_oscillator_model); + let event = generator.generate(&constant_skew_oscillator_model); + let ntp = event.variants.ntp().unwrap(); + assert_eq!(ntp.source_id, "dispersion"); + } + + #[rstest] + fn exhausted_generator_ends_simulation(constant_skew_oscillator_model: FullModel) { + // Create mock generators where one returns None + let mut mock1 = MockNtpSource::new(); + let mut mock2 = MockNtpSource::new(); + + // First generator is exhausted + _ = mock1 + .expect_next_event_ready() + .with(predicate::always()) + .times(1) + .return_const(None); + + // Second generator still has events + _ = mock2.expect_next_event_ready().times(0); + + let mut builder = GeneratorBuilder::new(); + builder.add_generator(Box::new(mock1)); + builder.add_generator(Box::new(mock2)); + + let generator = builder.build(); + + // next_event_ready should return None because one generator is exhausted + let next_timestamp = generator.next_event_ready(&constant_skew_oscillator_model); + assert!(next_timestamp.is_none()); + } + + #[rstest] + fn generate_calls_next_event_ready_if_needed(constant_skew_oscillator_model: FullModel) { + let mut mock1 = MockNtpSource::new(); + let mut mock2 = MockNtpSource::new(); + + _ = mock1 + .expect_next_event_ready() + .with(predicate::always()) + .times(1) + .return_const(Some(TscCount::new(16_000_000_000))); + + _ = mock2 + .expect_next_event_ready() + .with(predicate::always()) + .times(1) + .return_const(Some(TscCount::new(8_000_000_000))); + + // Only the second generator should be called to generate + _ = mock2 + .expect_generate() + .with(predicate::always()) + .times(1) + .returning(|_| create_mock_event("mock2")); + + let mut builder = GeneratorBuilder::new(); + builder.add_generator(Box::new(mock1)); + builder.add_generator(Box::new(mock2)); + + let mut generator = builder.build(); + + // Generate should internally call next_event_ready if next_generator is None + let event = generator.generate(&constant_skew_oscillator_model); + let ntp = event.variants.ntp().unwrap(); + + // The event should be from the second generator + assert_eq!(ntp.source_id, "mock2"); + } + + #[rstest] + fn add_generator_builder_method( + perfect_generator: Box, + series_generator: Box, + ) { + let mut builder = GeneratorBuilder::new(); + builder.add_generator(perfect_generator); + builder.add_generator(series_generator); + + let generator = builder.build(); + assert_eq!(generator.generators().len(), 2); + } +} diff --git a/clock-bound-ff-tester/src/simulation/ntp/perfect.rs b/clock-bound-ff-tester/src/simulation/ntp/perfect.rs new file mode 100644 index 0000000..1e9a7b4 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/ntp/perfect.rs @@ -0,0 +1,281 @@ +//! A `perfect` ntp source. Runs indefinitely, and every value is without error +//! +//! Note this does NOT mean zero clock error bound +//! +//! ```text +//! ┌──────────────┐ ┌──────────────┐ +//! │server_rx_time│ │server_tx_time│ +//! └──────────────┘ └──────────────┘ +//! ───────────────────────────────────────────────┌┐────────────────────────────┌┐─────────────────────────────────────────────► +//! // │ │\\\ +//! // │ │ \\ +//! // │ │ \\ +//! // │ │ \\ +//! // │ │ \\ +//! // │ │ \\ +//! / │ │ \\ +//! // │ │ \\ +//! // │ │ \\ +//! / │ │ \\ +//! // │ │ \\\ +//! // │ │ \\\ +//! / │ │ \\ +//! ┌┐ │ │ \┌┐ +//! ──────────────────────└┘────────────────────────┼────────────────────────────┼─────────────────────────────└┘──────────────► +//! ┌──────────────────┐ │ │ ┌────────────────┐ +//! │ client_send_time │ │ │ │client_recv_time│ +//! └────────┬─────────┘ │ │ └────────┬───────┘ +//! │ │ │ │ +//! │ │ │ │ +//! │ │ │ │ +//! │ │ │ │ +//! │forward_network_delay───►│◄───server_delay───────────►│◄─backward_network_delay────►│ +//! │ +//! ``` + +use crate::events::v1::{Event, EventKind, Ntp}; +use crate::time::{DemoteToEstimate, EstimateDuration, TscCount}; + +use crate::simulation::oscillator::FullModel; + +use super::round_trip_delays::{NtpEventTimestamps, RoundTripDelays}; + +/// Properties for [`Generator`] +#[derive(Debug, Clone)] +pub struct Props { + /// The root dispersion that is always returned + pub root_dispersion: EstimateDuration, + /// The root delay in the NTP source that is always returned + pub root_delay: EstimateDuration, + /// The time period in which the oscillator polls the NTP server + /// + /// An estimate, because this naively uses the local oscillator duration to drive + /// when the next NTP request will start. + /// + /// In other words, if the local oscillator has a consistent offset, it will not account + /// for that between NTP requests. + /// + /// Furthermore, if the clock has a skew, the next NTP request will not account for this, + /// and instead use the uncorrected local time frame to drive the next NTP request. + pub poll_period: EstimateDuration, + /// The network channel parameters + pub network_delays: RoundTripDelays, +} + +/// Runs forever, like the juggernaut that it is +/// +/// # Generation logic. +/// +/// This struct continuously generates NTP events as long as the oscillator model will allow. It always aligns +/// with the start of the passed in oscillator. For example, if the oscillator is set to send an NTP packet every +/// 8 seconds (from the local oscillator's timeframe), it will start 8 seconds after the start of the passed in +/// `local_oscillator`. +/// +/// Events are generated, driven by the local oscillator as well. The next poll corresponds to the `client_send_time` +/// in the diagram above. However, the value returned by [`super::Generator::next_event_ready`] corresponds to the +/// `client_recv_time` in the diagram above. +pub struct Generator { + props: Props, + id: String, + next_poll: EstimateDuration, +} + +#[bon::bon] +impl Generator { + /// Construct with builder pattern + #[builder] + pub fn new( + poll_period: EstimateDuration, + id: String, + #[builder(default)] root_dispersion: EstimateDuration, + #[builder(default)] root_delay: EstimateDuration, + #[builder(default)] network_delays: RoundTripDelays, + ) -> Self { + let props = Props { + root_dispersion, + root_delay, + poll_period, + network_delays, + }; + Self { + props, + id, + next_poll: poll_period, + } + } + + /// Return all of the NTP timestamps for an event given an oscillator model + /// + /// Useful if not using this struct as a generator, but as a primitive building block + pub fn calculate_ntp_event_timestamps( + &self, + oscillator: &FullModel, + ) -> Option { + self.props + .network_delays + .calculate_ntp_event_timestamps(self.next_poll, oscillator) + } +} + +impl super::Generator for Generator { + fn next_event_ready(&self, oscillator: &FullModel) -> Option { + let res = self.calculate_ntp_event_timestamps(oscillator)?; + Some(res.client_recv) + } + + fn generate(&mut self, oscillator: &FullModel) -> Event { + let NtpEventTimestamps { + client_send, + server_recv, + server_send, + client_recv, + } = self.calculate_ntp_event_timestamps(oscillator).unwrap(); + + // update next poll time + self.next_poll += self.props.poll_period; + + Event { + variants: EventKind::Ntp(Ntp { + server_system_recv_time: server_recv.demote_to_estimate(), + server_system_send_time: server_send.demote_to_estimate(), + root_delay: self.props.root_delay, + root_dispersion: self.props.root_dispersion, + client_system_times: None, + source_id: self.id.clone(), + }), + client_tsc_pre_time: client_send, + client_tsc_post_time: client_recv, + } + } +} + +#[cfg(test)] +mod test { + use crate::time::{Frequency, Skew, TrueDuration, TrueInstant}; + use rstest::{fixture, rstest}; + + use super::*; + use crate::simulation::{ntp::Generator as _, oscillator::Oscillator}; + + #[fixture] + fn constant_skew() -> FullModel { + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .tsc_timestamp_start(TscCount::new(10_000)) + .skew(Skew::from_ppm(-10.0)) + .starting_oscillator_offset(TrueDuration::from_micros(200)) + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[test] + fn new_defaults() { + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("perfect_test")) + .build(); + assert_eq!(generator.props.root_dispersion, EstimateDuration::default()); + assert_eq!(generator.props.root_delay, EstimateDuration::default()); + assert_eq!(generator.props.poll_period, EstimateDuration::from_secs(16)); + assert_eq!( + generator.props.network_delays.server, + TrueDuration::default() + ); + assert_eq!(generator.id, String::from("perfect_test")); + assert_eq!( + generator.props.network_delays.forward_network, + TrueDuration::default() + ); + assert_eq!( + generator.props.network_delays.backward_network, + TrueDuration::default() + ); + } + + #[test] + fn new() { + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("asdf")) + .root_dispersion(EstimateDuration::new(1)) + .root_delay(EstimateDuration::new(2)) + .network_delays( + RoundTripDelays::builder() + .server(TrueDuration::new(3)) + .forward_network(TrueDuration::new(4)) + .backward_network(TrueDuration::new(5)) + .build(), + ) + .build(); + assert_eq!(generator.props.root_dispersion, EstimateDuration::new(1)); + assert_eq!(generator.props.root_delay, EstimateDuration::new(2)); + assert_eq!(generator.props.poll_period, EstimateDuration::from_secs(16)); + assert_eq!(generator.props.network_delays.server, TrueDuration::new(3)); + assert_eq!( + generator.props.network_delays.forward_network, + TrueDuration::new(4) + ); + assert_eq!( + generator.props.network_delays.backward_network, + TrueDuration::new(5) + ); + } + + #[rstest] + fn next_event_ready(constant_skew: FullModel) { + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("value")) + .build(); + + let next_event_ready = generator.next_event_ready(&constant_skew); + assert_eq!(next_event_ready, Some(TscCount::new(16_000_010_000))); + } + + #[fixture] + fn generator() -> Generator { + Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("value")) + .root_delay(EstimateDuration::from_micros(120)) + .root_dispersion(EstimateDuration::from_micros(230)) + .network_delays( + RoundTripDelays::builder() + .server(TrueDuration::from_micros(35)) + .forward_network(TrueDuration::from_micros(55)) + .backward_network(TrueDuration::from_micros(67)) + .build(), + ) + .build() + } + + #[rstest] + fn generate(constant_skew: FullModel, mut generator: Generator) { + // this test tests against regression values. + // If this test starts failing, evaluate whether or not this + // is the best testing mechanism. + let event = generator.generate(&constant_skew); + assert_eq!(event.client_tsc_post_time, TscCount::new(16_000_166_998)); + let ntp = event.variants.ntp().unwrap(); + assert_eq!(event.client_tsc_pre_time, TscCount::new(16_000_010_000)); + assert_eq!( + ntp.server_system_recv_time.as_nanos(), + 1_576_800_016_000_015_000, + ); + assert_eq!( + ntp.server_system_send_time.as_nanos(), + 1_576_800_016_000_050_000, + ); + assert_eq!(ntp.root_delay, EstimateDuration::from_micros(120)); + assert_eq!(ntp.root_dispersion, EstimateDuration::from_micros(230)); + } + + #[rstest] + fn second_generation(constant_skew: FullModel, mut generator: Generator) { + let _ = generator.generate(&constant_skew); + let event = generator.generate(&constant_skew); + assert_eq!(event.client_tsc_post_time, TscCount::new(32_000_166_998)); + } +} diff --git a/clock-bound-ff-tester/src/simulation/ntp/round_trip_delays.rs b/clock-bound-ff-tester/src/simulation/ntp/round_trip_delays.rs new file mode 100644 index 0000000..8a4f80f --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/ntp/round_trip_delays.rs @@ -0,0 +1,356 @@ +//! Building block types for round trip delay + +use crate::time::{EstimateDuration, TrueDuration, TrueInstant, TscCount}; + +use crate::simulation::{ + delay::{Delay, DelayRng, TimeUnit}, + interpolation::SeriesInterpolation, + oscillator::FullModel, + stats, +}; + +impl Default for Delay { + fn default() -> Self { + Self::new( + stats::DiracDistribution::new(0.0).unwrap(), + TimeUnit::Millis, + ) + } +} + +/// Building block for taking a set of network delays and creating NTP events +#[derive(bon::Builder, Debug)] +pub struct VariableRoundTripDelays { + /// the true time delay of the NTP request from the client to the server + pub forward_network: Box, + /// the true time delay of the NTP reply back from the server to the client + pub backward_network: Box, + /// The true time processing time of the NTP server + pub server: Box, +} + +impl VariableRoundTripDelays { + pub fn generate_round_trip_delays( + &self, + rng: &mut rand_chacha::ChaCha12Rng, + ) -> RoundTripDelays { + RoundTripDelays { + forward_network: self.forward_network.get_value(rng), + backward_network: self.backward_network.get_value(rng), + server: self.server.get_value(rng), + } + } +} + +impl Default for VariableRoundTripDelays { + /// `VariableRoundTripDelays` defaults are determined via analysis for our ec2 fleet. + /// + /// ## TLDR: + /// Forward, backward and server delay values are pulled from gamma distributions. + /// The parameters for each as of 05/30 are: + /// | | shape | scale | loc | + /// |--- |--- |--- |--- | + /// | `forward_network` | 0.176 | 0.101 | 2.38E-7 | + /// | `backward_network`| 0.176 | 0.101 | 2.38E-7 | + /// | `server` | 0.0056 | 0.0158 | 2.38E-7 | + /// + /// This should be used when attempting to model plausible networking delay. + fn default() -> Self { + VariableRoundTripDelays::builder() + .forward_network(Box::new(Delay::new( + stats::GammaDistribution::new(0.176, 1.0 / 0.101, 2.38 * 10.0f64.powi(-7)).unwrap(), + TimeUnit::Secs, + ))) + .backward_network(Box::new(Delay::new( + stats::GammaDistribution::new(0.176, 1.0 / 0.101, 2.38 * 10.0f64.powi(-7)).unwrap(), + TimeUnit::Secs, + ))) + .server(Box::new(Delay::new( + stats::GammaDistribution::new(0.0056, 1.0 / 0.0158, 2.38 * 10.0f64.powi(-7)) + .unwrap(), + TimeUnit::Secs, + ))) + .build() + } +} + +/// Building block for taking a set of network delays and creating NTP events +#[derive(Debug, Clone, bon::Builder, PartialEq, Default)] +pub struct RoundTripDelays { + /// the true time delay of the NTP request from the client to the server + pub forward_network: TrueDuration, + /// the true time delay of the NTP reply back from the server to the client + pub backward_network: TrueDuration, + /// The true time processing time of the NTP server + pub server: TrueDuration, +} + +impl RoundTripDelays { + /// Returns the `forward_network_delay` + pub fn forward_network(&self) -> TrueDuration { + self.forward_network + } + + /// Returns the `backward_network_delay` + pub fn backward_network(&self) -> TrueDuration { + self.backward_network + } + + /// Returns the `server_delay` + pub fn server(&self) -> TrueDuration { + self.server + } + + /// Calculate the NTP event timestamps driven by the local oscillator timestamp + /// + /// - `oscillator_estimated_send_time` is the estimated time after the start of the oscillator model when the local client sends the NTP response. + /// - `oscillator` is the model of the local hardware oscillator + pub fn calculate_ntp_event_timestamps( + &self, + oscillator_estimated_send_delay: EstimateDuration, + oscillator: &FullModel, + ) -> Option { + // This algorithm works well by converting between the TSC domain to the TrueTime domain and back again. + // + // While this may *seem* roundabout, it's a simple way to understand the problem. Convert your start time into the True Time domain, + // do your operations, and then convert back. + + // First we need the tsc timestamp at the client send time + let client_send = oscillator_estimated_send_delay + * oscillator.oscillator().clock_frequency() + + oscillator.oscillator().tsc_timestamp_start(); + + // Then, convert this tsc timestamp into a true time with a reverse linear interpolation + let client_send_true = oscillator + .true_time_to_tsc_timestamps() + .as_ref() + .reverse_approximate(client_send)?; + + // then add network and server delays + let server_recv = client_send_true + self.forward_network; + let server_send = server_recv + self.server; + let client_recv = server_send + self.backward_network; + + // then convert back to the tsc timestamp domain + let client_recv = oscillator + .true_time_to_tsc_timestamps() + .as_ref() + .approximate(client_recv)?; + + Some(NtpEventTimestamps { + client_send, + server_recv, + server_send, + client_recv, + }) + } + + /// Returns the total network delay + pub fn network(&self) -> TrueDuration { + self.forward_network + self.backward_network + } +} + +/// Output of [`NetworkChannel::calculate_ntp_event_timestamps`] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NtpEventTimestamps { + /// The tsc timestamp that the client sends the NTP request + pub client_send: TscCount, + /// The true time that the server receives the NTP request + pub server_recv: TrueInstant, + /// The true time that the server sends the NTP response + pub server_send: TrueInstant, + /// The tsc timestamp that the client receives the NTP response + pub client_recv: TscCount, +} + +#[cfg(test)] +mod test { + use crate::time::{AssumeTrue, Frequency, Skew}; + + use rand_chacha; + use rand_chacha::rand_core::SeedableRng; + use rstest::{fixture, rstest}; + + use crate::simulation::{ + oscillator::Oscillator, stats::GammaDistribution, stats::NormalDistribution, + }; + + use super::*; + + #[fixture] + fn perfect_oscillator() -> FullModel { + // A perfect oscillator with a 1GHz frequency is a perfect monotonic tsc clock (aka 1 tick vs nanosecond) + // This makes math a lot easier for the basic case + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[fixture] + fn positive_skew_oscillator() -> FullModel { + // A positive skew oscillator with a 1GHz frequency is a perfect monotonic tsc clock (aka 1 tick vs nanosecond) + // This makes math a lot easier for the basic case + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .skew(Skew::from_percent(5.0)) // wow now thats I call a bad oscillator + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[rstest] + fn calculate_ntp_event_timestamps_perfect(perfect_oscillator: FullModel) { + // expectations + // + // The oscillator has no offsets, so therefore the ntp event timestamps should be easy to calculate + // This is doubly so since the oscillator under test has a 1GHz clock. Meaning that TSCs have + // the same frequency as a 1 nanosecond clock in TrueTime and EstimateTime types + let start_time = perfect_oscillator.oscillator().start_time(); + let forward_network = TrueDuration::from_micros(50); + let backward_network = TrueDuration::from_micros(55); + let client_start = EstimateDuration::from_millis(100); + let server = TrueDuration::from_micros(27); + + let delays = RoundTripDelays { + forward_network, + backward_network, + server, + }; + + let event = delays.calculate_ntp_event_timestamps(client_start, &perfect_oscillator); + + assert_eq!( + event, + Some(NtpEventTimestamps { + client_send: TscCount::new(client_start.as_nanos()), + server_recv: start_time + client_start.assume_true() + forward_network, + server_send: start_time + client_start.assume_true() + forward_network + server, + client_recv: TscCount::new( + (client_start.assume_true() + forward_network + server + backward_network) + .as_nanos() + ), + }) + ); + } + + #[rstest] + fn calculate_ntp_event_timestamps_positive_skew(positive_skew_oscillator: FullModel) { + // expectations + // + // The generator in this test has a 1GHz clock, which matches a 1 nanosecond interval in + // `ff-tester` time types. This means that a skew should be easy to test with comparison operators + let start_time = positive_skew_oscillator.oscillator().start_time(); + let forward_network = TrueDuration::from_micros(50); + let backward_network = TrueDuration::from_micros(55); + let client_start = EstimateDuration::from_millis(100); + let server = TrueDuration::from_micros(27); + + let delays = RoundTripDelays { + forward_network, + backward_network, + server, + }; + + let event = delays.calculate_ntp_event_timestamps(client_start, &positive_skew_oscillator); + + let timestamps = event.unwrap(); + + // the client_send tsc timestamp and client_start estimate time should agree. + assert_eq!( + timestamps.client_send, + TscCount::new(client_start.as_nanos()) + ); + + // Because the oscillator is skewed fast, the server recv time represents true time. + // This inequality shows that the true time is actually earlier than the normal calculations would expect + assert!(timestamps.server_recv < start_time + client_start.assume_true() + forward_network); + assert!( + timestamps.server_send + < start_time + client_start.assume_true() + forward_network + server + ); + + // The corollary to above: when the reply comes back, the local oscillator increased at a faster rate than the true time during the NTP exchange + assert!( + timestamps.client_recv + > TscCount::new( + (client_start.assume_true() + forward_network + server + backward_network) + .as_nanos() + ) + ); + } + #[rstest] + #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] + fn validate_networkdelay_from_distributions() { + // expectations + // + //This is intended to test the generation of a NTP packet from [`VariableRoundTripDelays`]. + //We expect for network delays in the packet to loosely align with the means of the gamma, normal and Dirac probability distributions. + let gamma_shape = 1.0; + let gamma_rate = 1.0; + let forward_network_distribution = + GammaDistribution::new(gamma_shape, gamma_rate, 0.0).unwrap(); + + let normal_mean = 2.0; + let normal_std = 0.5; + let backward_network_distribution = + NormalDistribution::new(normal_mean, normal_std).unwrap(); + let server_delay_tsc = 27; + let server = TrueDuration::from_micros(server_delay_tsc); + + let variable_delay = VariableRoundTripDelays { + forward_network: Box::new(Delay { + distribution: forward_network_distribution, + unit: TimeUnit::Micros, + }), + backward_network: Box::new(Delay { + distribution: backward_network_distribution, + unit: TimeUnit::Millis, + }), + // NOTE + // If we want apply a constant value that doesn't change, as in there is no variation, + // this is how we do it. + server: Box::new(Delay { + distribution: stats::DiracDistribution::new(server_delay_tsc as f64).unwrap(), + unit: TimeUnit::Micros, + }), + }; + + let mut rng = rand_chacha::ChaCha12Rng::from_seed(Default::default()); + let mut delay_values = vec![]; + for _ in 0..1000 { + delay_values.push(variable_delay.generate_round_trip_delays(&mut rng)); + } + + let (mean_forward_network_delay, mean_backward_network_delay, mean_server) = { + let mut rv_fnd = TrueDuration::from_secs(0); + let mut rv_bnd = TrueDuration::from_secs(0); + let mut rv_s = TrueDuration::from_secs(0); + + for it in &delay_values { + rv_fnd += it.forward_network; + rv_bnd += it.backward_network; + rv_s += it.server; + } + + #[allow(clippy::cast_possible_wrap)] + let n = delay_values.len(); + (rv_fnd / n, rv_bnd / n, rv_s / n) + }; + assert!( + (mean_forward_network_delay + - TrueDuration::from_micros((gamma_shape / gamma_rate) as i128)) + .abs() + < TrueDuration::from_micros((gamma_shape / gamma_rate.powi(2)) as i128) + ); + assert!( + (mean_backward_network_delay - TrueDuration::from_millis(normal_mean as i128)).abs() + < TrueDuration::from_micros(500) + ); + assert_eq!(mean_server, server); + } +} diff --git a/clock-bound-ff-tester/src/simulation/ntp/series.rs b/clock-bound-ff-tester/src/simulation/ntp/series.rs new file mode 100644 index 0000000..289e68b --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/ntp/series.rs @@ -0,0 +1,1100 @@ +//! An ntp source pre-configurable with a series of values for key variables +//! such as rood delay, root dispersion, and rtt variables + +use crate::simulation::{interpolation::SeriesInterpolation, oscillator::FullModel}; + +use super::round_trip_delays::{NtpEventTimestamps, RoundTripDelays}; +use crate::events::v1::{Event, EventKind, Ntp}; +use crate::time::{AssumeTrue, DemoteToEstimate, EstimateDuration, Series, TrueDuration, TscCount}; +use thiserror; + +/// Error types for NTP generator validation and initialization +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Series {data_series} must have at least {min} points, found {actual}.")] + TooFewDataPoints { + data_series: &'static str, + min: usize, + actual: usize, + }, + #[error( + "Series {data_series} must start at simulation time {required:?}, found first data point at {first_data_tstamp:?}." + )] + IllegalStartTime { + data_series: &'static str, + required: TrueDuration, + first_data_tstamp: TrueDuration, + }, + #[error( + "Series {data_series} must span at least one polling period of {poll_period:?}, found last data point at {last_data_tstamp:?}." + )] + IllegalEndTime { + data_series: &'static str, + poll_period: EstimateDuration, + last_data_tstamp: TrueDuration, + }, + #[error("Poll period must be at minimum {min:?}, found {actual:?}.")] + IllegalPollPeriod { + min: EstimateDuration, + actual: EstimateDuration, + }, +} + +/// Holds a time series of network forward delays from client to server +#[derive(Debug, Clone)] +pub struct ForwardDelays { + pub inner: Series, +} + +impl ForwardDelays { + /// Create a new forward delay series + pub fn new(inner: Series) -> Self { + Self { inner } + } + + /// Return the time series indices + pub fn indices(&self) -> &[TrueDuration] { + self.inner.indices() + } + + /// Return the forward delays of the time series + pub fn data(&self) -> &[TrueDuration] { + self.inner.data() + } + + /// Return the approximate data at the given time index + pub fn approximate(&self, x: TrueDuration) -> Option { + self.inner.approximate(x) + } +} + +/// Holds a time series of network backward delays from server to client +#[derive(Debug, Clone)] +pub struct BackwardDelays { + pub inner: Series, +} + +impl BackwardDelays { + /// Create a new backward delay series + pub fn new(inner: Series) -> Self { + Self { inner } + } + + /// Return the time series indices + pub fn indices(&self) -> &[TrueDuration] { + self.inner.indices() + } + + /// Return the backward delays of the time series + pub fn data(&self) -> &[TrueDuration] { + self.inner.data() + } + + /// Return the approximate data at the given time index + pub fn approximate(&self, x: TrueDuration) -> Option { + self.inner.approximate(x) + } +} + +/// Holds a time series of server processing delays +#[derive(Debug, Clone)] +pub struct ServerDelays { + pub inner: Series, +} + +impl ServerDelays { + /// Create a new server delay series + pub fn new(inner: Series) -> Self { + Self { inner } + } + + /// Return the time series indices + pub fn indices(&self) -> &[TrueDuration] { + self.inner.indices() + } + + /// Return the server delays of the time series + pub fn data(&self) -> &[TrueDuration] { + self.inner.data() + } + + /// Return the approximate data at the given time index + pub fn approximate(&self, x: TrueDuration) -> Option { + self.inner.approximate(x) + } +} + +/// Configuration properties for the time series NTP generator +/// +/// Contains all the time series that define how NTP variables change over time, +/// including root delays, root dispersions, and round-trip delay components. +#[derive(Debug, Clone)] +pub struct Props { + pub poll_period: EstimateDuration, + pub root_delays: Series, + pub root_dispersions: Series, + pub rtt_backwards_delays: BackwardDelays, + pub rtt_forward_delays: ForwardDelays, + pub rtt_server_delays: ServerDelays, +} + +/// An NTP source that varies over time based on pre-configured time series +/// +/// This generator produces NTP events with time-varying properties (root delay, +/// root dispersion, network delays) defined by input time series. It automatically +/// handles interpolation to provide continuous values between defined data points. +/// +/// The generator respects the input constraints of NTP exchanges and will stop +/// generating events once it reaches the end of the shortest input time series. +pub struct Generator { + props: Props, + id: String, + next_poll: EstimateDuration, + stop_after: TrueDuration, +} + +#[bon::bon] +impl Generator { + /// Creates a new time-varying NTP generator based on provided time series + /// + /// # Parameters + /// * `poll_period` - Time between successive NTP requests + /// * `id` - Identifier for the NTP source + /// * `root_dispersions` - Time series of root dispersion values + /// * `root_delays` - Time series of root delay values + /// * `round_trip_delays` - Time series of network and server delay components + /// + /// # Returns + /// A Result containing either the configured generator or an Error if any validation fails + /// + /// # Errors + /// Returns an error if any time series doesn't meet requirements: + /// - At least 2 data points + /// - Must start at time 0 + /// - Must span at least one poll period + #[builder] + pub fn new( + poll_period: EstimateDuration, + id: String, + root_dispersions: Series, + root_delays: Series, + round_trip_delays: &Series, + ) -> Result { + Generator::validate_poll_period(poll_period)?; + Generator::validate_root_delays(&root_delays, poll_period)?; + Generator::validate_root_dispersions(&root_dispersions, poll_period)?; + Generator::validate_round_trip_delays(round_trip_delays, poll_period)?; + + let min_series_last_time = [ + *root_delays + .indices() + .last() + .expect("Root delays series should not be empty."), + *root_dispersions + .indices() + .last() + .expect("Root dispersions series should not be empty."), + *round_trip_delays + .indices() + .last() + .expect("Round trip segment delays series should not be empty."), + ] + .into_iter() + .min() + .unwrap(); + + let (rtt_backwards_delays, rtt_forward_delays, rtt_server_delays) = + Generator::destructure_round_trip_delays(round_trip_delays); + + let props: Props = Props { + poll_period, + root_delays, + root_dispersions, + rtt_backwards_delays, + rtt_forward_delays, + rtt_server_delays, + }; + + Ok(Self { + props, + id, + next_poll: poll_period, + stop_after: min_series_last_time, + }) + } + + /// Calculates all timestamps for an NTP event based on the current state + /// + /// Returns the client send/receive times and server receive/send times for + /// the next NTP event, using the oscillator model to account for clock skew. + /// + /// # Returns + /// `Some(NtpEventTimestamps)` if the next event is within bounds, `None` if no more events + pub fn calculate_ntp_event_timestamps( + &self, + oscillator: &FullModel, + ) -> Option { + self.get_next_round_trip_delays(oscillator)? + .calculate_ntp_event_timestamps(self.next_poll, oscillator) + } + + /// Determines if another NTP event can be generated within the time series bounds + /// + /// # Returns + /// `true` if the next poll time is within the bounds of all time series, `false` otherwise + fn has_next(&self, oscillator_model: &FullModel) -> bool { + oscillator_model + .oscillator_estimate_to_true_duration(self.next_poll) + .is_some_and(|true_next_poll| true_next_poll <= self.stop_after) + } + + /// Gets the round trip delay components for the next NTP event + /// + /// Retrieves forward, backward and server delay values for the next poll time + /// by interpolating from the configured time series. + /// + /// # Returns + /// `Some(RoundTripDelays)` if the next event is within bounds, `None` if no more events + fn get_next_round_trip_delays(&self, oscillator_model: &FullModel) -> Option { + if !self.has_next(oscillator_model) { + return None; + } + let true_next_poll = + oscillator_model.oscillator_estimate_to_true_duration(self.next_poll)?; + Some(RoundTripDelays { + forward_network: self + .props + .rtt_forward_delays + .approximate(true_next_poll) + .unwrap_or_else(|| panic!( + "There should be a forward delay to approximate, given the poll time {:?} is no later than the last round trip delays series time {:?}.", + true_next_poll, self.props.rtt_forward_delays.indices().last() + )), + backward_network: self + .props + .rtt_backwards_delays + .approximate(true_next_poll) + .unwrap_or_else(|| panic!( + "There should be a backward delay to approximate, given the poll time {:?} is no later than the last round trip delays series time {:?}.", + true_next_poll, self.props.rtt_backwards_delays.indices().last() + )), + server: self + .props + .rtt_server_delays + .approximate(true_next_poll) + .unwrap_or_else(|| panic!( + "There should be a server delay to approximate, given the poll time {:?} is no later than the last round trip delays series time {:?}.", + true_next_poll, self.props.rtt_server_delays.indices().last() + )), + }) + } + + /// Validates that root dispersion series meets requirements + /// + /// # Errors + /// Returns an error if the series: + /// - Has fewer than 2 data points + /// - Doesn't start at time 0 + /// - Doesn't span at least one poll period + fn validate_root_dispersions( + root_dispersions: &Series, + poll_period: EstimateDuration, + ) -> Result<(), Error> { + if root_dispersions.len() < 2 { + return Err(Error::TooFewDataPoints { + data_series: "root_dispersions", + min: 2, + actual: root_dispersions.len(), + }); + } + + if root_dispersions + .indices() + .first() + .is_none_or(|first| *first != TrueDuration::from_secs(0)) + { + return Err(Error::IllegalStartTime { + data_series: "root_dispersions", + required: TrueDuration::from_secs(0), + first_data_tstamp: *root_dispersions.indices().first().unwrap(), + }); + } + + if root_dispersions + .indices() + .last() + .is_none_or(|last| *last < poll_period.assume_true()) + { + return Err(Error::IllegalEndTime { + data_series: "root_dispersions", + poll_period, + last_data_tstamp: *root_dispersions.indices().last().unwrap(), + }); + } + + Ok(()) + } + + /// Validates that root delay series meets requirements + /// + /// # Errors + /// Returns an error if the series: + /// - Has fewer than 2 data points + /// - Doesn't start at time 0 + /// - Doesn't span at least one poll period + fn validate_root_delays( + root_delays: &Series, + poll_period: EstimateDuration, + ) -> Result<(), Error> { + if root_delays.len() < 2 { + return Err(Error::TooFewDataPoints { + data_series: "root_delays", + min: 2, + actual: root_delays.len(), + }); + } + + if root_delays + .indices() + .first() + .is_none_or(|first| *first != TrueDuration::from_secs(0)) + { + return Err(Error::IllegalStartTime { + data_series: "root_delays", + required: TrueDuration::from_secs(0), + first_data_tstamp: *root_delays.indices().first().unwrap(), + }); + } + + if root_delays + .indices() + .last() + .is_none_or(|last| *last < poll_period.assume_true()) + { + return Err(Error::IllegalEndTime { + data_series: "root_delays", + poll_period, + last_data_tstamp: *root_delays.indices().last().unwrap(), + }); + } + + Ok(()) + } + + /// Validates that round trip delays series meets requirements + /// + /// # Errors + /// Returns an error if the series: + /// - Has fewer than 2 data points + /// - Doesn't start at time 0 + /// - Doesn't span at least one poll period + fn validate_round_trip_delays( + round_trip_delays: &Series, + poll_period: EstimateDuration, + ) -> Result<(), Error> { + if round_trip_delays.len() < 2 { + return Err(Error::TooFewDataPoints { + data_series: "round_trip_delays", + min: 2, + actual: round_trip_delays.len(), + }); + } + if round_trip_delays + .indices() + .first() + .is_none_or(|first| *first != TrueDuration::from_secs(0)) + { + return Err(Error::IllegalStartTime { + data_series: "round_trip_delays", + required: TrueDuration::from_secs(0), + first_data_tstamp: *round_trip_delays.indices().first().unwrap(), + }); + } + + if round_trip_delays + .indices() + .last() + .is_none_or(|last| *last < poll_period.assume_true()) + { + return Err(Error::IllegalEndTime { + data_series: "round_trip_delays", + poll_period, + last_data_tstamp: *round_trip_delays.indices().last().unwrap(), + }); + } + + Ok(()) + } + + /// Validates the polling period + /// + /// # Errors + /// Returns an error if the poll period is zero or negative + fn validate_poll_period(poll_period: EstimateDuration) -> Result<(), Error> { + if poll_period <= EstimateDuration::from_secs(0) { + return Err(Error::IllegalPollPeriod { + min: EstimateDuration::from_secs(1), + actual: poll_period, + }); + } + Ok(()) + } + + /// Splits a round trip delays series into its component delay series + /// + /// Creates separate time series for forward network delays, backward network delays, + /// and server processing delays to simplify interpolation for each component. + /// + /// # Returns + /// A tuple of (`BackwardDelays`, `ForwardDelays`, `ServerDelays`) series + fn destructure_round_trip_delays( + round_trip_delays: &Series, + ) -> (BackwardDelays, ForwardDelays, ServerDelays) { + // Create new series for each component + let backward_delays = round_trip_delays + .iter() + .map(|(t, rtd)| (*t, rtd.backward_network)) + .collect(); + + let forward_delays = round_trip_delays + .iter() + .map(|(t, rtd)| (*t, rtd.forward_network)) + .collect(); + + let server_delays = round_trip_delays + .iter() + .map(|(t, rtd)| (*t, rtd.server)) + .collect(); + + ( + BackwardDelays { + inner: backward_delays, + }, + ForwardDelays { + inner: forward_delays, + }, + ServerDelays { + inner: server_delays, + }, + ) + } +} + +impl super::Generator for Generator { + fn next_event_ready(&self, oscillator: &FullModel) -> Option { + if !self.has_next(oscillator) { + return None; + } + let ntp_event_tstamps = self.calculate_ntp_event_timestamps(oscillator)?; + Some(ntp_event_tstamps.client_recv) + } + + fn generate(&mut self, oscillator: &FullModel) -> Event { + let NtpEventTimestamps { + client_send, + server_recv, + server_send, + client_recv, + } = self + .calculate_ntp_event_timestamps(oscillator) + .expect("Next NTP event's timestamps should be computable based on the oscillator model if next_event_ready."); + + let true_next_poll = oscillator + .oscillator_estimate_to_true_duration(self.next_poll) + .expect("True next poll time should be computable based on the oscillator model estimate of the same if next_event_ready."); + + let root_delay = self + .props + .root_delays + .approximate(true_next_poll) + .unwrap_or_else(|| panic!( + "Root delay at true next poll time {:?} should be computable, given poll time is no later than the generator's last root delays series time {:?}, if next_event_ready.", + true_next_poll, self.props.root_delays.indices().last() + )); + + let root_dispersion = self + .props + .root_dispersions + .approximate(true_next_poll) + .unwrap_or_else(|| panic!( + "Root dispersion at true next poll time {:?} should be computable, given poll time is no later than the generator's last root dispersions series time {:?}, if next_event_ready.", + true_next_poll, self.props.root_dispersions.indices().last() + )); + + self.next_poll += self.props.poll_period; + + Event { + variants: EventKind::Ntp(Ntp { + server_system_recv_time: server_recv.demote_to_estimate(), + server_system_send_time: server_send.demote_to_estimate(), + root_delay, + root_dispersion, + source_id: self.id.clone(), + client_system_times: None, + }), + client_tsc_pre_time: client_send, + client_tsc_post_time: client_recv, + } + } +} + +#[cfg(test)] +mod test { + use crate::time::{Frequency, Skew, TrueDuration, TrueInstant}; + use rstest::{fixture, rstest}; + + use super::*; + use crate::simulation::{ntp::Generator as _, oscillator::Oscillator}; + + #[fixture] + fn clock_freq_ghz() -> f64 { + 1.0 + } + + #[fixture] + fn true_time_start_nanos() -> i128 { + 1_576_800_000_000_000_000 // 365 * 50 * 24 * 60 * 60 * 1000 * 1000 * 1000 + } + + #[fixture] + fn oscillator_skew_ppm() -> f64 { + -10.0 + } + + #[fixture] + fn tsc_timestamp_client_start() -> i128 { + 10_000 + } + + #[fixture] + fn starting_oscillator_offset_nanos() -> i128 { + 200_000 + } + + #[fixture] + fn constant_skew_oscillator_model( + clock_freq_ghz: f64, + true_time_start_nanos: i128, + oscillator_skew_ppm: f64, + tsc_timestamp_client_start: i128, + starting_oscillator_offset_nanos: i128, + ) -> FullModel { + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(clock_freq_ghz)) + .start_time(TrueInstant::from_nanos(true_time_start_nanos)) + .duration(TrueDuration::from_hours(5)) + .tsc_timestamp_start(TscCount::new(tsc_timestamp_client_start)) + .skew(Skew::from_ppm(oscillator_skew_ppm)) + .starting_oscillator_offset(TrueDuration::from_nanos(starting_oscillator_offset_nanos)) + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[fixture] + fn poll_period() -> i128 { + 16 + } + + #[fixture] + fn generator(poll_period: i128) -> Generator { + let (root_delays, root_dispersions, rtds) = valid_series(); + Generator::builder() + .poll_period(EstimateDuration::from_secs(poll_period)) + .id(String::from("series_test")) + .root_delays(root_delays) + .root_dispersions(root_dispersions) + .round_trip_delays(&rtds) + .build() + .unwrap() + } + + #[fixture] + fn valid_series() -> ( + Series, + Series, + Series, + ) { + let root_delays_data = [ + // (timestamp_secs, micros) + (0, 100), + (32, 150), + (96, 200), + ]; + let root_dispersions_data = [ + // (timestamp_secs, micros) + (0, 200), + (32, 250), + (96, 300), + ]; + let rtt_delays_data = [ + // (timestamp_secs, forward_μs, backward_μs, server_μs) + (0, 50, 50, 40), + (16, 80, 40, 60), + (32, 50, 70, 40), + (33, 50, 70, 40), + ]; + + let root_delays = root_delays_data + .iter() + .map(|(secs, micros)| { + ( + TrueDuration::from_secs(*secs), + EstimateDuration::from_micros(*micros), + ) + }) + .collect(); + + let root_dispersions = root_dispersions_data + .iter() + .map(|(secs, micros)| { + ( + TrueDuration::from_secs(*secs), + EstimateDuration::from_micros(*micros), + ) + }) + .collect(); + + let rtt_delays = rtt_delays_data + .iter() + .map(|(secs, fwd, bwd, svr)| { + ( + TrueDuration::from_secs(*secs), + RoundTripDelays::builder() + .forward_network(TrueDuration::from_micros(*fwd)) + .backward_network(TrueDuration::from_micros(*bwd)) + .server(TrueDuration::from_micros(*svr)) + .build(), + ) + }) + .collect(); + + (root_delays, root_dispersions, rtt_delays) + } + + #[test] + fn test_successful_initialization() { + let (root_delays, root_dispersions, rtds) = valid_series(); + + let generator_result = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("series_test")) + .root_delays(root_delays.clone()) + .root_dispersions(root_dispersions.clone()) + .round_trip_delays(&rtds.clone()) + .build(); + + assert!(generator_result.is_ok()); + let generator = generator_result.unwrap(); + assert_eq!(generator.id, String::from("series_test")); + assert_eq!(generator.next_poll, EstimateDuration::from_secs(16)); + assert_eq!(generator.stop_after, TrueDuration::from_secs(33)); // Min of all last times + } + + #[test] + fn test_validation_too_few_data_points() { + // Create series with only one point + let root_delays: Series = vec![( + TrueDuration::from_secs(0), + EstimateDuration::from_micros(100), + )] + .into_iter() + .collect(); + + let (_, root_dispersions, rtds) = valid_series(); + + let result = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("series_test")) + .root_delays(root_delays) + .root_dispersions(root_dispersions) + .round_trip_delays(&rtds) + .build(); + + assert!(result.is_err()); + match result { + Err(Error::TooFewDataPoints { + data_series, + min, + actual, + }) => { + assert_eq!(data_series, "root_delays"); + assert_eq!(min, 2); + assert_eq!(actual, 1); + } + _ => panic!("Expected TooFewDataPoints error"), + } + } + + #[test] + fn test_validation_illegal_start_time() { + // Create series with non-zero start time + let root_delays: Series = vec![ + ( + TrueDuration::from_secs(5), + EstimateDuration::from_micros(100), + ), + ( + TrueDuration::from_secs(50), + EstimateDuration::from_micros(150), + ), + ] + .into_iter() + .collect(); + + let (_, root_dispersions, rtt_delays) = valid_series(); + + let result = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("series_test")) + .root_delays(root_delays) + .root_dispersions(root_dispersions) + .round_trip_delays(&rtt_delays) + .build(); + + assert!(result.is_err()); + match result { + Err(Error::IllegalStartTime { + data_series, + required, + first_data_tstamp, + }) => { + assert_eq!(data_series, "root_delays"); + assert_eq!(required, TrueDuration::from_secs(0)); + assert_eq!(first_data_tstamp, TrueDuration::from_secs(5)); + } + _ => panic!("Expected IllegalStartTime error"), + } + } + + #[test] + fn test_validation_illegal_end_time() { + // Create series that doesn't span a full poll period + let rtt_delays: Series = vec![ + ( + TrueDuration::from_secs(0), + RoundTripDelays::builder() + .forward_network(TrueDuration::from_micros(55)) + .backward_network(TrueDuration::from_micros(67)) + .server(TrueDuration::from_micros(35)) + .build(), + ), + ( + TrueDuration::from_secs(10), + RoundTripDelays::builder() + .forward_network(TrueDuration::from_micros(60)) + .backward_network(TrueDuration::from_micros(70)) + .server(TrueDuration::from_micros(40)) + .build(), + ), + ] + .into_iter() + .collect(); + + let (root_delays, root_dispersions, _) = valid_series(); + + let result = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("series_test")) + .root_delays(root_delays) + .root_dispersions(root_dispersions) + .round_trip_delays(&rtt_delays) + .build(); + + assert!(result.is_err()); + match result { + Err(Error::IllegalEndTime { + data_series, + poll_period, + last_data_tstamp, + }) => { + assert_eq!(data_series, "round_trip_delays"); + assert_eq!(poll_period, EstimateDuration::from_secs(16)); + assert_eq!(last_data_tstamp, TrueDuration::from_secs(10)); + } + _ => panic!("Expected IllegalEndTime error"), + } + } + + #[test] + fn test_validation_illegal_poll_period() { + let (root_delays, root_dispersions, rtds) = valid_series(); + + let result = Generator::builder() + .poll_period(EstimateDuration::from_secs(0)) + .id(String::from("series_test")) + .root_delays(root_delays) + .root_dispersions(root_dispersions) + .round_trip_delays(&rtds) + .build(); + + assert!(result.is_err()); + match result { + Err(Error::IllegalPollPeriod { min, actual }) => { + assert_eq!(min, EstimateDuration::from_secs(1)); + assert_eq!(actual, EstimateDuration::from_secs(0)); + } + _ => panic!("Expected IllegalPollPeriod error"), + } + } + + #[rstest] + fn test_next_event_ready(constant_skew_oscillator_model: FullModel, mut generator: Generator) { + let next_event_ready = generator.next_event_ready(&constant_skew_oscillator_model); + assert!(next_event_ready.is_some()); + let _ = generator.generate(&constant_skew_oscillator_model); + } + + #[rstest] + fn test_generate(constant_skew_oscillator_model: FullModel, mut generator: Generator) { + let event = generator.generate(&constant_skew_oscillator_model); + + // Check source ID + let ntp = event.variants.ntp().unwrap(); + assert_eq!(ntp.source_id, "series_test"); + + // Check event type and timestamps + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let client_start_timestamp_in_nanos = + (tsc_timestamp_client_start() as f64 / clock_freq_ghz()) as i128; + assert_eq!( + event.client_tsc_pre_time, + TscCount::new(poll_period() * 1_000_000_000 + client_start_timestamp_in_nanos) + ); + + let client_start_instant = poll_period() * 1_000_000_000 + client_start_timestamp_in_nanos; + let client_start_instant = TrueInstant::from_nanos(client_start_instant); + + let expected = client_start_instant + + generator + .props + .rtt_forward_delays + .approximate(TrueDuration::from_secs(poll_period())) + .unwrap() + + generator + .props + .rtt_server_delays + .approximate(TrueDuration::from_secs(poll_period())) + .unwrap() + + generator + .props + .rtt_backwards_delays + .approximate(TrueDuration::from_secs(poll_period())) + .unwrap(); + + approx::assert_abs_diff_eq!( + event.client_tsc_post_time.get() as i64, + expected.as_nanos() as i64, + epsilon = 2 // 2 ns tolerance + ); + + #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] + let client_skew_between_polls_nanos = + poll_period() as f64 * -oscillator_skew_ppm() * 1_000.0; + + let expected = constant_skew_oscillator_model.oscillator().start_time() + + TrueDuration::from_nanos(poll_period() * 1_000_000_000) + - TrueDuration::from_nanos(starting_oscillator_offset_nanos()) + + TrueDuration::from_seconds_f64(client_skew_between_polls_nanos / 1e9) + + generator + .props + .rtt_forward_delays + .approximate(TrueDuration::from_secs(poll_period())) + .unwrap(); + + approx::assert_abs_diff_eq!( + ntp.server_system_recv_time.as_nanos() as i64, + expected.as_nanos() as i64, + epsilon = 1, + ); + + let expected = ntp.server_system_recv_time.assume_true() + + generator + .props + .rtt_server_delays + .approximate(TrueDuration::from_secs(poll_period())) + .unwrap(); + + approx::assert_abs_diff_eq!( + ntp.server_system_send_time.as_nanos() as i64, + expected.as_nanos() as i64, + epsilon = 1, + ); + + let expected_root_delay = EstimateDuration::from_micros(125); + let root_delay_delta = (ntp.root_delay.as_nanos() - expected_root_delay.as_nanos()).abs(); + assert!( + root_delay_delta <= 1, + "Root delay difference from expected is {:?} ns, expected <= 1 ns. Actual: {:?}, Expected: {:?}", + root_delay_delta, + ntp.root_delay, + expected_root_delay + ); + + let expected_root_dispersion = EstimateDuration::from_micros(225); + let root_dispersion_delta = + (ntp.root_dispersion.as_nanos() - expected_root_dispersion.as_nanos()).abs(); + assert!( + root_dispersion_delta <= 1, + "Root dispersion difference from expected is {:?} ns, expected <= 1 ns. Actual: {:?}, Expected: {:?}", + root_dispersion_delta, + ntp.root_dispersion, + expected_root_dispersion + ); + } + + #[rstest] + fn test_second_generation(constant_skew_oscillator_model: FullModel, mut generator: Generator) { + let first_event = generator.generate(&constant_skew_oscillator_model); + let second_event = generator.generate(&constant_skew_oscillator_model); + + let first_ntp = first_event.variants.ntp().unwrap(); + let second_ntp = second_event.variants.ntp().unwrap(); + + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let first_client_send_timestamp_in_nanos = + (first_event.client_tsc_pre_time.get() as f64 / clock_freq_ghz()) as i128; + + assert_eq!( + second_event.client_tsc_pre_time, + TscCount::new(poll_period() * 1_000_000_000 + first_client_send_timestamp_in_nanos) + ); + + let expected = TrueInstant::from_nanos(poll_period() * 1_000_000_000) + + TrueDuration::from_nanos(first_client_send_timestamp_in_nanos) + + generator + .props + .rtt_forward_delays + .approximate(TrueDuration::from_secs(2 * poll_period())) + .unwrap() + + generator + .props + .rtt_server_delays + .approximate(TrueDuration::from_secs(2 * poll_period())) + .unwrap() + + generator + .props + .rtt_backwards_delays + .approximate(TrueDuration::from_secs(2 * poll_period())) + .unwrap(); + + // relies on 1GHz clock + approx::assert_abs_diff_eq!( + second_event.client_tsc_post_time.get() as i64, + expected.as_nanos() as i64, + epsilon = 2 // 2 ns tolerance + ); + + #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] + let client_skew_between_polls_nanos = + (poll_period() as f64 * -oscillator_skew_ppm() * 1_000.0) as i128; + + let expected = first_ntp.server_system_recv_time.assume_true() + - generator + .props + .rtt_forward_delays + .approximate(TrueDuration::from_secs(poll_period())) + .unwrap() + + TrueDuration::from_nanos(poll_period() * 1_000_000_000) + + TrueDuration::from_nanos(client_skew_between_polls_nanos) + + generator + .props + .rtt_forward_delays + .approximate(TrueDuration::from_secs(2 * poll_period())) + .unwrap(); + + approx::assert_abs_diff_eq!( + second_ntp.server_system_recv_time.as_nanos() as i64, + expected.as_nanos() as i64, + epsilon = 1, + ); + + assert_eq!( + second_ntp.server_system_send_time.as_nanos(), + second_ntp.server_system_recv_time.as_nanos() + + generator + .props + .rtt_server_delays + .approximate(TrueDuration::from_secs(2 * poll_period())) + .unwrap() + .as_nanos() + ); + + let expected_root_delay = EstimateDuration::from_micros(150); + let root_delay_delta = + (second_ntp.root_delay.as_nanos() - expected_root_delay.as_nanos()).abs(); + assert!( + root_delay_delta <= 1, + "Root delay difference from expected is {:?} ns, expected <= 1 ns. Actual: {:?}, Expected: {:?}", + root_delay_delta, + second_ntp.root_delay, + expected_root_delay + ); + + let expected_root_dispersion = EstimateDuration::from_micros(250); + let root_dispersion_delta = + (second_ntp.root_dispersion.as_nanos() - expected_root_dispersion.as_nanos()).abs(); + assert!( + root_dispersion_delta <= 1, + "Root dispersion difference from expected is {:?} ns, expected <= 1 ns. Actual: {:?}, Expected: {:?}", + root_dispersion_delta, + second_ntp.root_dispersion, + expected_root_dispersion + ); + } + + #[rstest] + fn test_has_next(constant_skew_oscillator_model: FullModel) { + // Create series that end at different times + let root_delays: Series = vec![ + ( + TrueDuration::from_secs(0), + EstimateDuration::from_micros(100), + ), + ( + TrueDuration::from_secs(30), + EstimateDuration::from_micros(150), + ), + ] + .into_iter() + .collect(); + + let root_dispersions: Series = vec![ + ( + TrueDuration::from_secs(0), + EstimateDuration::from_micros(200), + ), + ( + TrueDuration::from_secs(30), + EstimateDuration::from_micros(250), + ), + ] + .into_iter() + .collect(); + + let rtds: Series = vec![ + (TrueDuration::from_secs(0), RoundTripDelays::default()), + (TrueDuration::from_secs(30), RoundTripDelays::default()), + ] + .into_iter() + .collect(); + + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("test")) + .root_delays(root_delays) + .root_dispersions(root_dispersions) + .round_trip_delays(&rtds) + .build() + .unwrap(); + + // Should have events available at the beginning + assert!(generator.has_next(&constant_skew_oscillator_model)); + + // Create a generator that's already past its stop time + let mut past_end_generator = generator; + past_end_generator.next_poll = EstimateDuration::from_secs(32); // Beyond the 30s limit + + // Should no longer have events available + assert!(!past_end_generator.has_next(&constant_skew_oscillator_model)); + } +} diff --git a/clock-bound-ff-tester/src/simulation/ntp/variable_network_delay_source.rs b/clock-bound-ff-tester/src/simulation/ntp/variable_network_delay_source.rs new file mode 100644 index 0000000..734efa2 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/ntp/variable_network_delay_source.rs @@ -0,0 +1,321 @@ +//! A ntp source with variable network delay. Runs indefinitely, and every value has some error. +//! +//! ```text +//! ┌──────────────┐ ┌──────────────┐ +//! ◄─│server_rx_time│─► ◄─│server_tx_time│─► +//! └──────────────┘ └──────────────┘ +//! ───────────────────────────────────────────────┌┐────────────────────────────┌┐─────────────────────────────────────────────► +//! // │ │\\\ +//! // │ │ \\ +//! // │ │ \\ +//! // │ │ \\ +//! // │ │ \\ +//! // │ │ \\ +//! / │ │ \\ +//! // │ │ \\ +//! // │ │ \\ +//! / │ │ \\ +//! // │ │ \\\ +//! // │ │ \\\ +//! / │ │ \\ +//! ┌┐ │ │ \┌┐ +//! ──────────────────────└┘────────────────────────┼────────────────────────────┼─────────────────────────────└┘──────────────► +//! ┌──────────────────┐ │ │ ┌────────────────┐ +//! │ client_send_time │ │ │ ◄─│client_recv_time│─► +//! └────────┬─────────┘ │ │ └────────┬───────┘ +//! │ │ │ │ +//! │ │ │ │ +//! │ │ │ │ +//! │ │ │ │ +//! │◄─forward_network_delay───►│◄───server_delay───────────►│◄─backward_network_delay────►│ +//! │ +//! ``` + +use crate::events::v1::{Event, EventKind, Ntp}; +use crate::time::{DemoteToEstimate, EstimateDuration, TscCount}; + +use crate::simulation::oscillator::FullModel; + +use super::round_trip_delays::{NtpEventTimestamps, VariableRoundTripDelays}; + +use rand_chacha::{ChaCha12Rng, rand_core::SeedableRng}; + +/// Properties for [`Generator`] +pub struct Props { + /// The root dispersion that is always returned + pub root_dispersion: EstimateDuration, + /// The root delay in the NTP source that is always returned + pub root_delay: EstimateDuration, + /// The time period in which the oscillator polls the NTP server + /// + /// An estimate, because this naively uses the local oscillator duration to drive + /// when the next NTP request will start. + /// + /// In other words, if the local oscillator has a consistent offset, it will not account + /// for that between NTP requests. + /// + /// Furthermore, if the clock has a skew, the next NTP request will not account for this, + /// and instead use the uncorrected local time frame to drive the next NTP request. + pub poll_period: EstimateDuration, + /// The network delay channel parameters. Each channel delay is defined by a probability + /// distribution which values are pulled from. + pub network_delays: VariableRoundTripDelays, +} + +/// # Generation logic. +/// +/// This struct continuously generates NTP events as long as the oscillator model will allow. It always aligns +/// with the start of the passed in oscillator. For example, if the oscillator is set to send an NTP packet every +/// 8 seconds (from the local oscillator's timeframe), it will start 8 seconds after the start of the passed in +/// `local_oscillator`. +/// +/// Events are generated, driven by the local oscillator. The next poll corresponds to the `client_send_time` +/// in the diagram above. However, the value returned by [`super::Generator::next_event_ready`] corresponds to the +/// `client_recv_time` in the diagram above. +/// +/// The `client_recv_time` is not consistent, unlike the `Perfect` generator. Each leg of the NTP +/// packet delay is pulled from a random distribution, provided by the caller. +pub struct Generator { + props: Props, + id: String, + next_poll: EstimateDuration, + next_event: Option, + rng: ChaCha12Rng, +} + +#[bon::bon] +impl Generator { + /// Construct with builder pattern + #[builder] + pub fn new( + poll_period: EstimateDuration, + id: String, + network_delays: VariableRoundTripDelays, + oscillator: &FullModel, + #[builder(default)] root_dispersion: EstimateDuration, + #[builder(default)] root_delay: EstimateDuration, + seed: Option, + ) -> Self { + let props = Props { + root_dispersion, + root_delay, + poll_period, + network_delays, + }; + + let rng = seed.map_or_else( + || ChaCha12Rng::from_rng(rand::rngs::OsRng).unwrap(), + ChaCha12Rng::seed_from_u64, + ); + let mut rv = Self { + props, + id, + next_poll: poll_period, + next_event: None, + rng, + }; + + // Generate the next event if possible. + // + // This prep work is needed to properly integrate with the + // intended use case of the `Generator` trait. + let res = rv.calculate_ntp_event_timestamps(oscillator); + rv.next_event = res; + rv + } + + /// Return all of the NTP timestamps for an event given an oscillator model. + /// + /// Useful if not using this struct as a generator, but as a primitive building block. + pub fn calculate_ntp_event_timestamps( + &mut self, + oscillator: &FullModel, + ) -> Option { + self.props + .network_delays + .generate_round_trip_delays(&mut self.rng) + .calculate_ntp_event_timestamps(self.next_poll, oscillator) + } +} + +impl super::Generator for Generator { + fn next_event_ready(&self, _oscillator: &FullModel) -> Option { + self.next_event.clone().map(|v| v.client_recv) + } + + fn generate(&mut self, oscillator: &FullModel) -> Event { + let NtpEventTimestamps { + client_send, + server_recv, + server_send, + client_recv, + } = { + if self.next_event.is_some() { + self.next_event.take().unwrap() + } else { + self.calculate_ntp_event_timestamps(oscillator).unwrap() + } + }; + + // update next poll time + self.next_poll += self.props.poll_period; + + // Generate next event + self.next_event = self.calculate_ntp_event_timestamps(oscillator); + + Event { + variants: EventKind::Ntp(Ntp { + server_system_recv_time: server_recv.demote_to_estimate(), + server_system_send_time: server_send.demote_to_estimate(), + root_delay: self.props.root_delay, + root_dispersion: self.props.root_dispersion, + source_id: self.id.clone(), + client_system_times: None, + }), + client_tsc_pre_time: client_send, + client_tsc_post_time: client_recv, + } + } +} + +#[cfg(test)] +mod test { + use crate::time::{EstimateInstant, Frequency, Skew, TrueDuration, TrueInstant}; + use rstest::{fixture, rstest}; + + use super::*; + use crate::simulation::{ + delay::{Delay, TimeUnit}, + ntp::Generator as _, + oscillator::Oscillator, + stats::GammaDistribution, + }; + + #[fixture] + fn constant_skew() -> FullModel { + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .tsc_timestamp_start(TscCount::new(10_000)) + .skew(Skew::from_ppm(-10.0)) + .starting_oscillator_offset(TrueDuration::from_micros(200)) + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[rstest] + fn new_defaults(constant_skew: FullModel) { + let network_delays = VariableRoundTripDelays::default(); + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("perfect_test")) + .network_delays(network_delays) + .oscillator(&constant_skew) + .build(); + assert_eq!(generator.props.root_dispersion, EstimateDuration::default()); + assert_eq!(generator.props.root_delay, EstimateDuration::default()); + assert_eq!(generator.props.poll_period, EstimateDuration::from_secs(16)); + assert_eq!(generator.id, String::from("perfect_test")); + } + + #[rstest] + fn new(constant_skew: FullModel) { + let server_delay = Delay::new( + GammaDistribution::new(1.0, 1.0, 35.0).unwrap(), + TimeUnit::Micros, + ); + let forward_network_delay = Delay::new( + GammaDistribution::new(1.0, 1.0, 55.0).unwrap(), + TimeUnit::Micros, + ); + let backward_network_delay = Delay::new( + GammaDistribution::new(1.0, 1.0, 67.0).unwrap(), + TimeUnit::Micros, + ); + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("asdf")) + .root_dispersion(EstimateDuration::new(1)) + .root_delay(EstimateDuration::new(2)) + .network_delays( + VariableRoundTripDelays::builder() + .server(Box::new(server_delay.clone())) + .forward_network(Box::new(forward_network_delay.clone())) + .backward_network(Box::new(backward_network_delay.clone())) + .build(), + ) + .oscillator(&constant_skew) + .build(); + assert_eq!(generator.props.root_dispersion, EstimateDuration::new(1)); + assert_eq!(generator.props.root_delay, EstimateDuration::new(2)); + assert_eq!(generator.props.poll_period, EstimateDuration::from_secs(16)); + } + + #[rstest] + fn next_event_ready(constant_skew: FullModel) { + let network_delays = VariableRoundTripDelays::default(); + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("value")) + .network_delays(network_delays) + .oscillator(&constant_skew) + .seed(2) + .build(); + + let next_event_ready = generator.next_event_ready(&constant_skew).unwrap(); + assert!(next_event_ready >= TscCount::new(16_000_009_999)); + } + + #[fixture] + fn generator(constant_skew: FullModel) -> Generator { + let variable_delay = VariableRoundTripDelays { + forward_network: Box::new(Delay::new( + GammaDistribution::new(1.0, 1.0, 55.0).unwrap(), + TimeUnit::Micros, + )), + backward_network: Box::new(Delay::new( + GammaDistribution::new(1.0, 1.0, 67.0).unwrap(), + TimeUnit::Micros, + )), + server: Box::new(Delay::new( + GammaDistribution::new(1.0, 1.0, 35.0).unwrap(), + TimeUnit::Micros, + )), + }; + Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("value")) + .root_delay(EstimateDuration::from_micros(120)) + .root_dispersion(EstimateDuration::from_micros(230)) + .network_delays(variable_delay) + .oscillator(&constant_skew) + .build() + } + + #[rstest] + fn generate(constant_skew: FullModel, mut generator: Generator) { + // this test tests against regression values. + // If this test starts failing, evaluate whether or not this + // is the best testing mechanism. + let event = generator.generate(&constant_skew); + assert!(event.client_tsc_post_time >= TscCount::new(16_000_166_997)); + let ntp = event.variants.ntp().unwrap(); + assert_eq!(event.client_tsc_pre_time, TscCount::new(16_000_010_000)); + assert!( + ntp.server_system_recv_time >= EstimateInstant::from_nanos(1_576_800_016_000_014_999) + ); + assert!( + ntp.server_system_send_time >= EstimateInstant::from_nanos(1_576_800_016_000_049_999), + ); + assert_eq!(ntp.root_delay, EstimateDuration::from_micros(120)); + assert_eq!(ntp.root_dispersion, EstimateDuration::from_micros(230)); + } + + #[rstest] + fn second_generation(constant_skew: FullModel, mut generator: Generator) { + let _ = generator.generate(&constant_skew); + let event = generator.generate(&constant_skew); + assert!(event.client_tsc_post_time >= TscCount::new(32_000_166_998)); + } +} diff --git a/clock-bound-ff-tester/src/simulation/oscillator.rs b/clock-bound-ff-tester/src/simulation/oscillator.rs new file mode 100644 index 0000000..d0c1410 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/oscillator.rs @@ -0,0 +1,1164 @@ +//! Code specific to modelling local hardware oscillators +//! +//! "So it just keeps going?? Unable to stop, unable to slow down?? +//! That's horrible! That's a f***ing horror show!" - Neil Kohney, The Other End + +use std::f64::consts::PI; + +use crate::events::v1::{Oscillator as SerdeOscillator, OscillatorOffsets}; +use crate::time::{ + AssumeTrue, CbBridge, DemoteToEstimate, EstimateDuration, Frequency, Series, Skew, + TrueDuration, TrueInstant, TscCount, +}; +use rand::prelude::Distribution; +use statrs::distribution::Normal; + +use super::interpolation::SeriesInterpolation; + +/// The internal oscillator model used when modeling the system's drift from true time +#[derive(Debug, Clone, PartialEq)] +pub struct Oscillator { + pub inner: SerdeOscillator, +} + +impl From for Oscillator { + fn from(inner: SerdeOscillator) -> Self { + Self { inner } + } +} + +impl From for SerdeOscillator { + fn from(outer: Oscillator) -> Self { + outer.inner + } +} + +/// Represents a source of random noise for oscillator models +/// +/// This struct holds parameters for generating random noise to add to oscillator +/// offset models, simulating real-world environmental and electrical noise that +/// affects hardware oscillators. +/// +/// # Parameters +/// +/// * `rng` - The random number generator used for sampling +/// * `mean` - The mean value of the noise distribution (defaults to 0.0) +/// * `std_dev` - The standard deviation of the noise (defaults to 1.0) +/// * `step_size` - The time interval between consecutive noise samples (defaults to 1 second) +/// +/// # Usage +/// +/// ``` +/// use rand_chacha; +/// use rand_chacha::rand_core::SeedableRng; +/// use clock_bound_ff_tester::time::{TrueDuration, TrueInstant, Frequency}; +/// use clock_bound_ff_tester::simulation::oscillator::{Oscillator, Noise}; +/// +/// let rng = Box::new(rand_chacha::ChaCha12Rng::from_seed(Default::default())); +/// +/// let noise = Noise::builder() +/// .rng(rng) +/// .mean(TrueDuration::from_secs(0)) +/// .std_dev(TrueDuration::from_secs(5)) +/// .step_size(TrueDuration::from_millis(100)) +/// .build(); +/// +/// let oscillator = Oscillator::create_sin() +/// .clock_frequency(Frequency::from_ghz(1.0)) +/// .start_time(TrueInstant::from_days(0)) +/// .duration(TrueDuration::from_secs(100)) +/// .noise(noise) +/// .call(); +/// ``` +pub struct Noise { + pub rng: Box, + pub mean: TrueDuration, + pub std_dev: TrueDuration, + pub step_size: TrueDuration, +} + +#[bon::bon] +impl Noise { + #[builder] + pub fn new( + rng: Box, + #[builder(default = TrueDuration::from_secs(0))] mean: TrueDuration, + std_dev: TrueDuration, + #[builder(default = TrueDuration::from_secs(1))] step_size: TrueDuration, + ) -> Self { + Self { + rng, + mean, + std_dev, + step_size, + } + } + + pub fn rng_mut(&mut self) -> &mut dyn rand::RngCore { + &mut *self.rng + } + + pub fn rng(&self) -> &dyn rand::RngCore { + &*self.rng + } + + pub fn mean(&self) -> TrueDuration { + self.mean + } + + pub fn standard_deviation(&self) -> TrueDuration { + self.std_dev + } + + pub fn step_size(&self) -> TrueDuration { + self.step_size + } +} + +#[bon::bon] +impl Oscillator { + /// Return the clock frequency from this model. + pub fn clock_frequency(&self) -> Frequency { + self.inner.clock_frequency() + } + + /// Return the start time from this model. + pub fn start_time(&self) -> TrueInstant { + self.inner.start_time() + } + + /// Return the tsc timestamp start from this model + pub fn tsc_timestamp_start(&self) -> TscCount { + self.inner.tsc_timestamp_start() + } + + /// Return the offset data series from this model + pub fn offset_series(&self) -> &OscillatorOffsets { + self.inner.oscillator_offsets() + } + + /// Create an oscillator with constant parameters + /// + /// This oscillator model is only 2 points, where the linear interpolation does + /// the heavy lifting on all points needed in the middle. + /// # Parameters + /// + /// * `clock_frequency` - The nominal frequency of the oscillator + /// * `start_time` - The true time when the oscillator model begins + /// * `duration` - Total time span of the oscillator model + /// * `tsc_timestamp_start` - Initial value of tsc timestamps (defaults to 0) + /// * `skew` - The constant frequency error of this oscillator (defaults to 0) + /// * `starting_oscillator_offset` - Initial offset from true time (defaults to 0) + /// * `noise` - Optional noise model to add random variations to the offset. + /// When provided, the resulting oscillator will have multiple points based on + /// the noise model's `step_size` instead of just 2 points. + #[builder] + #[expect(clippy::cast_precision_loss)] + #[expect(clippy::cast_possible_truncation)] + pub fn create_simple( + clock_frequency: Frequency, + start_time: TrueInstant, + duration: TrueDuration, + #[builder(default = TscCount::new(0))] tsc_timestamp_start: TscCount, + #[builder(default)] skew: Skew, + #[builder(default)] starting_oscillator_offset: TrueDuration, + noise: Option, + ) -> Self { + let series_start = (TrueDuration::from_secs(0), starting_oscillator_offset); + + let end_offset = skew.get() * duration.as_nanos() as f64; + let end_offset = starting_oscillator_offset + TrueDuration::from_nanos(end_offset as i128); + let series_end = (duration, end_offset); + + let mut offsets: Series = + [series_start, series_end].into_iter().collect(); + + if let Some(mut noise) = noise { + let normal_dist = Normal::new( + noise.mean.as_femtos() as f64, + noise.std_dev.as_femtos() as f64, + ) + .unwrap(); + let noise_points = + offsets.indices().last().unwrap().as_femtos() / noise.step_size.as_femtos(); + let offsets_with_noise: Series = (0..noise_points) + .map(|i| { + let x = noise.step_size * usize::try_from(i).unwrap(); + let y = offsets.approximate(x) + .expect("Approximation should succeed as noise sampling points are within the original series range"); + #[allow(clippy::cast_possible_truncation)] + let y = TrueDuration::from_femtos( + y.as_femtos() + normal_dist.sample(noise.rng_mut()).round() as i128, + ); + (x, y) + }) + .collect(); + offsets = offsets_with_noise; + } + + let inner = SerdeOscillator::builder() + .clock_frequency(clock_frequency) + .system_start_time(start_time) + .tsc_timestamp_start(tsc_timestamp_start) + .oscillator_offsets(OscillatorOffsets::new(offsets)) + .build(); + Oscillator::from(inner) + } + + /// Creates an oscillator with a sinusoidal offset pattern. + /// + /// This method generates an oscillator whose offset from true time follows a sine wave pattern, + /// which is useful for modeling periodic variations in clock behavior such as temperature-induced + /// oscillations. Unlike `create_simple` which creates a linear offset model, this creates a + /// periodically varying offset that better represents real-world oscillator behavior. + /// + /// The model samples the sine wave at regular intervals (defined by `sample_period`) over the + /// specified `duration`. For each sample point, the offset is calculated as: + /// + /// `offset(t) = amplitude * sin(2π * t / period)` + /// + /// # Parameters + /// + /// * `clock_frequency`: The nominal frequency of the oscillator + /// * `start_time`: The true time when the oscillator model begins + /// * `duration`: Total time span of the oscillator model + /// * `tsc_timestamp_start`: Initial value of tsc timestamps (defaults to 0) + /// * `period`: Time for one complete sine wave cycle (defaults to 5 minutes) + /// * `amplitude`: Maximum deviation from true time (defaults to 40 microseconds) + /// * `sample_period`: Time between consecutive samples in the model (defaults to 1 second) + /// * `noise` - Optional noise model to add random variations to the sinusoidal pattern. + /// When provided, the resulting oscillator will have noise superimposed on the sinusoidal pattern. + /// + /// ```text + /// Offset + /// ^ + /// | + /// +A | . . <-- Amplitude + /// | . . . . + /// | . . . . + /// | . . . . + /// 0 |.-----------.-----------.------------> True Time + /// | . . + /// | . . + /// | . . + /// -A | ' <-- Amplitude + /// | + /// ||<--------------------->| + /// | Period + /// | + /// | |-| + /// | Sample + /// | Period + /// ``` + #[builder] + pub fn create_sin( + clock_frequency: Frequency, + start_time: TrueInstant, + duration: TrueDuration, + #[builder(default = TscCount::new(0))] tsc_timestamp_start: TscCount, + #[builder(default = TrueDuration::from_minutes(5))] period: TrueDuration, + #[builder(default = TrueDuration::from_micros(40))] amplitude: TrueDuration, + #[builder(default = TrueDuration::from_secs(1))] sample_period: TrueDuration, + noise: Option, + ) -> Self { + let num_samples = duration.get() / sample_period.get(); + + // Generate the time series of offsets over true time + let mut offsets: Series = (0..num_samples) + .map(|i| { + let x = sample_period * usize::try_from(i).unwrap(); + let y = amplitude.as_seconds_f64() + * ((2.0 * PI * x.as_seconds_f64()) / period.as_seconds_f64()).sin(); + let y = TrueDuration::from_seconds_f64(y); + (x, y) + }) + .collect(); + + if let Some(mut noise) = noise { + #[allow(clippy::cast_precision_loss)] + let normal_dist = Normal::new( + noise.mean.as_femtos() as f64, + noise.std_dev.as_femtos() as f64, + ) + .unwrap(); + let noise_points = + offsets.indices().last().unwrap().as_femtos() / noise.step_size.as_femtos(); + let offsets_with_noise: Series = (0..noise_points) + .map(|i| { + let x = noise.step_size * usize::try_from(i).unwrap(); + let y = offsets.approximate(x) + .expect("Approximation should succeed as noise sampling points are within the original series range"); + #[allow(clippy::cast_possible_truncation)] + let y = y + TrueDuration::from_femtos(normal_dist.sample(noise.rng_mut()).round() as i128); + (x, y) + }) + .collect(); + offsets = offsets_with_noise; + } + + let offsets = OscillatorOffsets::new(offsets); + + let inner = SerdeOscillator::builder() + .clock_frequency(clock_frequency) + .system_start_time(start_time) + .tsc_timestamp_start(tsc_timestamp_start) + .oscillator_offsets(offsets) + .build(); + + Oscillator::from(inner) + } + + /// Return the clock frequencies from this model. + /// + /// While the underlying oscillator struct stores the nominal frequency of the oscillator, a change + /// in offsets means that the actual frequency of the oscillator changes. This function calculates + /// the actual frequency of the oscillator at each offset. + /// + /// This function calculates the frequencies using a first order divided difference (forward looking) + /// See [here](https://en.wikipedia.org/wiki/Numerical_differentiation) for more info + /// + /// NOTE: Because this is using a first order numerical differentiation algorithm, + /// the length of the output frequencies would be one less than that of the oscillator `offset_series`. + /// In this implementation, we duplicate the last value to keep the lengths the same + #[expect(clippy::missing_panics_doc, reason = "lengths checked at Series::new")] + #[expect( + clippy::cast_precision_loss, + reason = "integer values are close to 0. floats are best we got when dividing" + )] + pub fn frequencies(&self) -> FrequencyModel { + // We calculate frequency by doing + // frequency[i] = expected_clock_frequency * (1 + skew) where + // offset[i + 1] - offset[i] + // skew = ------------------------------- + // time_step[i + 1] - time_step[i] + let time_step_windows = self.inner.oscillator_offsets().indices().windows(2); + let offset_windows = self.inner.oscillator_offsets().offsets().windows(2); + + let mut frequencies: Vec<_> = time_step_windows + .zip(offset_windows) + .map(|(time_step_window, offset_window)| { + let delta_time_step = (time_step_window[1] - time_step_window[0]).as_nanos() as f64; + let delta_offset = (offset_window[1] - offset_window[0]).as_nanos() as f64; + let skew = delta_offset / delta_time_step; + self.inner.clock_frequency() * (1.0 + skew) + }) + .collect(); + + // duplicate last value make lengths consistent + frequencies.push(*frequencies.last().unwrap()); + + let durations = self.inner.oscillator_offsets().as_ref().indices().to_vec(); + + // unwrap okay. Lengths will match + FrequencyModel { + inner: Series::new(durations, frequencies).unwrap(), + } + } + + /// Return the true-time to tsc timestamp dataset + /// + /// This representation of the model shows the relationship between the local oscillator driven + /// tsc timestamps as they compare to the forward progression of true time. + /// + /// This representation will be extremely helpful when calculating time events + /// + /// Go from + /// ```text + /// | Offset + /// │ + /// │ + /// │ + /// │ + /// │ .............. ... + /// │ .... .. ... + /// │ .... .. ... + /// ┼.──────────────────────..─────────────────────..───── True Time + /// │ .. .. + /// │ ... .. + /// │ ... .... + /// │ ..... ..... + /// │ .... + /// │ + /// ``` + /// + /// To + /// + /// ```text + /// Oscillator Uncorrected -----> . / <- True Time + /// .. / + /// │ . / + /// │ TSC Timestamp . / + /// │ . / + /// │ ../ + /// │ / + /// │ /. + /// │ / . + /// │ / .. + /// │ / . + /// │ / . + /// │ / .. + /// │ / .. + /// │ / .. + /// │ / .. + /// │ / ... + /// │ / ... + /// │ / ....... + /// │ /....... + /// │ ..../.. + /// │ ...... / + /// │ ... / + /// │ ... / + /// │ .. / + /// │ .. / + /// │. / + /// │. / + /// │. / + /// │/. True Time + /// │──────────────────────────────────────────────────────────────────────────────────────── + /// ``` + #[expect(clippy::missing_panics_doc, reason = "lengths checked at Series::new")] + pub fn true_time_to_tsc_timestamps(&self) -> TscCountModel { + let true_time: Vec<_> = self + .inner + .oscillator_offsets() + .as_ref() + .absolute_time_indexes(self.inner.start_time()) + .collect(); + + let tsc_timestamps = self + .inner + .oscillator_offsets() + .as_ref() + .iter() + .map(|(time_step, offset)| { + // true_time = uncorrected_oscillator_time - offset, therefore + // true_time + offset = uncorrected_oscillator_time + let uncorrected_time = (*time_step + *offset).demote_to_estimate(); + + // tsc_timestamp / nominal_clock_frequency = uncorrected_oscillator_time, therefore + // tsc_timestamp = uncorrected_oscillator_time * nominal_clock_frequency + let tsc_duration = uncorrected_time * self.inner.clock_frequency(); + tsc_duration + self.inner.tsc_timestamp_start() + }) + .collect(); + + TscCountModel { + inner: Series::new(true_time, tsc_timestamps).unwrap(), + } + } +} + +/// An [`Oscillator`] with all associated initialized +/// +/// This type is purely a pre-optimization and a convenience. By calculating this up front, +/// it removes complexity of calculating different values when generating events +#[derive(Debug, Clone)] +pub struct FullModel { + oscillator: Oscillator, + frequencies: FrequencyModel, + true_time_to_tsc_timestamps: TscCountModel, +} + +impl FullModel { + /// Create a new [`FullModel`] from an [`Oscillator`] + pub fn calculate_from_oscillator(oscillator: Oscillator) -> Self { + let frequencies = oscillator.frequencies(); + let true_time_to_tsc_timestamps = oscillator.true_time_to_tsc_timestamps(); + Self { + oscillator, + frequencies, + true_time_to_tsc_timestamps, + } + } + + /// Get the [`Oscillator`] model + pub fn oscillator(&self) -> &Oscillator { + &self.oscillator + } + + /// Get the [`FrequencyModel`] model + pub fn frequencies(&self) -> &FrequencyModel { + &self.frequencies + } + + /// Get the [`TscCountModel`] model + pub fn true_time_to_tsc_timestamps(&self) -> &TscCountModel { + &self.true_time_to_tsc_timestamps + } + + /// destructure into the `ff-tester::events` oscillator for serialization + pub fn to_oscillator(self) -> SerdeOscillator { + self.oscillator.into() + } + + /// Calculate a duration in the "True Time" domain for an estimated duration in oscillator time domain + /// - `estimate` is the estimated time after the start of the oscillator model + pub fn oscillator_estimate_to_true_duration( + &self, + estimate: EstimateDuration, + ) -> Option { + // first, we need the tsc timestamp at the given oscillator estimate duration + let estimate_tsc_tstamp = estimate * self.oscillator().clock_frequency() + + self.oscillator().tsc_timestamp_start(); + // then, we convert that into TrueTime instant with reverse linear interpollation + let true_instant = self + .oscillator() + .true_time_to_tsc_timestamps() + .as_ref() + .reverse_approximate(estimate_tsc_tstamp)?; + + // then, we get a duration since oscillator start time, which is an instant in TrueTime + Some(true_instant - self.oscillator().start_time()) + } +} + +/// A representation of how a model's frequency changes with true time +#[derive(Debug, Clone, PartialEq)] +pub struct FrequencyModel { + pub inner: Series, +} + +impl AsRef> for FrequencyModel { + fn as_ref(&self) -> &Series { + &self.inner + } +} + +/// A representation of how the tsc timestamp values look over "True Time" +#[derive(Debug, Clone, PartialEq)] +pub struct TscCountModel { + pub inner: Series, +} + +impl TscCountModel { + /// [`TrueInstant`] values + pub fn true_instants(&self) -> &[TrueInstant] { + self.inner.indices() + } + + /// [`TscCount`] values + pub fn tsc_timestamps(&self) -> &[TscCount] { + self.inner.data() + } + + /// Convert back to an [`Oscillator`] + /// + /// This is helpful for the data collector to capture TSC+System Clock values and convert + /// back into something that is serializable. + /// + /// This function performs [`Oscillator::true_time_to_tsc_timestamps`] in reverse. + /// + /// While this function COULD estimate the TSC, it will be more consistent to use system values + /// + /// ## Caveats + /// This function is not PERFECTLY reversible. The resulting [`Oscillator`] will always + /// start with an offset value of zero. This means that if one had an [`Oscillator`], converted + /// to a [`TscCountModel`], then converted back to an [`Oscillator`], the resulting + /// [`Oscillator`] would not be identical to the original. + /// + /// This behavior is not seen as an issue. These offsets are equivalent + pub fn convert_to_oscillator(&self, tsc_frequency: Frequency) -> Oscillator { + let system_start_time = self.inner.indices()[0]; + let tsc_timestamp_start = self.inner.data()[0]; + let clock_frequency = tsc_frequency; + let oscillator_iter = self.inner.iter().map(|(true_time, tsc_timestamp)| { + // reverse of `true_time_to_tsc_timestamps` processing + let tsc_duration = *tsc_timestamp - tsc_timestamp_start; + let uncorrected_time = tsc_duration / tsc_frequency; + let scenario_duration = *true_time - system_start_time; + let offset = uncorrected_time.into_estimate().assume_true() - scenario_duration; + (scenario_duration, offset) + }); + + let osc = SerdeOscillator { + clock_frequency, + system_start_time, + tsc_timestamp_start, + oscillator_offsets: OscillatorOffsets::new(oscillator_iter.collect()), + }; + + Oscillator::from(osc) + } +} + +impl AsRef> for TscCountModel { + fn as_ref(&self) -> &Series { + &self.inner + } +} + +#[cfg(test)] +mod test { + use super::*; + use rand::rngs::StdRng; + use rand_chacha::rand_core::SeedableRng; + + use crate::time::TrueDuration; + use rstest::{fixture, rstest}; + + #[fixture] + fn perfect_oscillator() -> Oscillator { + Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .tsc_timestamp_start(TscCount::new(10000)) + .duration(TrueDuration::from_secs(100)) + .call() + } + + #[fixture] + fn constant_skew_oscillator() -> Oscillator { + Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .tsc_timestamp_start(TscCount::new(10000)) + .duration(TrueDuration::from_secs(100)) + .skew(Skew::from_ppm(-15.0)) + .call() + } + + // this is not really sensible. A constant offset IS a perfect oscillator. But lets unit test this anyways + #[fixture] + fn constant_offset_oscillator() -> Oscillator { + Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .tsc_timestamp_start(TscCount::new(10000)) + .duration(TrueDuration::from_secs(100)) + .starting_oscillator_offset(TrueDuration::from_micros(50)) + .call() + } + + // this is not really sensible. A constant offset IS a perfect oscillator. But lets unit test this anyways + #[fixture] + fn constant_negative_offset_oscillator() -> Oscillator { + Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .tsc_timestamp_start(TscCount::new(10000)) + .duration(TrueDuration::from_secs(100)) + .starting_oscillator_offset(TrueDuration::from_micros(-50)) + .call() + } + + #[rstest] + #[case::perfect(perfect_oscillator())] + #[case::constant_offset(constant_offset_oscillator())] + #[case::constant_negative_offset(constant_negative_offset_oscillator())] + fn perfect_frequencies(#[case] expected_perfect_oscillator: Oscillator) { + let frequencies = expected_perfect_oscillator.frequencies(); + let frequencies = frequencies.as_ref().data(); + assert!( + frequencies + .iter() + .all(|f| *f == expected_perfect_oscillator.inner.clock_frequency()) + ); + } + + #[rstest] + #[case::perfect(perfect_oscillator())] + #[case::constant_offset(constant_offset_oscillator())] + #[case::constant_negative_offset(constant_negative_offset_oscillator())] + fn lengths_match(#[case] expected_perfect_oscillator: Oscillator) { + let len = expected_perfect_oscillator.offset_series().as_ref().len(); + assert_eq!( + len, + expected_perfect_oscillator + .true_time_to_tsc_timestamps() + .as_ref() + .len() + ); + assert_eq!( + len, + expected_perfect_oscillator.frequencies().as_ref().len() + ); + } + + #[rstest] + fn constant_skew_frequencies(constant_skew_oscillator: Oscillator) { + let frequencies = constant_skew_oscillator.frequencies(); + let first = frequencies.as_ref().data()[0]; + for f in frequencies.as_ref().data() { + assert!(approx::abs_diff_eq!(first.get(), f.get())); + } + } + + #[rstest] + #[case::perfect(perfect_oscillator())] + #[case::constant_offset(constant_offset_oscillator())] + #[case::constant_negative_offset(constant_negative_offset_oscillator())] + #[case::constant_skew(constant_skew_oscillator())] + fn true_time_to_tsc_timestamps_is_monotonically_increasing(#[case] oscillator: Oscillator) { + // while this test is a good sanity check of this function, it's not really verifying much. + // + // first, this type doesn't actually guard against the counter decreasing (right now). If the offset jumps an extreme amount, + // it could create a hardware counter decrease. This is nonsensical, but easy to check as we **generate** oscillator models. + // + // secondly, this makes more sense to visually inspect at this point in development + let timestamp_model = oscillator.true_time_to_tsc_timestamps(); + + // Check that the number of timestamps matches input series + assert_eq!( + timestamp_model.true_instants().len(), + oscillator.inner.oscillator_offsets().as_ref().len() + ); + + // Verify timestamps are monotonically increasing + for pair in timestamp_model.true_instants().windows(2) { + assert!(pair[0] <= pair[1]); + } + } + + #[rstest] + #[case::perfect(perfect_oscillator())] + #[case::constant_skew(constant_skew_oscillator())] + fn calculate_true_to_tsc_and_back_again(#[case] oscillator: Oscillator) { + let timestamp_model = oscillator.true_time_to_tsc_timestamps(); + + let converted_oscillator = + timestamp_model.convert_to_oscillator(oscillator.clock_frequency()); + + assert_eq!(oscillator, converted_oscillator); + } + + #[rstest] + #[case::perfect(perfect_oscillator(), 10_000_000_000, 10_000_000_000)] + #[case::skewed(constant_skew_oscillator(), 10_000_000_000, 10_000_150_000)] + #[case::offset(constant_offset_oscillator(), 10_000_000_000, 9_999_950_000)] + fn oscillator_estimate_to_true_duration_matches_expected( + #[case] oscillator: Oscillator, + #[case] estimate_nanos: i128, + #[case] expected_nanos: i128, + ) { + let model = FullModel::calculate_from_oscillator(oscillator); + let estimate = EstimateDuration::from_nanos(estimate_nanos); + let true_duration = model + .oscillator_estimate_to_true_duration(estimate) + .unwrap(); + let expected = TrueDuration::from_nanos(expected_nanos); + + assert!( + approx::abs_diff_eq!( + true_duration.as_nanos() as i64, + expected.as_nanos() as i64, + epsilon = 100 + ), + "Expected {expected:?}, got {true_duration:?}" + ); + } + + #[test] + fn create_sin_basic_instantiation() { + // Test with default parameters except the required ones + let osc = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(100)) + .call(); + + // Verify we have the expected number of samples (with default 1s sampling) + let offsets = osc.offset_series().as_ref(); + assert_eq!(offsets.len(), 100); + } + + #[test] + fn create_sin_amplitude() { + // Test that amplitude parameter affects the maximum deviation + let amplitude = TrueDuration::from_micros(75); + let osc = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(100)) + .amplitude(amplitude) + .call(); + + // Find the maximum absolute offset + let max_offset = osc + .offset_series() + .as_ref() + .data() + .iter() + .map(|offset| offset.as_nanos().abs()) + .max() + .unwrap(); + + assert_eq!(max_offset, amplitude.as_nanos()); + } + + #[test] + fn create_sin_period() { + // Test that period parameter is respected + let period = TrueDuration::from_secs(10); + let osc = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(21)) + .period(period) + .amplitude(TrueDuration::from_micros(100)) // Large amplitude for clear pattern + .sample_period(TrueDuration::from_millis(100)) // Finer sampling + .call(); + + let offsets = osc.offset_series().as_ref(); + + // Find first two negative-to-positive zero crossings + let mut first_neg_to_pos_crossing = None; + let mut second_neg_to_pos_crossing = None; + + for i in 1..offsets.len() { + if offsets.data()[i - 1].as_nanos() < 0 && offsets.data()[i].as_nanos() >= 0 { + if first_neg_to_pos_crossing.is_none() { + first_neg_to_pos_crossing = Some(i); + } else if second_neg_to_pos_crossing.is_none() { + second_neg_to_pos_crossing = Some(i); + } + } + } + + assert!( + first_neg_to_pos_crossing.is_some() && second_neg_to_pos_crossing.is_some(), + "Failed to find two complete cycles" + ); + + // Measure time between crossings (may end up needing to approximate the period) + let observed_period = offsets.indices()[second_neg_to_pos_crossing.unwrap()] + - offsets.indices()[first_neg_to_pos_crossing.unwrap()]; + + assert_eq!(observed_period.as_nanos(), period.as_nanos()); + } + + #[test] + fn test_create_sin_sample_period() { + // Test that sample_period controls the number of points + let duration = TrueDuration::from_secs(100); + + let osc_1s = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(duration) + .sample_period(TrueDuration::from_secs(1)) + .call(); + + let osc_2s = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(duration) + .sample_period(TrueDuration::from_secs(2)) + .call(); + + assert_eq!(osc_1s.offset_series().as_ref().len(), 100); // 100 samples with 1s period + assert_eq!(osc_2s.offset_series().as_ref().len(), 50); // 50 samples with 2s period + } + + #[test] + fn test_create_sin_tsc_timestamp_start() { + // Test that tsc_timestamp_start is correctly set + let tsc_start = TscCount::new(12345); + let osc = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(10)) + .tsc_timestamp_start(tsc_start) + .call(); + + assert_eq!(osc.tsc_timestamp_start(), tsc_start); + } + + #[test] + fn test_create_sin_frequencies() { + // Test that frequencies model shows variation due to sine wave + let amplitude = TrueDuration::from_micros(40); + let period = TrueDuration::from_secs(10); + let osc = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(20)) + .amplitude(amplitude) + .period(period) + .call(); + + let freqs = osc.frequencies(); + let baseline = osc.clock_frequency().get(); + + // Check that frequencies vary both above and below nominal + let mut found_above = false; + let mut found_below = false; + + for &freq in freqs.as_ref().data() { + if freq.get() > baseline { + found_above = true; + } else if freq.get() < baseline { + found_below = true; + } + + if found_above && found_below { + break; + } + } + + assert!( + found_above && found_below, + "Expected frequencies to vary both above and below nominal frequency" + ); + } + + #[test] + fn simple_oscillator_with_noise() { + // Deterministic RNG for testing + let seed = 42; + let rng = Box::new(rand::rngs::StdRng::seed_from_u64(seed)); + + // Significant standard deviation for clear testing + let noise = Noise::builder() + .rng(rng) + .mean(TrueDuration::from_nanos(0)) + .std_dev(TrueDuration::from_millis(100)) + .step_size(TrueDuration::from_millis(10)) + .build(); + + let osc_with_noise = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(1)) + .noise(noise) + .call(); + + // The default simple oscillator only has 2 points, but with noise it should have more + let points_count = osc_with_noise.offset_series().as_ref().len(); + assert!( + points_count > 2, + "Simple oscillator with noise should have more than the default 2 points" + ); + } + + #[test] + fn sine_oscillator_with_noise() { + // Deterministic RNG for testing + let seed = 42; + let rng = Box::new(rand::rngs::StdRng::seed_from_u64(seed)); + + // Significant standard deviation for clear testing + let noise = Noise::builder() + .rng(rng) + .mean(TrueDuration::from_nanos(0)) + .std_dev(TrueDuration::from_millis(100)) + .step_size(TrueDuration::from_millis(10)) + .build(); + + let osc_with_noise = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(1)) + .sample_period(TrueDuration::from_millis(10)) + .noise(noise) + .call(); + + // Identical oscillator without noise + let osc_without_noise = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(1)) + .sample_period(TrueDuration::from_millis(10)) + .call(); + + // Get offsets from both oscillators + let noisy_offsets = osc_with_noise.offset_series().as_ref().data(); + let clean_offsets = osc_without_noise.offset_series().as_ref().data(); + + // Noise is applied onto the offset series which indexed by as many sample periods there + // are in a given duration. A noisy offset series can be less in size by 1. + assert_eq!(noisy_offsets.len(), clean_offsets.len() - 1); + + // Verify at least some points differ (noise should have changed them) + let mut has_difference = false; + for (noisy, clean) in noisy_offsets.iter().zip(clean_offsets.iter()) { + if noisy.as_nanos() != clean.as_nanos() { + has_difference = true; + break; + } + } + + assert!( + has_difference, + "Noise should have changed at least some offset values" + ); + } + + #[test] + fn noise_statistical_properties() { + // Test that noise follows expected statistical properties + let seed = 42; + let std_dev_ns: f64 = 100.0; + let mean: f64 = 0.0; + + let rng = Box::new(StdRng::seed_from_u64(seed)); + + #[allow(clippy::cast_precision_loss)] + #[allow(clippy::cast_possible_truncation)] + let noise = Noise::builder() + .rng(rng) + .mean(TrueDuration::from_nanos(mean.round() as i128)) + .std_dev(TrueDuration::from_nanos(std_dev_ns.round() as i128)) + .step_size(TrueDuration::from_millis(10)) + .build(); + + // Create a flat baseline oscillator (all zeros) so we can isolate noise + let osc_with_noise = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(2)) + .amplitude(TrueDuration::from_nanos(0)) // Zero amplitude = flat line + .sample_period(TrueDuration::from_millis(10)) + .noise(noise) + .call(); + + let noisy_offsets = osc_with_noise.offset_series().as_ref().data(); + + // Calculate mean + let sum: i128 = noisy_offsets.iter().map(|d| d.as_nanos()).sum(); + #[allow(clippy::cast_precision_loss)] + let calculated_mean = sum as f64 / noisy_offsets.len() as f64; + + // Calculate standard deviation + #[allow(clippy::cast_precision_loss)] + let variance_sum: f64 = noisy_offsets + .iter() + .map(|d| { + let diff = d.as_nanos() as f64 - calculated_mean; + diff * diff + }) + .sum(); + #[allow(clippy::cast_precision_loss)] + let calculated_std_dev = (variance_sum / noisy_offsets.len() as f64).sqrt(); + + // We expect the calculated mean to be near the specified mean (allowing some deviation) + assert!( + (calculated_mean - mean).abs() < std_dev_ns, + "Calculated mean {calculated_mean} should be close to specified mean {mean}" + ); + + // For std dev, we expect it to be within a conservative 30% of the specified value + assert!( + (calculated_std_dev - std_dev_ns).abs() / std_dev_ns < 0.3, + "Calculated std dev {calculated_std_dev} should be close to specified std dev {std_dev_ns}" + ); + } + + #[test] + fn noise_with_extreme_parameters() { + // Test with very small standard deviation (close to zero effect) + let seed = 42; + let rng = Box::new(StdRng::seed_from_u64(seed)); + + let tiny_noise = Noise::builder() + .rng(rng) + .mean(TrueDuration::from_nanos(0)) + .std_dev(TrueDuration::from_nanos(1)) + .step_size(TrueDuration::from_millis(10)) + .build(); + + let osc_tiny_noise = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_millis(100)) + .amplitude(TrueDuration::from_micros(10)) + .sample_period(TrueDuration::from_millis(10)) + .noise(tiny_noise) + .call(); + + // Create identical oscillator without noise + let osc_no_noise = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_millis(100)) + .amplitude(TrueDuration::from_micros(10)) + .sample_period(TrueDuration::from_millis(10)) + .call(); + + // With tiny standard deviation, the outputs should be very close to the no-noise version + let tiny_noise_offsets = osc_tiny_noise.offset_series().as_ref().data(); + let no_noise_offsets = osc_no_noise.offset_series().as_ref().data(); + + for (o1, o2) in tiny_noise_offsets.iter().zip(no_noise_offsets.iter()) { + approx::assert_abs_diff_eq!( + o1.as_nanos() as i64, + o2.as_nanos() as i64, + epsilon = 2 // Allow 2ns difference due to tiny std dev noise + ); + } + + // Test with large mean offset (should shift all values) + let large_mean = 5000; // 5 microseconds + let rng = Box::new(StdRng::seed_from_u64(seed)); + let mean_shift_noise = Noise::builder() + .rng(rng) + .mean(TrueDuration::from_nanos(large_mean)) + .std_dev(TrueDuration::from_nanos(1)) // Minimal std dev to isolate mean effect + .step_size(TrueDuration::from_millis(10)) + .build(); + + let osc_shifted = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_millis(100)) + .amplitude(TrueDuration::from_micros(0)) // Zero amplitude to isolate noise effect + .sample_period(TrueDuration::from_millis(10)) + .noise(mean_shift_noise) + .call(); + + let shifted_offsets = osc_shifted.offset_series().as_ref().data(); + + // All offsets should be close to the specified mean + #[allow(clippy::cast_precision_loss)] + for offset in shifted_offsets { + assert!( + (offset.as_nanos() - large_mean).abs() <= 1, + "Offsets should be shifted by the noise mean value" + ); + } + + // Test with very large standard deviation + let rng = Box::new(StdRng::seed_from_u64(seed)); + let large_noise = Noise::builder() + .rng(rng) + .mean(TrueDuration::from_nanos(0)) + .std_dev(TrueDuration::from_millis(1)) // 1ms std dev + .step_size(TrueDuration::from_millis(10)) + .build(); + + let osc_large_noise = Oscillator::create_sin() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(0)) + .duration(TrueDuration::from_secs(1)) + .amplitude(TrueDuration::from_micros(10)) // Small sine amplitude + .sample_period(TrueDuration::from_millis(10)) + .noise(large_noise) + .call(); + + // With large noise, the standard deviation of the result should + // be much larger than the sine amplitude + let large_noise_offsets = osc_large_noise.offset_series().as_ref().data(); + + // Calculate standard deviation + #[allow(clippy::cast_precision_loss)] + let sum: i128 = large_noise_offsets.iter().map(|d| d.as_nanos()).sum(); + #[allow(clippy::cast_precision_loss)] + let mean_offset = sum as f64 / large_noise_offsets.len() as f64; + + #[allow(clippy::cast_precision_loss)] + let variance_sum: f64 = large_noise_offsets + .iter() + .map(|d| { + let diff = d.as_nanos() as f64 - mean_offset; + diff * diff + }) + .sum(); + #[allow(clippy::cast_precision_loss)] + let std_dev = (variance_sum / large_noise_offsets.len() as f64).sqrt(); + + // The std dev of the output should be much larger than the sine amplitude (10μs) + assert!( + std_dev > 50_000.0, // Should be much larger than the 10μs sine amplitude + "Large noise should dominate the sine wave pattern" + ); + } +} diff --git a/clock-bound-ff-tester/src/simulation/phc.rs b/clock-bound-ff-tester/src/simulation/phc.rs new file mode 100644 index 0000000..1a2a73b --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/phc.rs @@ -0,0 +1,5 @@ +//! PHC related stuff + +pub mod round_trip_delays; + +pub mod variable_delay_source; diff --git a/clock-bound-ff-tester/src/simulation/phc/round_trip_delays.rs b/clock-bound-ff-tester/src/simulation/phc/round_trip_delays.rs new file mode 100644 index 0000000..0771a45 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/phc/round_trip_delays.rs @@ -0,0 +1,247 @@ +//! PHC round trip delay + +use super::variable_delay_source::PhcEventTimestamps; +use crate::simulation::{ + delay::DelayRng, interpolation::SeriesInterpolation, oscillator::FullModel, +}; +use crate::time::{EstimateDuration, TrueDuration}; + +/// Building block for taking a set of network delays and creating PHC events +#[derive(Debug, Clone, Default)] +pub struct RoundTripDelays { + /// the true time delay of the PHC request from the client to the server + pub forward_network: TrueDuration, + /// the true time delay of the PHC reply back from the server to the client + pub backward_network: TrueDuration, +} + +impl RoundTripDelays { + pub fn calculate_phc_event_timestamps( + &self, + oscillator_estimated_send_delay: EstimateDuration, + oscillator: &FullModel, + clock_error_bound: Option, + ) -> Option { + // This algorithm works well by converting between the TSC domain to the TrueTime domain and back again. + // + // While this may *seem* roundabout, it's a simple way to understand the problem. Convert your start time into the True Time domain, + // do your operations, and then convert back. + + // First we need the tsc timestamp at the client send time + let client_send = oscillator_estimated_send_delay + * oscillator.oscillator().clock_frequency() + + oscillator.oscillator().tsc_timestamp_start(); + + // Then, convert this tsc timestamp into a true time with a reverse linear interpolation + let client_send_true = oscillator + .true_time_to_tsc_timestamps() + .as_ref() + .reverse_approximate(client_send)?; + + // then add network and server delays + let phc_time = client_send_true + self.forward_network; + let client_recv = phc_time + self.backward_network; + + // then convert back to the tsc timestamp domain + let client_recv = oscillator + .true_time_to_tsc_timestamps() + .as_ref() + .approximate(client_recv)?; + + Some(PhcEventTimestamps { + client_send, + phc_time, + client_recv, + clock_error_bound, + }) + } +} +/// Building block for taking a set of network delays and creating PHC events +#[derive(bon::Builder, Debug)] +pub struct VariableRoundTripDelays { + /// the true time delay of the PHC request from the client to the server + pub forward_network: Box, + /// the true time delay of the PHC reply back from the server to the client + pub backward_network: Box, +} + +impl VariableRoundTripDelays { + pub fn generate_round_trip_delays( + &self, + rng: &mut rand_chacha::ChaCha12Rng, + ) -> RoundTripDelays { + RoundTripDelays { + forward_network: self.forward_network.get_value(rng), + backward_network: self.backward_network.get_value(rng), + } + } + + /// Contains placeholder values. + /// Do not use this function if you need good, well informed, initial values. + #[expect( + clippy::missing_panics_doc, + reason = "Gamma Parameters are constant and won't panic" + )] + pub fn test_default() -> Self { + use crate::simulation::{ + delay::{Delay, TimeUnit}, + stats, + }; + + VariableRoundTripDelays::builder() + .forward_network(Box::new(Delay::new( + stats::GammaDistribution::new(0.1, 1.0, 0.0).unwrap(), + TimeUnit::Micros, + ))) + .backward_network(Box::new(Delay::new( + stats::GammaDistribution::new(0.1, 1.0, 0.0).unwrap(), + TimeUnit::Micros, + ))) + .build() + } +} + +#[cfg(test)] +mod test { + use crate::time::{AssumeTrue, Frequency, Skew, TrueInstant, TscCount}; + use rstest::{fixture, rstest}; + + use crate::simulation::oscillator::{FullModel, Oscillator}; + + use super::*; + + #[fixture] + fn perfect_oscillator() -> FullModel { + // A perfect oscillator with a 1GHz frequency is a perfect monotonic tsc clock (aka 1 tick vs nanosecond) + // This makes math a lot easier for the basic case + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[fixture] + fn positive_skew_oscillator() -> FullModel { + // A positive skew oscillator with a 1GHz frequency is a perfect monotonic tsc clock (aka 1 tick vs nanosecond) + // This makes math a lot easier for the basic case + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .skew(Skew::from_percent(5.0)) // wow now thats I call a bad oscillator + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[rstest] + fn calculate_phc_event_timestamps_perfect(perfect_oscillator: FullModel) { + // expectations + // + // The oscillator has no offset, as such we should be able to accurately calculate the + // timestamps. + + let start_time = perfect_oscillator.oscillator().start_time(); + let forward_network = TrueDuration::from_micros(50); + let backward_network = TrueDuration::from_micros(55); + let client_start = EstimateDuration::from_millis(100); + + let delays = RoundTripDelays { + forward_network, + backward_network, + }; + + let event = delays.calculate_phc_event_timestamps(client_start, &perfect_oscillator, None); + + assert_eq!( + event, + Some(PhcEventTimestamps { + client_send: TscCount::new(client_start.as_nanos()), + phc_time: client_start.assume_true() + start_time + forward_network, + client_recv: TscCount::new( + (client_start.assume_true() + forward_network + backward_network).as_nanos() + ), + clock_error_bound: None, + }) + ); + } + + #[rstest] + fn calculate_phc_event_timestamps_with_ceb(perfect_oscillator: FullModel) { + // expectations + // + // The oscillator has no offset, as such we should be able to accurately calculate the + // timestamps. + + let start_time = perfect_oscillator.oscillator().start_time(); + let forward_network = TrueDuration::from_micros(50); + let backward_network = TrueDuration::from_micros(55); + let client_start = EstimateDuration::from_millis(100); + let clock_error_bound = Some(EstimateDuration::from_millis(10)); + + let delays = RoundTripDelays { + forward_network, + backward_network, + }; + + let event = delays.calculate_phc_event_timestamps( + client_start, + &perfect_oscillator, + clock_error_bound, + ); + + assert_eq!( + event, + Some(PhcEventTimestamps { + client_send: TscCount::new(client_start.as_nanos()), + phc_time: client_start.assume_true() + start_time + forward_network, + client_recv: TscCount::new( + (client_start.assume_true() + forward_network + backward_network).as_nanos() + ), + clock_error_bound, + }) + ); + } + + #[rstest] + fn calculate_phc_event_timestamps_positive_skew(positive_skew_oscillator: FullModel) { + // expectations + // + // The generator in this test has a 1GHz clock, which matches a 1 nanosecond interval in + // `ff-tester` time types. This means that a skew should be easy to test with comparison operators + let start_time = positive_skew_oscillator.oscillator().start_time(); + let forward_network = TrueDuration::from_micros(50); + let backward_network = TrueDuration::from_micros(55); + let client_start = EstimateDuration::from_millis(100); + + let delays = RoundTripDelays { + forward_network, + backward_network, + }; + + let event = + delays.calculate_phc_event_timestamps(client_start, &positive_skew_oscillator, None); + + let timestamps = event.unwrap(); + + // the client_send tsc timestamp and client_start estimate time should agree. + assert_eq!( + timestamps.client_send, + TscCount::new(client_start.as_nanos()) + ); + + // Because the oscillator is skewed fast, the PHC time represents true time. + // This inequality shows that the true time is actually earlier than the normal calculations would expect + assert!(timestamps.phc_time < start_time + client_start.assume_true() + forward_network); + assert!(timestamps.phc_time < start_time + client_start.assume_true() + forward_network); + + // The corollary to above: when the reply comes back, the local oscillator increased at a faster rate than the true time during the time it took to get time from the PHC. + assert!( + timestamps.client_recv + > TscCount::new( + (client_start.assume_true() + forward_network + backward_network).as_nanos() + ) + ); + } +} diff --git a/clock-bound-ff-tester/src/simulation/phc/variable_delay_source.rs b/clock-bound-ff-tester/src/simulation/phc/variable_delay_source.rs new file mode 100644 index 0000000..9db57f9 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/phc/variable_delay_source.rs @@ -0,0 +1,341 @@ +//! PHC event generator. +//! +//! ```text +//! ┌────────┐ +//! │phc_time│ +//! └────────┘ +//! ───────────────────────────────────────────────┌┐┐─────────────────────────────────────────────► +//! // │ \\\ +//! // │ \\ +//! // │ \\ +//! // │ \\ +//! // │ \\ +//! // │ \\ +//! / │ \\ +//! // │ \\ +//! // │ \\ +//! / │ \\ +//! // │ \\\ +//! // │ \\\ +//! / │ \\ +//! ┌┐ │ \┌┐ +//! ──────────────────────└┘────────────────────────┼──────────────────────────────└┘──────────────► +//! ┌──────────────────┐ │ ┌────────────────┐ +//! │ client_send_time │ │ ◄─│client_recv_time│─► +//! └────────┬─────────┘ │ └────────┬───────┘ +//! │ │ │ +//! │ │ │ +//! │ │ │ +//! │ │ │ +//! │◄─forward_network_delay───►│◄─◄─backward_network_delay────►│ +//! │ +//! ``` + +use crate::events::v1::{Event, EventKind, Phc}; +use crate::time::{DemoteToEstimate, EstimateDuration, TrueInstant, TscCount}; + +use crate::simulation::{delay::DelayRng, oscillator::FullModel}; + +use super::round_trip_delays::VariableRoundTripDelays; + +use rand_chacha::{ChaCha12Rng, rand_core::SeedableRng}; + +/// Properties for [`Generator`] +#[derive(Debug)] +pub struct Props { + /// The time period in which the oscillator polls the PHC + /// + /// An estimate, because this naively uses the local oscillator duration to drive + /// when the next PHC request will start. + /// + /// In other words, if the local oscillator has a consistent offset, it will not account + /// for that between PHC requests. + /// + /// Furthermore, if the clock has a skew, the next PHC request will not account for this, + /// and instead use the uncorrected local time frame to drive the next PHC request. + pub poll_period: EstimateDuration, + /// The network channel parameters + pub network_delays: VariableRoundTripDelays, + /// The generated clock error bounds + pub clock_error_bounds: Option>, +} + +/// Runs forever, like the juggernaut that it is +/// +/// # Generation logic. +/// +/// This struct continuously generates PHC events as long as the oscillator model will allow. It always aligns +/// with the start of the passed in oscillator. For example, if the oscillator is set to send an PHC every +/// 8 seconds (from the local oscillator's timeframe), it will start 8 seconds after the start of the passed in +/// `local_oscillator`. +/// +/// Events are generated, driven by the local oscillator as well. The next poll corresponds to the `client_send_time` +/// in the diagram above. However, the value returned by [`Generator::next_event_ready`](crate::simulation::generator::Generator::next_event_ready) +/// corresponds to the `client_recv_time` in the diagram above. +#[derive(Debug)] +pub struct Generator { + props: Props, + id: String, + next_poll: EstimateDuration, + next_event: Option, + rng: ChaCha12Rng, +} + +#[bon::bon] +impl Generator { + /// Construct with builder pattern + #[builder] + pub fn new( + poll_period: EstimateDuration, + id: String, + oscillator: &FullModel, + network_delays: VariableRoundTripDelays, + clock_error_bounds: Option>, + seed: Option, + ) -> Self { + let props = Props { + poll_period, + network_delays, + clock_error_bounds, + }; + let rng = seed.map_or_else( + || ChaCha12Rng::from_rng(rand::rngs::OsRng).unwrap(), + ChaCha12Rng::seed_from_u64, + ); + let mut rv = Self { + props, + id, + next_poll: poll_period, + next_event: None, + rng, + }; + + // Generate the next event if possible. + // + // This prep work is needed to properly integrate with the + // intended use case of the `Generator` trait. + let res = rv.calculate_phc_event_timestamps(oscillator); + rv.next_event = res; + rv + } + + /// Return all of the PHC timestamps for an event given an oscillator model + /// + /// Useful if not using this struct as a generator, but as a primitive building block + pub fn calculate_phc_event_timestamps( + &mut self, + oscillator: &FullModel, + ) -> Option { + // Generate the CEB + let ceb = self.props.clock_error_bounds.as_mut().map(|ceb| { + let ceb = ceb.get_value(&mut self.rng); + ceb.demote_to_estimate() + }); + self.props + .network_delays + .generate_round_trip_delays(&mut self.rng) + .calculate_phc_event_timestamps(self.next_poll, oscillator, ceb) + } +} + +impl crate::simulation::generator::Generator for Generator { + fn next_event_ready(&self, _oscillator: &FullModel) -> Option { + self.next_event.clone().map(|v| v.client_recv) + } + + fn generate(&mut self, oscillator: &FullModel) -> Event { + let PhcEventTimestamps { + client_send, + phc_time, + client_recv, + clock_error_bound, + } = { + if self.next_event.is_some() { + self.next_event.take().unwrap() + } else { + self.calculate_phc_event_timestamps(oscillator).unwrap() + } + }; + + // update next poll time + self.next_poll += self.props.poll_period; + + // Generate next event + self.next_event = self.calculate_phc_event_timestamps(oscillator); + + Event { + variants: EventKind::Phc(Phc { + phc_time: phc_time.demote_to_estimate(), + source_id: self.id.clone(), + client_system_times: None, + clock_error_bound, + }), + client_tsc_pre_time: client_send, + client_tsc_post_time: client_recv, + } + } +} + +/// Output of [`Generator::calculate_phc_event_timestamps`] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PhcEventTimestamps { + /// The tsc timestamp when the client sends the PHC request + pub client_send: TscCount, + /// The tsc timestamp of the PHC time + pub phc_time: TrueInstant, + /// The tsc timestamp when client receives the PHC request + pub client_recv: TscCount, + /// The Clock Error Bound, if configured + pub clock_error_bound: Option, +} + +#[cfg(test)] +mod test { + use crate::time::{EstimateDuration, Frequency, Skew, TrueDuration, TrueInstant, TscCount}; + use rstest::{fixture, rstest}; + + use super::*; + use crate::simulation::{ + delay::{Delay, TimeUnit}, + generator::Generator as _, + oscillator::Oscillator, + stats::DiracDistribution, + }; + + #[fixture] + fn constant_skew() -> FullModel { + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .tsc_timestamp_start(TscCount::new(10_000)) + .skew(Skew::from_ppm(-10.0)) + .starting_oscillator_offset(TrueDuration::from_micros(200)) + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[rstest] + fn new_defaults(constant_skew: FullModel) { + let network_delays = VariableRoundTripDelays::test_default(); + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("perfect_test")) + .network_delays(network_delays) + .oscillator(&constant_skew) + .build(); + assert_eq!(generator.props.poll_period, EstimateDuration::from_secs(16)); + assert_eq!(generator.id, String::from("perfect_test")); + } + + #[rstest] + fn new(constant_skew: FullModel) { + let forward_network_delay = + Delay::new(DiracDistribution::new(1.0).unwrap(), TimeUnit::Micros); + let backward_network_delay = + Delay::new(DiracDistribution::new(1.0).unwrap(), TimeUnit::Micros); + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("asdf")) + .network_delays( + VariableRoundTripDelays::builder() + .forward_network(Box::new(forward_network_delay.clone())) + .backward_network(Box::new(backward_network_delay.clone())) + .build(), + ) + .oscillator(&constant_skew) + .build(); + assert_eq!(generator.props.poll_period, EstimateDuration::from_secs(16)); + } + + #[rstest] + fn next_event_ready(constant_skew: FullModel) { + let network_delays = VariableRoundTripDelays::test_default(); + let generator = Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("value")) + .network_delays(network_delays) + .oscillator(&constant_skew) + .seed(0) + .build(); + + let next_event_ready = generator.next_event_ready(&constant_skew).unwrap(); + assert_eq!(next_event_ready, TscCount::new(16_000_010_079)); + } + + #[fixture] + fn generator(constant_skew: FullModel) -> Generator { + let variable_delay = VariableRoundTripDelays { + forward_network: Box::new(Delay::new( + DiracDistribution::new(1.0).unwrap(), + TimeUnit::Micros, + )), + backward_network: Box::new(Delay::new( + DiracDistribution::new(1.0).unwrap(), + TimeUnit::Micros, + )), + }; + Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("value")) + .network_delays(variable_delay) + .oscillator(&constant_skew) + .build() + } + + #[fixture] + fn ceb_generator(constant_skew: FullModel) -> Generator { + let variable_delay = VariableRoundTripDelays { + forward_network: Box::new(Delay::new( + DiracDistribution::new(1.0).unwrap(), + TimeUnit::Micros, + )), + backward_network: Box::new(Delay::new( + DiracDistribution::new(1.0).unwrap(), + TimeUnit::Micros, + )), + }; + + let ceb: Box = Box::new(Delay::new( + DiracDistribution::new(1.0).unwrap(), + TimeUnit::Micros, + )); + + Generator::builder() + .poll_period(EstimateDuration::from_secs(16)) + .id(String::from("value")) + .network_delays(variable_delay) + .oscillator(&constant_skew) + .clock_error_bounds(ceb) + .build() + } + + #[rstest] + fn generate(constant_skew: FullModel, mut generator: Generator) { + // this test tests against regression values. + // If this test starts failing, evaluate whether or not this + // is the best testing mechanism. + let event = generator.generate(&constant_skew); + assert_eq!(event.client_tsc_post_time, TscCount::new(16_000_012_000)); + let phc = event.variants.phc().unwrap(); + assert_eq!(event.client_tsc_pre_time, TscCount::new(16_000_010_000)); + assert_eq!(phc.phc_time.as_nanos(), 1_576_800_015_999_961_000); + } + + #[rstest] + fn generate_with_ceb(constant_skew: FullModel, mut ceb_generator: Generator) { + let event = ceb_generator.generate(&constant_skew); + let phc = event.variants.phc().unwrap(); + assert_eq!( + phc.clock_error_bound, + Some(EstimateDuration::from_micros(1)) + ); + } + + #[rstest] + fn second_generation(constant_skew: FullModel, mut generator: Generator) { + let _ = generator.generate(&constant_skew); + let event = generator.generate(&constant_skew); + assert_eq!(event.client_tsc_post_time, TscCount::new(32_000_012_000)); + } +} diff --git a/clock-bound-ff-tester/src/simulation/stats.rs b/clock-bound-ff-tester/src/simulation/stats.rs new file mode 100644 index 0000000..d2be140 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/stats.rs @@ -0,0 +1,26 @@ +//! Code specific to statistical tools used to model oscillators, NTP events, and other time +//! related phenomena. + +use rand::distributions::Distribution as RandDistribution; + +/// Our internal Distribution trait wrapping the `rand::distributions::Distribution` trait which +/// implements sampling as well as several other Traits required by internal modules. +/// +/// At it's core this trait is a convince trait intended to ease the burden of adding statistical distributions to objects +/// which must include `Copy`, `PartialEq` traits. Additionally, we currently sample only f64 values +/// which this trait makes explicit. +pub trait Distribution: RandDistribution + Copy + PartialEq + std::fmt::Debug {} +impl Distribution for T where T: RandDistribution + Copy + PartialEq + std::fmt::Debug {} + +mod gamma; +pub use gamma::GammaDistribution; + +mod normal; +pub use normal::NormalDistribution; + +mod truncated; +pub use truncated::Truncated; + +pub use statrs::distribution::Dirac as DiracDistribution; + +pub mod string_parse; diff --git a/clock-bound-ff-tester/src/simulation/stats/gamma.rs b/clock-bound-ff-tester/src/simulation/stats/gamma.rs new file mode 100644 index 0000000..38beb12 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/stats/gamma.rs @@ -0,0 +1,115 @@ +//! Code specific to the Gamma statistical distribution. + +use rand::{Rng, distributions::Distribution as RandDistribution}; +use statrs::distribution::{Gamma, GammaError}; + +/// Our internal Gamma distribution wrapper. +/// +/// Unlike the `statrs` distribution we include `loc` as a parameter. +/// This parameter allows us to shift the probability distribution. +/// This implementation and naming is pulled from scipy. +/// +#[derive(Clone, Copy, Debug, PartialEq)] +#[allow(dead_code)] +pub struct GammaDistribution { + distribution: Gamma, + loc: f64, +} + +impl GammaDistribution { + /// Returns a Gamma distribution struct which we can sample from. + /// + /// # Errors + /// An error will be returned if the input parameters are ill defined. + pub fn new(shape: f64, rate: f64, loc: f64) -> Result { + Ok(GammaDistribution { + distribution: Gamma::new(shape, rate)?, + loc, + }) + } + + fn sample(&self, rng: &mut R) -> f64 { + self.distribution.sample(rng) + self.loc + } + + pub fn shape(&self) -> f64 { + self.distribution.shape() + } + + pub fn rate(&self) -> f64 { + self.distribution.rate() + } + + pub fn loc(&self) -> f64 { + self.loc + } +} + +impl RandDistribution for GammaDistribution { + fn sample(&self, rng: &mut R) -> f64 { + self.sample(rng) + } +} + +#[cfg(test)] +mod test { + use super::*; + + use rand_chacha; + use rand_chacha::rand_core::SeedableRng; + use rstest::rstest; + use statrs::statistics::Statistics; + + #[rstest] + #[case::standard_case(2.0, 1.0, 0.0)] + #[case::significant_rate(2.0, 10.0, 0.0)] + #[case::significant_loc(2.0, 1.0, 5.0)] + fn check_mean_standard_deviation_sampling( + #[case] shape: f64, + #[case] rate: f64, + #[case] loc: f64, + ) { + let Ok(distribution) = GammaDistribution::new(shape, rate, loc) else { + panic!("Unexpected error condition!"); + }; + + let mut data = vec![]; + let mut rng = rand_chacha::ChaCha12Rng::from_seed(Default::default()); + + for _ in 0..1000 { + let value = distribution.sample(&mut rng); + data.push(value); + } + + let data_mean = data.clone().mean(); + let data_std = data.clone().std_dev(); + + let expected_mean = shape / rate + loc; + let expected_std = shape.powf(0.5) / rate; + + assert!( + (data_mean - expected_mean).abs() < expected_mean.abs() + expected_std, + "expected mean: {expected_mean}, data mean: {data_mean}, std: {data_std}" + ); + + assert!( + (data_std - expected_std).abs() < expected_std * 0.5, + "expected std: {expected_std}, data std: {data_std}" + ); + } + + #[rstest] + #[case::shape_invalid(-1.0, 1.0, GammaError::ShapeInvalid)] + #[case::rate_invalid(1.0, -1.0, GammaError::RateInvalid)] + #[case::shape_rate_infinite(f64::INFINITY, f64::INFINITY, GammaError::ShapeAndRateInfinite)] + pub fn invalid_args_constructor( + #[case] shape: f64, + #[case] rate: f64, + #[case] expected_error: GammaError, + ) { + assert_eq!( + GammaDistribution::new(shape, rate, 0.0), + Err(expected_error) + ); + } +} diff --git a/clock-bound-ff-tester/src/simulation/stats/normal.rs b/clock-bound-ff-tester/src/simulation/stats/normal.rs new file mode 100644 index 0000000..0da4ceb --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/stats/normal.rs @@ -0,0 +1,250 @@ +//! Code specific to the Normal, Gaussian, statistical distribution. + +use rand::{Rng, distributions::Distribution as RandDistribution}; +use statrs::distribution::{Normal, NormalError}; +use statrs::statistics::Distribution as StatrsDistribution; + +/// Our internal Normal distribution wrapper. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NormalDistribution { + distribution: Normal, +} + +impl NormalDistribution { + /// Returns a Normal distribution struct which we can sample from. + /// + /// # Errors + /// An error will be returned if the standard deviation is 0 or if the backend distribution + /// library [Normal] distribution function mean and std parameters don't align with what we pass in. + pub fn new(mean: f64, standard_deviation: f64) -> Result { + Ok(NormalDistribution { + distribution: Normal::new(mean, standard_deviation)?, + }) + } + + /// Returns the mean of the distribution. + #[expect( + clippy::missing_panics_doc, + reason = "normal distribution always has a mean" + )] + pub fn mean(&self) -> f64 { + self.distribution.mean().unwrap() + } + + /// Returns the standard deviation of the distribution. + #[expect( + clippy::missing_panics_doc, + reason = "normal distribution always has a standard deviation" + )] + pub fn standard_deviation(&self) -> f64 { + self.distribution.std_dev().unwrap() + } + + /// Pulls a samples from the Normal distribution. + fn sample(&self, rng: &mut R) -> f64 { + self.distribution.sample(rng) + } +} + +impl RandDistribution for NormalDistribution { + fn sample(&self, rng: &mut R) -> f64 { + self.sample(rng) + } +} + +#[cfg(test)] +mod test { + use super::*; + + use rand_chacha; + use rand_chacha::rand_core::SeedableRng; + use rstest::rstest; + use statrs::statistics::Statistics; + + use nalgebra::{DVector, Scalar, dvector}; + use num_traits::Float; + use varpro::prelude::*; + use varpro::solvers::levmar::{LevMarProblemBuilder, LevMarSolver}; + + fn make_bins(n_bins: usize, min: f64, step_size: f64) -> Vec { + let mut rv = vec![]; + + for i in 0..n_bins { + #[expect( + clippy::cast_precision_loss, + reason = "It isn't expected for this function to be used for more than 2^53 bins. In the event that it is any loss of precision is ok." + )] + rv.push(min + i as f64 * step_size); + } + rv + } + + #[rstest] + #[case::succcess(1.0, 2.0)] + #[case::succcess(0.0, 1.0)] + #[case::succcess(-1.0, 1.0)] + pub fn check_mean_standard_deviation_sampling( + #[case] mean: f64, + #[case] standard_deviation: f64, + ) { + let Ok(distribution) = NormalDistribution::new(mean, standard_deviation) else { + if standard_deviation == 0.0 { + return; + } + panic!("Unexpected error condition!"); + }; + + let mut data = vec![]; + let mut rng = rand_chacha::ChaCha12Rng::from_seed(Default::default()); + + for _ in 0..1000 { + let value = distribution.sample(&mut rng); + data.push(value); + } + + let data_mean = data.clone().mean(); + assert!( + (data_mean - mean).abs() < mean.abs() + standard_deviation, + "mean: {mean}, data_mean: {data_mean}, std: {standard_deviation}", + ); + let data_std = data.clone().std_dev(); + assert!( + data_std - standard_deviation < standard_deviation, + "std: {standard_deviation}, data_std: {data_std}", + ); + } + + #[test] + #[allow(clippy::assertions_on_result_states)] + pub fn invalid_args_constructor() { + // The constructor should fail due to a standard deviation of 0. + assert_eq!( + NormalDistribution::new(0.0, 0.0).unwrap_err(), + NormalError::StandardDeviationInvalid + ); + } + + // While the computing the mean and std from a record is great it doesn't provide any + // shape information. To be sure the shapes of the distribution are correct we + // perform a fit. + // + // For the Normal distribution we fit to the log normal. This transforms our exponential to a + // linear equation making the derivative the fit requires easier. Note, for simplicity we + // ignore the scaling factor. This will be accounted for but the correlation with the standard + // deviation can be ignored. + // + // ```latex + // N = e^{ \frac{(-(x-\mu)^2} {2 \sigma^2} } + // ``` + // + // becomes + // + // ```latex + // ln(N) = - (x-\mu)^2 / 2 \sigma^2 + c + // ``` + // + // We fit for \mu, \sigma and c, where c is some constant that accounting for scaling. + #[rstest] + #[case::succcess(0.0, 1.0, -3.0)] + #[case::succcess(1.0, 1.5, -2.0)] + #[case::succcess(-1.0, 1.0, -4.0)] + pub fn fit_validation( + #[case] mean: f64, + #[case] standard_deviation: f64, + #[case] minimum_bin_edge: f64, + ) { + fn model>( + x: &DVector, + mu: ScalarType, + ) -> DVector { + let two: ScalarType = 2.0.into(); + x.map(|x| -x.powi(2) / two + x * mu - mu.powi(2) / two) + } + + fn ddmu_model>( + x: &DVector, + mu: ScalarType, + ) -> DVector { + x.map(|x| x - mu) + } + + let sigma = standard_deviation; + let mu = mean; + let normal_distribution = Normal::new(mu, sigma).unwrap(); + + let mut rng = rand_chacha::ChaCha12Rng::from_seed(Default::default()); + + // NOTE + // This is an extremely scrappy histogram making tool. + // I don't like the popular tools provided in crates. + // They aren't tailored to this kind of analysis and + // we may want to develop something in the future. + let bins = make_bins(12, minimum_bin_edge, 0.5); + let mut histogram = vec![0; bins.len() - 1]; + for _ in 0..1000 { + let value = normal_distribution.sample(&mut rng); + for i in 0..bins.len() - 1 { + if value > bins[i] && value <= bins[i + 1] { + histogram[i] += 1; + break; + } + } + } + + let x = { + let mut v = vec![]; + for i in 0..bins.len() - 1 { + let offset = (bins[i + 1] - bins[i]) / 2.0; + v.push(bins[i] + offset); + } + v + }; + + let y = { + let mut temp_y = dvector![]; + for it in &histogram { + let v = f64::from(*it).ln(); + temp_y = temp_y.push(v); + } + temp_y + }; + let model = SeparableModelBuilder::::new(&["mu"]) + .initial_parameters(vec![1.0]) + .function(&["mu"], model) + .partial_deriv("mu", ddmu_model) + .invariant_function(|x| DVector::from_element(x.len(), 1.0)) + .independent_variable(x.into()) + .build() + .unwrap(); + + let problem = LevMarProblemBuilder::new(model) + .observations(y) + .build() + .unwrap(); + + let fit_result = LevMarSolver::default() + .fit(problem) + .expect("fit must exit successfully"); + + let linear_coefficients = fit_result.linear_coefficients().unwrap(); + let nonlinear_parameters = fit_result.nonlinear_parameters(); + + let fit_mean = *nonlinear_parameters.get(0).unwrap(); + let fit_std = (*linear_coefficients.get(0).unwrap()).powf(-0.5); + assert!( + (fit_mean - mean).abs() < standard_deviation, + "fit_std: {fit_std} fit_mean: {fit_mean} mean: {mean} std: {standard_deviation}, Data: {:?}", + (histogram, bins) + ); + // NOTE + // The 2.0 was chosen arbitrarily. Given a large sample size and small enough + // binning the variance of the standard deviation should much less than the standard + // deviation itself. The idea here is to check that we are in the right ball park and + // are flagged if there are any larger issues with implementation. + assert!( + (fit_std - standard_deviation).abs() < standard_deviation / 2.0, + "fit_std: {fit_std} fit_mean: {fit_mean} mean: {mean} std: {standard_deviation}, Data: {:?}", + (histogram, bins) + ); + } +} diff --git a/clock-bound-ff-tester/src/simulation/stats/string_parse.rs b/clock-bound-ff-tester/src/simulation/stats/string_parse.rs new file mode 100644 index 0000000..e1aef89 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/stats/string_parse.rs @@ -0,0 +1,91 @@ +use super::GammaDistribution; +use std::str::FromStr; + +impl FromStr for GammaDistribution { + type Err = String; + + /// Function parses a string and if successful returns a `GammaDistribution` struct. + /// + /// Currently this function assumes that the args in the string is ordered. + /// The format is: {`shape`,`rate`,`loc`} + /// + /// Example: + /// {1.0,2.0,1} + /// + /// Where: + /// `shape`: 1.0 + /// `rate`: 2.0 + /// `loc`: 1.0 + /// + /// # Errors + /// If input fails to parse the specified format + fn from_str(s: &str) -> Result { + let [shape, rate, loc] = { + let rv = s + .strip_prefix('{') + .and_then(|s| s.strip_suffix('}')) + .ok_or(String::from( + "Parsing distribution. Expected braces. No braces found.", + ))?; + let rv: Vec<&str> = rv.split(',').collect(); + + if rv.len() > 3 { + return Err(format!( + "Parsing distribution. Too many inputs. Expected 3. Found {}", + rv.len() + )); + } + if rv.len() < 3 { + return Err(format!( + "Parsing distribution. Too few inputs. Expected 3. Found {}", + rv.len() + )); + } + + [rv[0], rv[1], rv[2]] + }; + + let shape = shape + .trim() + .parse::() + .map_err(|_| String::from("ParseDelayArgs::ParseParam"))?; + let rate = rate + .trim() + .parse::() + .map_err(|_| String::from("ParseDelayArgs::ParseParam"))?; + let loc = loc + .trim() + .parse::() + .map_err(|_| String::from("ParseDelayArgs::ParseParam"))?; + + Result::Ok(GammaDistribution::new(shape, rate, loc).unwrap()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::floats("{1.0,2.0,3.0}", (1.0, 2.0, 3.0))] + #[case::ints("{1,2,3}", (1.0, 2.0, 3.0))] + #[case::whitespace("{1, 2, 3 }", (1.0, 2.0, 3.0))] + fn parsing_success(#[case] input: &str, #[case] expected: (f64, f64, f64)) { + let d = GammaDistribution::from_str(input).unwrap(); + + assert!(approx::relative_eq!(d.shape(), expected.0)); + assert!(approx::relative_eq!(d.rate(), expected.1)); + assert!(approx::relative_eq!(d.loc(), expected.2)); + } + + #[rstest] + #[case::missing_first_paran("1.0,2.0,3.0}")] + #[case::second_first_paran("{1.0,2.0,3.0")] + #[case::too_few("{1.0,2.0}")] + #[case::too_many("{1.0,1.0,1.0,2.0}")] + #[case::missing_comma("{1.0,1.02.0,3.0}")] + fn parsing_failure(#[case] input: &str) { + GammaDistribution::from_str(input).unwrap_err(); + } +} diff --git a/clock-bound-ff-tester/src/simulation/stats/truncated.rs b/clock-bound-ff-tester/src/simulation/stats/truncated.rs new file mode 100644 index 0000000..c7ab394 --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/stats/truncated.rs @@ -0,0 +1,105 @@ +//! Code specific to the Truncated distributions. + +use rand::{Rng, distributions::Distribution as RandDistribution}; + +/// Our internal truncated distribution +/// +/// Uses rejection sampling to sample from the inner distribution +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Truncated { + minimum: f64, + maximum: f64, + inner: T, +} + +impl Truncated { + /// Returns a Truncated Distribution we can sample from + /// + /// # Panics + /// This function panics if `maximum <= minimum` + pub fn new(minimum: f64, maximum: f64, inner: T) -> Self { + assert!( + (maximum > minimum), + "Maximum value ({maximum}) must be larger than minimum ({minimum}) value" + ); + + Self { + minimum, + maximum, + inner, + } + } +} + +impl> Truncated { + /// Returns a random value from the truncated distribution. + /// + /// # Panics + /// This function panics if the distribution is re-sampled equal to or more than 1 million + /// times. This is an arbitrary number, but it is a reasonable upper bound for the number of + /// tries needed to sample from the distribution. If this happens, the RNG is likely to be + /// broken. + fn sample(&self, rng: &mut R) -> f64 { + let mut rv = self.inner.sample(rng); + let mut loop_count = 0; + + while rv < self.minimum || rv > self.maximum { + rv = self.inner.sample(rng); + + assert!( + loop_count < 1_000_000, + "RNG failed to produce within bounds after enough tries" + ); + loop_count += 1; + } + rv + } +} + +impl> RandDistribution for Truncated { + fn sample(&self, rng: &mut R) -> f64 { + self.sample(rng) + } +} + +#[cfg(test)] +mod test { + use super::*; + + use crate::simulation::stats::NormalDistribution; + use rand_chacha; + use rand_chacha::rand_core::SeedableRng; + use rstest::rstest; + + #[rstest] + #[case::succcess(1.0, 2.0, -1.0, 3.0)] + #[case::succcess(0.0, 1.0, -0.5, 1.5)] + #[case::succcess(-1.0, 1.0, -1.0, 1.0)] + pub fn test_truncnormal_distribution_min_max( + #[case] mean: f64, + #[case] standard_deviation: f64, + #[case] min: f64, + #[case] max: f64, + ) { + let normal = NormalDistribution::new(mean, standard_deviation).unwrap(); + let distribution = Truncated::new(min, max, normal); + + let mut rng = rand_chacha::ChaCha12Rng::from_seed(Default::default()); + + let mut sample_min = f64::MAX; + let mut sample_max = f64::MIN; + for _ in 0..1000 { + let value = distribution.sample(&mut rng); + if value < min { + sample_min = value; + } + if value > max { + sample_max = value; + } + } + + assert!(sample_min > min, "sample_min: {sample_min}, min: {min}"); + + assert!(sample_max < max, "sample_max: {sample_max}, max: {max}"); + } +} diff --git a/clock-bound-ff-tester/src/simulation/vmclock.rs b/clock-bound-ff-tester/src/simulation/vmclock.rs new file mode 100644 index 0000000..08eb30a --- /dev/null +++ b/clock-bound-ff-tester/src/simulation/vmclock.rs @@ -0,0 +1,305 @@ +//! VMClock related generators and support + +use std::fmt::Debug; + +use crate::events::v1::{Event, EventKind, VMClock}; +use crate::time::{DemoteToEstimate, EstimateDuration, Period, TscDiff}; +use rand::RngCore; + +use super::{ + delay::{DelayRng, TimeUnit}, + interpolation::SeriesInterpolation, + oscillator::FullModel, +}; + +#[derive(Debug)] +pub struct Props { + /// The rate at which the VMClock changes + /// + /// This value is not like other poll periods, in that a real system will likely poll the + /// VMClock at a MUCH faster rate (thinking 100x a second). This value will reflect the + /// rate at which the VMClock may realistically change + pub update_period: EstimateDuration, + /// The maximum error of the frequency estimate + pub clock_period_max_error: Option>, + /// The amount of time in the past that the VMClock was updated + pub vmclock_time_lag: Box, +} + +/// A VMClock-only generator +/// +/// # Generation logic +/// +/// This struct generally generates a new event every `update_period` period of time. +/// +/// ## Clock Period +/// It calculates the `clock_period` with 100% accuracy on each generations. However, the clock frequency max error field +/// adds potential ranges of error estimates. +/// +/// ## VMClock Time +/// The current implementation has a *perfect* `vmclock_time` accuracy, and NO clock error bound reading. The +/// clock sync algorithm must handle this case. The VMClock time will be some value in the past of the `next_event`, +/// randomized with a `DelayRng` +pub struct Generator { + props: Props, + id: String, + next_poll: EstimateDuration, + next_event: Option, + rng: Box, +} + +impl Debug for Generator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Generator") + .field("props", &self.props) + .field("id", &self.id) + .field("next_poll", &self.next_poll) + .field("next_event", &self.next_event) + .finish_non_exhaustive() + } +} + +#[bon::bon] +impl Generator { + /// Constructor + #[builder] + pub fn new(props: Props, id: String, oscillator: &FullModel, rng: Box) -> Self { + let mut rv = Self { + id, + next_poll: props.update_period, + props, + next_event: None, + rng, + }; + + let next_event = rv.next_event(oscillator); + rv.next_event = next_event; + + rv + } +} + +impl Generator { + /// Generate the next event + fn next_event(&mut self, full_model: &FullModel) -> Option { + let tsc_start = self.next_poll * full_model.oscillator().clock_frequency() + + full_model.oscillator().tsc_timestamp_start(); + + // tsc values aren't REALLY used. Just put vaguely logical values. + let tsc_end = tsc_start + TscDiff::new(100); + + // Get the time in the past that this event occurred + let true_tsc_start_time = full_model + .true_time_to_tsc_timestamps() + .as_ref() + .reverse_approximate(tsc_start)?; // returns none if this is past the end of scenario + let lag = self.props.vmclock_time_lag.get_value(&mut self.rng); + let vmclock_time = true_tsc_start_time - lag; + + // now get the corresponding tsc value for vmclock_time + let (vmclock_tsc, vmclock_time) = if let Some(tsc) = full_model + .true_time_to_tsc_timestamps() + .as_ref() + .approximate(vmclock_time) + { + (tsc, vmclock_time) + } else { + // If we are going WAAAY too far in the past, just clamp for now + let (time, tsc) = full_model + .true_time_to_tsc_timestamps() + .as_ref() + .iter() + .next() + .unwrap(); + (*tsc, *time) + }; + + // the time since the start of the scenario + let true_vmclock_duration = vmclock_time - full_model.oscillator().start_time(); + + // lets use it to get the clock frequency/period of this time + let frequency = full_model + .frequencies() + .approximate(true_vmclock_duration) + .expect("frequency value should exist at this point"); + + let period = frequency.period(); + let period_max_error = self.props.clock_period_max_error.as_ref().map(|d| { + let (delay, unit) = d.get_value_tsc(&mut self.rng); + match unit { + TimeUnit::Secs => Period::from_seconds(delay), + TimeUnit::Millis => Period::from_seconds(delay / 1.0e3), + TimeUnit::Micros => Period::from_seconds(delay / 1.0e6), + TimeUnit::Nanos => Period::from_seconds(delay / 1.0e9), + } + }); + + Some(Event { + client_tsc_pre_time: tsc_start, + client_tsc_post_time: tsc_end, + variants: EventKind::VMClock(VMClock { + tsc_timestamp: vmclock_tsc, + vmclock_time: vmclock_time.demote_to_estimate(), + vmclock_time_max_error: None, // unsupported atm + clock_period: period, + clock_period_max_error: period_max_error, + source_id: self.id.clone(), + }), + }) + } +} + +impl crate::simulation::generator::Generator for Generator { + fn next_event_ready(&self, oscillator: &FullModel) -> Option { + if self.next_event.is_some() { + let true_dur = oscillator.oscillator_estimate_to_true_duration(self.next_poll)?; + let true_time = oscillator.oscillator().start_time() + true_dur; + let tsc_start = oscillator + .true_time_to_tsc_timestamps() + .as_ref() + .approximate(true_time)?; + Some(tsc_start) + } else { + None + } + } + + fn generate(&mut self, oscillator: &FullModel) -> Event { + let event = self.next_event.take().unwrap(); + + // update next poll time + self.next_poll += self.props.update_period; + + // generate next event + self.next_event = self.next_event(oscillator); + + event + } +} + +#[cfg(test)] +mod test { + use crate::simulation::{delay::Delay, oscillator::Oscillator}; + + use super::*; + use crate::simulation::generator::Generator as _; + use crate::time::{AssumeTrue, Frequency, Skew, TrueDuration, TrueInstant, TscCount}; + use rand::SeedableRng; + use rand_chacha::ChaCha12Rng; + use rstest::{fixture, rstest}; + use statrs::distribution::Dirac; + + #[fixture] + fn constant_skew() -> FullModel { + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(1.0)) + .start_time(TrueInstant::from_days(365 * 50)) + .duration(TrueDuration::from_hours(5)) + .tsc_timestamp_start(TscCount::new(10_000)) + .skew(Skew::from_ppm(-10.0)) + .starting_oscillator_offset(TrueDuration::from_micros(200)) + .call(); + FullModel::calculate_from_oscillator(oscillator) + } + + #[fixture] + fn dut_generator(constant_skew: FullModel) -> Generator { + let props = Props { + update_period: EstimateDuration::from_secs(50), + clock_period_max_error: Some(Box::new(Delay::new( + Dirac::new(0.3).unwrap(), + TimeUnit::Nanos, + ))), + vmclock_time_lag: Box::new(Delay::new(Dirac::new(1.0).unwrap(), TimeUnit::Secs)), + }; + + Generator::builder() + .props(props) + .id(String::from("test")) + .oscillator(&constant_skew) + .rng(Box::new(ChaCha12Rng::from_seed(Default::default()))) + .build() + } + + #[rstest] + fn generator_initialization(dut_generator: Generator) { + assert_eq!(dut_generator.id, "test"); + assert_eq!(dut_generator.next_poll, EstimateDuration::from_secs(50)); + assert!(dut_generator.next_event.is_some()); + } + + #[rstest] + fn next_event_ready(dut_generator: Generator, constant_skew: FullModel) { + let ready_time = dut_generator.next_event_ready(&constant_skew); + assert!(ready_time.is_some()); + } + + // Kind of a regression value test with TSC value + #[rstest] + fn generate_event(mut dut_generator: Generator, constant_skew: FullModel) { + let event = dut_generator.generate(&constant_skew); + + match event.variants { + EventKind::VMClock(vmclock) => { + assert_eq!(vmclock.source_id, "test"); + assert_eq!(vmclock.tsc_timestamp, TscCount::new(49_000_020_000)); + approx::assert_abs_diff_eq!(vmclock.clock_period.get(), 1.000_010e-9); + // Verify clock_period_max_error is present and matches our fixture setup + approx::assert_abs_diff_eq!(vmclock.clock_period_max_error.unwrap().get(), 3e-10); + } + _ => panic!("Expected VMClock event"), + } + + // Verify next event is generated + assert!(dut_generator.next_event.is_some()); + + // Verify next_poll was updated + assert_eq!( + dut_generator.next_poll, + EstimateDuration::from_secs(100) // Initial 50 + update_period of 50 + ); + } + + #[rstest] + fn vmclock_time_lag(mut dut_generator: Generator, constant_skew: FullModel) { + let event = dut_generator.generate(&constant_skew); + + if let EventKind::VMClock(vmclock) = event.variants { + // Given our fixture's vmclock_time_lag of 1 second + let event_time = vmclock.vmclock_time; + let true_time = constant_skew.oscillator().start_time() + + constant_skew + .oscillator_estimate_to_true_duration(dut_generator.props.update_period) + .expect("Should convert duration"); + + // The vmclock time should be approximately 1 second behind due to the lag + let difference = true_time - event_time.assume_true(); + assert!(difference.as_seconds_f64() > 0.9 && difference.as_seconds_f64() < 1.1); + } else { + panic!("Expected VMClock event"); + } + } + + #[rstest] + fn multiple_generations(mut dut_generator: Generator, constant_skew: FullModel) { + // Generate several events and verify consistency + let mut last_event = None; + for i in 0..3 { + let event = dut_generator.generate(&constant_skew); + + let EventKind::VMClock(vmclock) = event.variants else { + panic!("Expected VMClock event"); + }; + assert_eq!(vmclock.source_id, "test"); + assert!(vmclock.tsc_timestamp > TscCount::new(0)); + + // Verify increasing timestamps + if i == 0 { + last_event = Some(vmclock.vmclock_time); + } else { + assert!(vmclock.vmclock_time > last_event.unwrap()); + last_event = Some(vmclock.vmclock_time); + } + } + } +} diff --git a/clock-bound-ff-tester/src/time/estimate_instant.rs b/clock-bound-ff-tester/src/time/estimate_instant.rs index a605202..0eb9e6a 100644 --- a/clock-bound-ff-tester/src/time/estimate_instant.rs +++ b/clock-bound-ff-tester/src/time/estimate_instant.rs @@ -4,7 +4,7 @@ //! come from an external reading and may have known inaccuracies. //! //! A linux "system time" is an "estimate time" in this definition. However, a linux system time is a linux specific clock, -//! and this crate uses a different name to prevent confusion from that term. For example, a TSC reading (a raw timestamp), +//! and this crate uses a different name to prevent confusion from that term. For example, a TSC reading, //! multiplied by its current frequency and start time creates another estimate time. But that time is not the same as the //! linux system time. diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 030139e..e288ae4 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -55,6 +55,8 @@ daemon = [ "dep:thiserror", "tracing-subscriber/env-filter", ] +time-string-parse = ["dep:nom"] + default = ["client", "daemon"] [[bin]] diff --git a/clock-bound/src/daemon/time/clocks.rs b/clock-bound/src/daemon/time/clocks.rs index 3af1d05..c034c3b 100644 --- a/clock-bound/src/daemon/time/clocks.rs +++ b/clock-bound/src/daemon/time/clocks.rs @@ -73,7 +73,8 @@ mod tests { let now_std = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap(); - assert!(now_std.as_secs().abs_diff(now.as_seconds() as u64) < 1); + let now = now - Instant::UNIX_EPOCH; + approx::assert_abs_diff_eq!(now_std.as_secs_f64(), now.as_seconds_f64(), epsilon = 0.01) } // Naively show that CLOCK_MONOTONIC_RAW is monotonic (and hope that it's raw) diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index c702b90..2a634ab 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -2,6 +2,9 @@ //! //! Simpler representation of time than `nix::time::TimeSpec`. Just abstractions over integer math +#[cfg(feature = "time-string-parse")] +pub mod string_parse; + use std::{ marker::PhantomData, ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}, @@ -189,39 +192,42 @@ impl Time { self.get() / FEMTOS_PER_PICO } - /// Returns the total number of nanoseconds since the Unix Epoch + /// Returns the total number of nanoseconds, rounded since the Unix Epoch pub const fn as_nanos(self) -> i128 { - self.get() / FEMTOS_PER_NANO + (self.get() + FEMTOS_PER_NANO / 2) / FEMTOS_PER_NANO } - /// Returns the total number of microseconds, truncated, since the Unix Epoch + /// Returns the total number of microseconds, rounded, since the Unix Epoch pub const fn as_micros(self) -> i128 { - self.get() / FEMTOS_PER_MICRO + (self.get() + FEMTOS_PER_MICRO / 2) / FEMTOS_PER_MICRO } - /// Returns the total number of milliseconds, truncated, since the Unix Epoch + /// Returns the total number of milliseconds, rounded, since the Unix Epoch pub const fn as_millis(self) -> i128 { - self.get() / FEMTOS_PER_MILLI + (self.get() + FEMTOS_PER_MILLI / 2) / FEMTOS_PER_MILLI } - /// Returns the total number of seconds, truncated, since the Unix Epoch + /// Returns the total number of seconds, rounded, since the Unix Epoch pub const fn as_seconds(self) -> i128 { - self.get() / FEMTOS_PER_SEC + (self.get() + FEMTOS_PER_SEC / 2) / FEMTOS_PER_SEC } - /// Returns the total number of minutes, truncated, since the Unix Epoch + /// Returns the total number of minutes, rounded, since the Unix Epoch pub const fn as_minutes(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE) + const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE; + (self.get() + SCALE_FACTOR / 2) / SCALE_FACTOR } - /// Returns the total number of hours, truncated, since the Unix Epoch + /// Returns the total number of hours, rounded, since the Unix Epoch pub const fn as_hours(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR; + (self.get() + SCALE_FACTOR / 2) / SCALE_FACTOR } - /// Returns the total number of days, truncated, since the Unix Epoch + /// Returns the total number of days, rounded, since the Unix Epoch pub const fn as_days(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY; + (self.get() + SCALE_FACTOR / 2) / SCALE_FACTOR } } @@ -384,38 +390,38 @@ impl Diff { /// Create a new [`Diff`] from the number of seconds in `f64` format /// - /// Will truncate to the nearest femtosecond + /// Will round to the nearest femtosecond #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] pub const fn from_seconds_f64(secs: f64) -> Self { - Self::new((secs * FEMTOS_PER_SEC as f64) as i128) + Self::new((secs * FEMTOS_PER_SEC as f64).round() as i128) } /// Create a new [`Diff`] from the number of milliseconds in `f64` format /// - /// Will truncate to the nearest femtosecond + /// Will round to the nearest femtosecond #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] pub const fn from_millis_f64(millis: f64) -> Self { - Self::new((millis * FEMTOS_PER_MILLI as f64) as i128) + Self::new((millis * FEMTOS_PER_MILLI as f64).round() as i128) } /// Create a new [`Diff`] from the number of microseconds in `f64` format /// - /// Will truncate to the nearest femtosecond + /// Will round to the nearest femtosecond #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] pub const fn from_micros_f64(micros: f64) -> Self { - Self::new((micros * FEMTOS_PER_MICRO as f64) as i128) + Self::new((micros * FEMTOS_PER_MICRO as f64).round() as i128) } /// Create a new [`Diff`] from the number of nanoseconds in `f64` format /// - /// Will truncate to the nearest femtosecond + /// Will round to the nearest femtosecond #[expect(clippy::cast_possible_truncation, reason = "truncation documented")] #[expect(clippy::cast_precision_loss, reason = "const will not wrap")] pub const fn from_nanos_f64(nanos: f64) -> Self { - Self::new((nanos * FEMTOS_PER_NANO as f64) as i128) + Self::new((nanos * FEMTOS_PER_NANO as f64).round() as i128) } /// Create a new [`Diff`] from the number of milliseconds @@ -475,50 +481,63 @@ impl Diff { self.get() } - /// Returns the total number of picoseconds, truncated + /// Returns the total number of picoseconds, rounded pub const fn as_picos(self) -> i128 { - self.get() / FEMTOS_PER_PICO + (self.get() + FEMTOS_PER_PICO / 2) / FEMTOS_PER_PICO } - /// Returns the total number of nanoseconds, truncated + /// Returns the total number of nanoseconds, rounded pub const fn as_nanos(self) -> i128 { - self.get() / FEMTOS_PER_NANO + (self.get() + FEMTOS_PER_NANO / 2) / FEMTOS_PER_NANO } - /// Returns the total number of microseconds, truncated + /// Returns the total number of microseconds, rounded pub const fn as_micros(self) -> i128 { - self.get() / FEMTOS_PER_MICRO + (self.get() + FEMTOS_PER_MICRO / 2) / FEMTOS_PER_MICRO } - /// Returns the total number of milliseconds, truncated + /// Returns the total number of milliseconds, rounded pub const fn as_millis(self) -> i128 { - self.get() / FEMTOS_PER_MILLI + (self.get() + FEMTOS_PER_MILLI / 2) / FEMTOS_PER_MILLI } - /// Returns the total number of seconds, truncated + /// Returns the total number of seconds, rounded pub const fn as_seconds(self) -> i128 { + (self.get() + FEMTOS_PER_SEC / 2) / FEMTOS_PER_SEC + } + + /// Returns the total number of seconds, truncated + pub const fn as_seconds_trunc(self) -> i128 { self.get() / FEMTOS_PER_SEC } + /// Returns the total number of nanoseconds, truncated + pub const fn as_nanos_trunc(self) -> i128 { + self.get() / FEMTOS_PER_NANO + } + /// Returns the total number of seconds as a f64 #[expect(clippy::cast_precision_loss, reason = "division mitigates")] pub const fn as_seconds_f64(self) -> f64 { self.get() as f64 / FEMTOS_PER_SEC as f64 } - /// Returns the total number of minutes, truncated + /// Returns the total number of minutes, rounded pub const fn as_minutes(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE) + const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE; + (self.get() + SCALE_FACTOR / 2) / SCALE_FACTOR } - /// Returns the total number of hours, truncated + /// Returns the total number of hours, rounded pub const fn as_hours(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR) + const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR; + (self.get() + SCALE_FACTOR / 2) / SCALE_FACTOR } - /// Returns the total number of days, truncated + /// Returns the total number of days, rounded pub const fn as_days(self) -> i128 { - self.get() / (FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY) + const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY; + (self.get() + SCALE_FACTOR / 2) / SCALE_FACTOR } /// Returns the `Duration` converted to a `timeval`. @@ -535,8 +554,8 @@ impl Diff { )] pub fn to_timeval_nanos(self) -> timeval { let mut tv = timeval { - tv_sec: self.as_seconds() as i64, - tv_usec: (self.as_nanos() % NANOS_PER_SECOND) as i64, + tv_sec: self.as_seconds_trunc() as i64, + tv_usec: (self.as_nanos_trunc() % NANOS_PER_SECOND) as i64, }; // Normalize the timeval, as `tv_usec` cannot be negative (so we push the "negative" place into `tv_sec`) if tv.tv_usec < 0 { @@ -726,7 +745,7 @@ mod test { #[test] fn rounding() { let time = Instant::from_time(1, 500_000_000); - assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_seconds(), 2); assert_eq!(time.as_nanos(), 1_500_000_000); } @@ -781,7 +800,7 @@ mod test { #[test] fn duration_truncating() { let time = Duration::from_nanos(1_500_000_000); - assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_seconds_trunc(), 1); assert_eq!(time.as_nanos(), 1_500_000_000); } @@ -809,7 +828,7 @@ mod test { #[test] fn duration_constructor() { let time = Duration::from_time(1, 500_000_000); - assert_eq!(time.as_seconds(), 1); + assert_eq!(time.as_seconds_trunc(), 1); assert_eq!(time.as_nanos(), 1_500_000_000); } @@ -835,7 +854,7 @@ mod test { #[test] fn duration_nanos_f64_conversion() { let duration = Duration::from_nanos_f64(1.5); - assert_eq!(duration.as_nanos(), 1); + assert_eq!(duration.as_nanos(), 2); } #[rstest::rstest] diff --git a/clock-bound/src/daemon/time/inner/string_parse.rs b/clock-bound/src/daemon/time/inner/string_parse.rs new file mode 100644 index 0000000..eda2892 --- /dev/null +++ b/clock-bound/src/daemon/time/inner/string_parse.rs @@ -0,0 +1,165 @@ +//! String parse functionality for time types + +// developers note. Functionality is needed for setting values +// in `ff-tester` for high level parameters. Do not need to set +// femtosecond level granularity +pub trait DurationParse: Sized { + fn from_nanoseconds(nanos: i128) -> Self; + + /// Parse from a duration of time + /// + /// Takes in a signed integer plus a suffix of + /// - days + /// - hours + /// - minutes + /// - seconds + /// - milliseconds + /// - microseconds + /// - nanoseconds + /// + /// Without a suffix, nanoseconds are the default type + /// + /// # Errors + /// Errors if + fn parse_from_duration(s: &str) -> Result { + let s = s.trim(); + let (rest, duration) = + nom::character::complete::digit1::<_, ()>(s).map_err(|_| "Failed to parse duration")?; + let duration = duration + .parse::() + .map_err(|_| "i128 parsing failed")?; + + let suffix = rest.trim(); + + match suffix.to_lowercase().as_str() { + "" => Ok(Self::from_nanoseconds(duration)), + "ns" | "nano" | "nanos" | "nanosecond" | "nanoseconds" => { + Ok(Self::from_nanoseconds(duration)) + } + "us" | "micro" | "micros" | "microsecond" | "microseconds" => { + Ok(Self::from_nanoseconds(duration * NANOS_PER_MICRO)) + } + "ms" | "milli" | "millis" | "millisecond" | "milliseconds" => { + Ok(Self::from_nanoseconds(duration * NANOS_PER_MILLI)) + } + "s" | "sec" | "secs" | "second" | "seconds" => { + Ok(Self::from_nanoseconds(duration * NANOS_PER_SEC)) + } + "m" | "min" | "mins" | "minute" | "minutes" => Ok(Self::from_nanoseconds( + duration * NANOS_PER_SEC * SECS_PER_MINUTE, + )), + "h" | "hr" | "hrs" | "hour" | "hours" => Ok(Self::from_nanoseconds( + duration * NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR, + )), + "d" | "day" | "days" => Ok(Self::from_nanoseconds( + duration * NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY, + )), + _ => Err(format!("Unknown suffix: {suffix}")), + } + } +} + +impl DurationParse for super::Time { + fn from_nanoseconds(nanos: i128) -> Self { + Self::from_nanos(nanos) + } +} + +impl DurationParse for super::Diff { + fn from_nanoseconds(nanos: i128) -> Self { + Self::from_nanos(nanos) + } +} + +impl std::str::FromStr for super::Time { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::parse_from_duration(s) + } +} + +impl std::str::FromStr for super::Diff { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::parse_from_duration(s) + } +} + +const NANOS_PER_SEC: i128 = 1_000_000_000; +const NANOS_PER_MILLI: i128 = 1_000_000; +const NANOS_PER_MICRO: i128 = 1_000; +const SECS_PER_MINUTE: i128 = 60; +const MINS_PER_HOUR: i128 = 60; +const HOURS_PER_DAY: i128 = 24; + +#[cfg(test)] +mod test { + use super::*; + use crate::daemon::time::{Duration, Instant}; + + use rstest::rstest; + + #[rstest] + #[case("1", 1)] + #[case("1ns", 1)] + #[case("1nano", 1)] + #[case("1nanos", 1)] + #[case("1nanosecond", 1)] + #[case("1nanoseconds", 1)] + #[case("1us", NANOS_PER_MICRO)] + #[case("1micro", NANOS_PER_MICRO)] + #[case("1micros", NANOS_PER_MICRO)] + #[case("1microsecond", NANOS_PER_MICRO)] + #[case("1microseconds", NANOS_PER_MICRO)] + #[case("1ms", NANOS_PER_MILLI)] + #[case("1milli", NANOS_PER_MILLI)] + #[case("1millis", NANOS_PER_MILLI)] + #[case("1millisecond", NANOS_PER_MILLI)] + #[case("1milliseconds", NANOS_PER_MILLI)] + #[case("1s", NANOS_PER_SEC)] + #[case("1sec", NANOS_PER_SEC)] + #[case("1secs", NANOS_PER_SEC)] + #[case("1second", NANOS_PER_SEC)] + #[case("1seconds", NANOS_PER_SEC)] + #[case("1m", NANOS_PER_SEC * SECS_PER_MINUTE)] + #[case("1min", NANOS_PER_SEC * SECS_PER_MINUTE)] + #[case("1mins", NANOS_PER_SEC * SECS_PER_MINUTE)] + #[case("1minute", NANOS_PER_SEC * SECS_PER_MINUTE)] + #[case("1minutes", NANOS_PER_SEC * SECS_PER_MINUTE)] + #[case("1h", NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR)] + #[case("1hr", NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR)] + #[case("1hrs", NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR)] + #[case("1hour", NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR)] + #[case("1hours", NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR)] + #[case("1d", NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY)] + #[case("1day", NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY)] + #[case("1days", NANOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY)] + fn test_duration_parse(#[case] input: &str, #[case] expected: i128) { + let duration = Duration::parse_from_duration(input).unwrap(); + assert_eq!(duration.as_nanos(), expected); + let instant = Instant::parse_from_duration(input).unwrap(); + assert_eq!(instant.as_nanos(), expected); + } + + #[rstest] + #[case::trimming(" 1s ", NANOS_PER_SEC)] + #[case::case_insensitivity("1S", NANOS_PER_SEC)] + #[case::uppercase("1SEC", NANOS_PER_SEC)] + fn test_duration_parse_edge_cases(#[case] input: &str, #[case] expected: i128) { + let duration = Duration::parse_from_duration(input).unwrap(); + assert_eq!(duration.as_nanos(), expected); + } + + #[rstest] + #[case("")] + #[case("abc")] + #[case("1x")] + #[case("seconds")] + #[case("1.5s")] + #[case("-1s")] + fn test_duration_parse_errors(#[case] input: &str) { + let _ = Duration::parse_from_duration(input).unwrap_err(); + } +} diff --git a/clock-bound/src/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs index 2720c0f..b4f92ba 100644 --- a/clock-bound/src/daemon/time/timex.rs +++ b/clock-bound/src/daemon/time/timex.rs @@ -66,7 +66,7 @@ impl Timex { // Set `modes` bits for all fields we modify, plus ADJ_NANO to use nanosecond units // and ADJ_STATUS to set status bits below. modes: MOD_FREQUENCY | MOD_OFFSET | MOD_TIMECONST | MOD_NANO | MOD_STATUS, - offset: phase_correction.as_nanos() as i64, + offset: phase_correction.as_nanos_trunc() as i64, freq: skew.to_timex_freq(), maxerror: 0, esterror: 0, diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index cdfd53b..03b9489 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -3,6 +3,7 @@ #![expect(clippy::cast_precision_loss)] use crate::daemon::time::Instant; +use crate::daemon::time::inner::FemtoType; use super::Duration; use super::inner::{Diff, Time}; @@ -66,6 +67,24 @@ impl TscCount { } } +#[cfg(feature = "time-string-parse")] +impl std::str::FromStr for TscCount { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { + i128::from_str(s).map(Self::new) + } +} + +#[cfg(feature = "time-string-parse")] +impl std::str::FromStr for TscDiff { + type Err = std::num::ParseIntError; + + fn from_str(s: &str) -> Result { + i128::from_str(s).map(Self::new) + } +} + /// A frequency in Hz /// /// ## Note on lossy-ness @@ -122,6 +141,33 @@ impl Frequency { } } +#[cfg(feature = "time-string-parse")] +impl std::str::FromStr for Frequency { + type Err = String; + + fn from_str(s: &str) -> Result { + use nom::error::ErrorKind; + + let s = s.trim(); + let (rest, freq) = + nom::number::complete::double::<_, (&str, ErrorKind)>(s).map_err(|e| e.to_string())?; + + if freq <= 0.0 { + return Err("Frequency must be positive".to_string()); + } + + let suffix = rest.trim(); + + match suffix.to_lowercase().as_str() { + "" | "hz" => Ok(Self::from_hz(freq)), + "khz" => Ok(Self::from_khz(freq)), + "mhz" => Ok(Self::from_mhz(freq)), + "ghz" => Ok(Self::from_ghz(freq)), + _ => Err(format!("Unknown suffix: {suffix}")), + } + } +} + impl Display for Frequency { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} Hz", self.0) @@ -146,17 +192,25 @@ impl Div for TscDiff { fn div(self, rhs: Frequency) -> Self::Output { let raw = self.get() as f64; let duration_femtos = raw / rhs.0 * 1.0e15; - Duration::from_femtos(duration_femtos as i128) + Duration::from_femtos(duration_femtos.round() as i128) } } -impl Mul for Duration { +impl Mul for Diff { type Output = TscDiff; fn mul(self, rhs: Frequency) -> Self::Output { let duration_femtos = self.as_femtos() as f64; let raw = duration_femtos * rhs.0 / 1.0e15; - TscDiff::new(raw as i128) + TscDiff::new(raw.round() as i128) + } +} + +impl Mul for Frequency { + type Output = TscDiff; + + fn mul(self, rhs: Duration) -> Self::Output { + rhs * self } } @@ -239,6 +293,28 @@ impl Display for Skew { } } +#[cfg(feature = "time-string-parse")] +impl std::str::FromStr for Skew { + type Err = String; + + fn from_str(s: &str) -> Result { + use nom::error::ErrorKind; + + let val = s.trim(); + let (rest, skew) = nom::number::complete::double::<_, (&str, ErrorKind)>(val) + .map_err(|e| e.to_string())?; + + let suffix = rest.trim(); + + match suffix.to_lowercase().as_str() { + "" => Ok(Self(skew)), + "%" | "percent" => Ok(Self::from_percent(skew)), + "ppm" => Ok(Self::from_ppm(skew)), + _ => Err(format!("Unknown suffix: {suffix}")), + } + } +} + /// A representation of a TSC clock period in seconds /// /// Logically, this value is the mathematical inverse of [`Frequency`]. In other words, @@ -327,6 +403,30 @@ impl Div for Duration { } } +#[cfg(feature = "time-string-parse")] +impl std::str::FromStr for Period { + type Err = String; + + fn from_str(s: &str) -> Result { + use nom::error::ErrorKind; + + let val = s.trim(); + let (rest, period) = nom::number::complete::double::<_, (&str, ErrorKind)>(val) + .map_err(|e| e.to_string())?; + + if period <= 0.0 { + return Err("Period must be positive".to_string()); + } + + let suffix = rest.trim(); + + match suffix.to_lowercase().as_str() { + "" | "s" | "sec" | "second" | "seconds" => Ok(Self(period)), + _ => Err(format!("Unknown suffix: {suffix}")), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -509,4 +609,64 @@ mod tests { let tsc = TscCount::from_uncorrected_time(uncorrected, p, k); assert_eq!(tsc.get(), 1_000_000_000); } + + #[cfg(feature = "time-string-parse")] + #[rstest] + #[case("1.0", 1.0)] + #[case("1 Hz", 1.0)] + #[case("1 kHz", 1000.0)] + #[case("1 MHz", 1_000_000.0)] + #[case("1ghz", 1_000_000_000.0)] + fn frequency_parse_from_str_valid(#[case] input: &str, #[case] expected: f64) { + use std::str::FromStr; + let freq = Frequency::from_str(input).unwrap(); + assert_abs_diff_eq!(freq.get(), expected); + } + + #[cfg(feature = "time-string-parse")] + #[rstest] + #[case("")] + #[case("invalid")] + #[case::negative("-1 Hz")] + #[case("1 InvalidUnit")] + fn frequency_parse_from_str_invalid(#[case] input: &str) { + use std::str::FromStr; + let _ = Frequency::from_str(input).unwrap_err(); + } + + #[cfg(feature = "time-string-parse")] + #[rstest] + #[case("0.001", 0.001)] + #[case("100 ppm", 0.0001)] + #[case("5%", 0.05)] + #[case("5 percent", 0.05)] + fn skew_parse_from_str(#[case] input: &str, #[case] expected: f64) { + use std::str::FromStr; + let skew = Skew::from_str(input).unwrap(); + assert_abs_diff_eq!(skew.get(), expected); + } + + #[cfg(feature = "time-string-parse")] + #[rstest] + #[case("1.0", 1.0)] + #[case("1s", 1.0)] + #[case("0.0000000000000001seconds", 0.000_000_000_000_000_1)] + #[case("0.001 sec", 0.001)] + #[case("1000second", 1000.0)] + fn period_parse_from_str_valid(#[case] input: &str, #[case] expected: f64) { + use std::str::FromStr; + let freq = Period::from_str(input).unwrap(); + assert_abs_diff_eq!(freq.get(), expected); + } + + #[cfg(feature = "time-string-parse")] + #[rstest] + #[case("")] + #[case("invalid")] + #[case::negative("-1 Hz")] + #[case("1 InvalidUnit")] + fn period_parse_from_str_invalid(#[case] input: &str) { + use std::str::FromStr; + let _ = Period::from_str(input).unwrap_err(); + } } From 2f42eda01f4982548510f77e66e838db4600c706 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Thu, 23 Oct 2025 14:00:41 -0400 Subject: [PATCH 039/177] Refactors the linklocal struct removing option to create w/o cdc (#48) The linklocal object previously contained a struct which made it possible to create an linklocal object in a bad state, without a clock disruption channel. This commit flattens the struct and removes the option to create an object without the clock disruption channel. --- clock-bound/src/daemon/io.rs | 34 ++++++++++---------------------- clock-bound/src/daemon/io/ntp.rs | 28 +++++++++++++++----------- 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index e4d4adb..bb1b48d 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -51,18 +51,19 @@ impl SourceIO { debug!(?entry, "Current source entry status"); entry.or_insert_with(|| { let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); - let communication_channels = CommunicationChannels { - event_sender, - ctrl_receiver, - clock_disruption_receiver: Some(self.clock_disruption_channels.sender.subscribe()), - }; + let clock_disruption_receiver = self.clock_disruption_channels.sender.subscribe(); spawn(async move { let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) .await .unwrap(); - let mut linklocal = LinkLocal::construct(socket, communication_channels); - linklocal.run().await; + let mut link_local = LinkLocal::construct( + socket, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ); + link_local.run().await; }); ctrl_sender }); @@ -84,28 +85,13 @@ struct ClockDisruptionChannels { receiver: watch::Receiver, } -/// Communication channels for IO tasks. -#[derive(Debug)] -pub struct CommunicationChannels { - /// The channel which the IO task passes NTP events. - event_sender: mpsc::Sender, - - /// The channel which the IO task receives control events. - ctrl_receiver: mpsc::Receiver, - - /// The channel which the IO task receives clock disruption events. - /// - /// If the IO task is a VMClock task the no receiver is needed. - clock_disruption_receiver: Option>, -} - // TODO: This is a stub for future clock disruption events. #[derive(Clone, Debug)] -struct ClockDisruptionEvent {} +pub struct ClockDisruptionEvent {} // TODO: This is a stub for future control events. #[derive(Debug)] -struct ControlRequest {} +pub struct ControlRequest {} /// `TimeSource` is a type representing the possible time sources the daemon can collect samples /// from. diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index bd193a7..b6e393d 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -6,13 +6,13 @@ use thiserror::Error; use tokio::{ io, net::UdpSocket, - sync::mpsc, + sync::{mpsc, watch}, time::{self, Duration, Interval, interval, timeout}, }; use tracing::{debug, info}; -use super::CommunicationChannels; use super::tsc::read_timestamp_counter; +use super::{ClockDisruptionEvent, ControlRequest}; use crate::daemon::{ event::{self, NtpData}, time::tsc::TscCount, @@ -44,17 +44,26 @@ pub enum LinkLocalError { #[derive(Debug)] pub struct LinkLocal { socket: UdpSocket, - communication_channels: CommunicationChannels, + event_sender: mpsc::Sender, + ctrl_receiver: mpsc::Receiver, + clock_disruption_receiver: watch::Receiver, ntp_buffer: [u8; Packet::SIZE], interval: Interval, } impl LinkLocal { /// Constructs a new `LinkLocal` with using given parameters. - pub fn construct(socket: UdpSocket, communication_channels: CommunicationChannels) -> Self { + pub fn construct( + socket: UdpSocket, + event_sender: mpsc::Sender, + ctrl_receiver: mpsc::Receiver, + clock_disruption_receiver: watch::Receiver, + ) -> Self { LinkLocal { socket, - communication_channels, + event_sender, + ctrl_receiver, + clock_disruption_receiver, ntp_buffer: [0u8; Packet::SIZE], interval: interval(INTERVAL_DURATION), } @@ -102,10 +111,7 @@ impl LinkLocal { })?; debug!(?recv_packet_result, "Received packet."); - self.communication_channels - .event_sender - .send(ntp_event.clone()) - .await?; + self.event_sender.send(ntp_event.clone()).await?; debug!(?ntp_event, "Successfully send link local event."); Ok(()) } @@ -126,12 +132,12 @@ impl LinkLocal { debug!(?e, "Failed to sample link local source."); } } - _ = self.communication_channels.ctrl_receiver.recv() => { + _ = self.ctrl_receiver.recv() => { // Ctrl logic here. // Currently we breakout of the loop if we receive a control event. break; } - _ = self.communication_channels.clock_disruption_receiver.as_mut().unwrap().changed() => { + _ = self.clock_disruption_receiver.changed() => { // Clock Disruption logic here todo!("Clock disruption logic has yet to be implemented."); } From 337869f964805b4d330f9a326dc1d5bbd2cd8302 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 24 Oct 2025 09:48:25 -0400 Subject: [PATCH 040/177] [ff] Implement estimate event buffer. (#49) Slightly refactor local event buffer so that the expunge fn does not require a tsc cutoff. --- .../ff/event_buffer/estimate.rs | 398 +++++++++++++++++- .../ff/event_buffer/local.rs | 12 +- 2 files changed, 402 insertions(+), 8 deletions(-) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs index 04516e4..420a7f1 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs @@ -1,11 +1,403 @@ //! An estimate event buffer +use std::num::NonZeroUsize; -use std::marker::PhantomData; +use super::Local; +use crate::daemon::{ + clock_sync_algorithm::RingBuffer, + event::TscRtt, + time::{Duration, TscCount, tsc::Period}, +}; /// An estimate ring buffer /// -/// TODO: Implement +/// # Intended use +/// Constantly feed a [`Local`] estimate buffer and then feed the [`Estimate`] with +/// [`Estimate::feed`]. This will do nothing in most calls, but if the SKM window has expired, +/// it will take the lowest RTT value in [`Local`] and push it into [`Estimate`]. +/// +/// This way a long term estimate on the TSC period can be done. +/// +/// It is expected that the same [`Local`] buffer is passed into a constructed [`Estimate`] for the lifetime +/// of both buffers (they are eternally linked). +/// +/// # Storage and capacity +/// This one stores the best RTT values of each SKM window +/// over a longer period of time (upwards of 1 week) +/// +/// Generally the capacity of this will remain static, as we need to hold 1 week of +/// data where each sample is the best datapoint in an SKM window. This is roughly +/// a datapoint every 1,000 seconds for 604,800 seconds, leading to ~600 data points max. +/// +/// Note that this buffer is not strictly time-bound. If a SKM window is completely starved for +/// hours (days!), it still has a capacity of 600 and will end up storing longer periods of time. +/// Samples don't have a concept of expiring, we are just trading off longer duration estimates +/// with memory. +/// +/// # Windowing +/// When *attempting* to add a new event to the [`Local`] ring buffer, if it is greater than +/// 1000 seconds since the last time the [`Estimate`] ring buffer was updated, we pick the min +/// rtt value from the [`Local`] ring buffer, and push that onto the [`Estimate`] ring buffer. +/// +/// When we search for the min rtt value, we exclude the value just added to the [`Local`] ring +/// buffer as that value is outside the SKM window. +/// +/// # Initialization +/// This struct will initialize on the first call to [`Estimate::feed`] with the oldest timestamp in the [`Local`] +/// buffer. +/// +/// # Starvation +/// What happens if there are NO good data points for an SKM? That's an issue for [`Local`] to handle. But windows without +/// anything in Local will just early exit, as there is nothing to feed into [`Estimate`] #[derive(Debug, Clone)] +#[cfg_attr(test, derive(bon::Builder))] // Workaround the strict invariants that make testing un-ergonomic pub struct Estimate { - _phantom: PhantomData, + /// The inner storage + inner: RingBuffer, + /// The `tsc_post` of the last event we added to the ring buffer + /// + /// Value is `Some` if there are values in `self.inner` OR + /// this has been initialized in an early [`Estimate::feed`] call + last_tsc_post: Option, +} + +impl Estimate { + // Hold data from 600 SKM windows + const CAPACITY: NonZeroUsize = NonZeroUsize::new(600).unwrap(); + + /// Construct + pub fn new() -> Self { + Self { + inner: RingBuffer::new(Self::CAPACITY), + last_tsc_post: None, + } + } + + /// get the `last_tsc_post` + /// + /// None if no data was ever in the paired `Local` on feed + pub fn last_tsc_post(&self) -> Option { + self.last_tsc_post + } + + /// get the length + pub fn len(&self) -> usize { + self.inner.len() + } + + /// returns true if the buffer is empty + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Returns an iterator of events from oldest to newest + pub fn iter(&self) -> impl DoubleEndedIterator { + self.inner.iter() + } + + fn push(&mut self, event: T, now_tsc_post: TscCount) { + self.last_tsc_post = Some(now_tsc_post); + self.inner.push(event); + } +} + +impl Estimate { + /// Feed the ring buffer IF the SKM window expired + /// + /// Given a [`Local`] ring buffer that has been [`Estimate::feed`] with an event at `now_event_tsc_post`, + /// update the [`Estimate`] ring buffer if the SKM window has expired + /// + /// Returns `Some` with the updated value if an event was fed in. `None` otherwise. + /// + /// # Period input + /// Updating the estimate event buffer requires a period measurement. This is because this buffer + /// is inherently time based (it stores the best result of every 1024 second window, which assumes + /// we know the tsc rate..). + /// + /// This means the `estimate` event buffer cannot start to be populated until we have an initial period calculation. + /// + /// ## Starvation + /// Ideally the local buffer is relatively full of samples by the time the SKM window expires. However, if the local + /// buffer is empty for an entire SKM window, that data point will simply skip. + #[expect(clippy::missing_panics_doc, reason = "unwraps commented")] + pub fn feed(&mut self, local: &Local, period_estimate: Period) -> Option<&T> { + // bail out if local is empty. + let head = local.as_ref().head()?; + let now_event_tsc_post = head.tsc_post(); + + let Some(last_tsc_post) = self.last_tsc_post else { + // We are not initialized. Initialize and bail out. + // unwrap okay. local confirmed as not empty above + let tail_tsc_post = local.as_ref().tail().unwrap().tsc_post(); + self.last_tsc_post = Some(tail_tsc_post); + return None; + }; + + let diff = now_event_tsc_post - last_tsc_post; + let duration = diff * period_estimate; + + if duration < Duration::from_secs(1000) { + // SKM window hasn't expired yet. Bail out + None + } else { + // find the min rtt value in the local buffer + // exclude the value we just added + // + // rev() because iter goes from oldest to newest + let mut rev_iter = local.iter().rev().peekable(); + + if let Some(event) = rev_iter.peek() + && event.tsc_post() == now_event_tsc_post + { + // if this is the value we just added skip + rev_iter.next(); + } + + let min_rtt_event = rev_iter.min_by_key(|event| event.rtt()); + + let Some(min_rtt_event) = min_rtt_event else { + // could happen if we call this function with a single value in the local buffer, + tracing::warn!( + "only the latest value found in Local estimate buffer. We are likely starving." + ); + self.last_tsc_post = Some(now_event_tsc_post); + // unwrap ok. local is not empty + self.inner.push(local.as_ref().head().unwrap().clone()); + return self.inner.head(); + }; + + // push and store this value + self.last_tsc_post = Some(now_event_tsc_post); + self.inner.push(min_rtt_event.clone()); + self.inner.head() + } + } +} + +impl AsRef> for Estimate { + fn as_ref(&self) -> &RingBuffer { + &self.inner + } +} + +impl Default for Estimate { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::super::test_assets::TestEvent; + use super::*; + use crate::daemon::event::Event; + + #[test] + fn default_state() { + let estimate: Estimate = Estimate::new(); + assert!(estimate.is_empty()); + assert_eq!(estimate.len(), 0); + assert!(estimate.last_tsc_post().is_none()); + } + + #[test] + fn push_single_event() { + let mut estimate = Estimate::new(); + let event = TestEvent::pre_and_rtt(1000, 100); + estimate.push(event.clone(), TscCount::new(1000)); + + assert!(!estimate.is_empty()); + assert_eq!(estimate.len(), 1); + assert_eq!(estimate.last_tsc_post(), Some(TscCount::new(1000))); + } + + #[test] + fn feed_empty_local() { + let mut estimate: Estimate = Estimate::new(); + let local: Local = Local::new(NonZeroUsize::new(5).unwrap()); + let period = Period::from_seconds(1e-9); // 1GHz for example + + let result = estimate.feed(&local, period); + assert!(result.is_none()); + } + + #[test] + fn feed_first_event() { + let mut estimate: Estimate = Estimate::new(); + let mut local = Local::new(NonZeroUsize::new(5).unwrap()); + let event = TestEvent::pre_and_rtt(1000, 100); + + // Add event to local buffer + local.feed(event.clone()).unwrap(); + + let period = Period::from_seconds(1e-9); //1 GHz + let result = estimate.feed(&local, period); + + assert!(result.is_none()); + } + + #[test] + fn feed_before_window_expiry() { + let mut estimate: Estimate = Estimate::new(); + let mut local = Local::new(NonZeroUsize::new(5).unwrap()); + + // Add initial event + let event1 = TestEvent::pre_and_rtt(1000, 100); + local.feed(event1.clone()).unwrap(); + let period = Period::from_seconds(1e-9); + + // First feed should succeed + estimate.feed(&local, period); + + // Add another event before window expiry (less than 1000 seconds) + let event2 = TestEvent::pre_and_rtt(2000, 90); + local.feed(event2.clone()).unwrap(); + + let result = estimate.feed(&local, period); + assert!(result.is_none()); + } + + #[test] + fn feed_after_window_expiry() { + let mut estimate: Estimate = Estimate::new(); + let mut local = Local::new(NonZeroUsize::new(5).unwrap()); + let period = Period::from_seconds(1e-9); + + // Add initial event + let event1 = TestEvent::pre_and_rtt(1000, 100); + local.feed(event1.clone()).unwrap(); + let retval = estimate.feed(&local, period); + assert!(retval.is_none()); + + // Add event after window expiry (> 1000 seconds) + // Using 2_000_000_000_000 ticks (2000 seconds at 1GHz) to ensure window expiry + let event2 = TestEvent::pre_and_rtt(2_000_000_000_000, 90); + local.feed(event2.clone()).unwrap(); + + let result = estimate.feed(&local, period); + let result = result.unwrap(); + assert_eq!(*result, event1); + assert_eq!(estimate.len(), 1); + } + + #[test] + fn feed_happy_path() { + let mut estimate: Estimate = Estimate::new(); + let mut local = Local::new(NonZeroUsize::new(200).unwrap()); + let period = Period::from_seconds(1e-9); + + // Add initial event to estimate + let event1 = TestEvent::pre_and_rtt(1000, 100); + local.feed(event1.clone()).unwrap(); + estimate.feed(&local, period); + + let events: Vec<_> = (1..=100) + .map(|i| { + //local buffer gets events from 902 seconds to 1001. Last one triggers + TestEvent::pre_and_rtt((i + 901) * 1_000_000_000, 100) + }) + .collect(); + + // this is the min rtt event. This should get pushed to estimate buffer after the SKM window expires (last element in vec) + let expected = TestEvent::pre_and_rtt(500 * 1_000_000_000, 50); + let events = { + let mut tmp = vec![expected.clone()]; + tmp.extend(events); + tmp + }; + + // Feed everything but the last event. Estimate feed should return `None` since SKM never expired + for i in 0..100 { + // not the last value + local.feed(events[i].clone()).unwrap(); + local.expunge_old_events(period); + let result = estimate.feed(&local, period); + assert!( + result.is_none(), + "Failure at event {i}, {:?}", + events[i].clone() + ); + } + + // last event triggers the 1000 second SKM window expiring. + local.feed(events[100].clone()).unwrap(); + local.expunge_old_events(period); + let result = estimate.feed(&local, period); + let result = result.unwrap(); + assert_eq!(*result, expected); + } + + #[test] + fn feed_newest_excluded() { + let mut estimate: Estimate = Estimate::new(); + let mut local = Local::new(NonZeroUsize::new(200).unwrap()); + let period = Period::from_seconds(1e-9); + + // Add initial event to estimate + let event1 = TestEvent::pre_and_rtt(1000, 100); + local.feed(event1.clone()).unwrap(); + estimate.feed(&local, period); + + let events: Vec<_> = (1..=100) + .map(|i| { + //local buffer gets events from 902 seconds to 1001. Last one triggers + TestEvent::pre_and_rtt((i + 901) * 1_000_000_000, 100) + }) + .collect(); + + // this is the min rtt event. This should get pushed to estimate buffer after the SKM window expires (last element in vec) + let expected = TestEvent::pre_and_rtt(500 * 1_000_000_000, 50); + let mut events = { + let mut tmp = vec![expected.clone()]; + tmp.extend(events); + tmp + }; + + // Overwrite the last event to have the minimum RTT + events[100] = TestEvent::pre_and_rtt(1001 * 1_000_000_000, 25); + + // Feed everything but the last event. Estimate feed should return `None` since SKM never expired + for i in 0..100 { + // not the last value + local.feed(events[i].clone()).unwrap(); + local.expunge_old_events(period); + let result = estimate.feed(&local, period); + assert!( + result.is_none(), + "Failure at event {i}, {:?}", + events[i].clone() + ); + } + + // last event triggers the 1000 second SKM window expiring. + local.feed(events[100].clone()).unwrap(); + local.expunge_old_events(period); + let result = estimate.feed(&local, period); + let result = result.unwrap(); + + // added value should NOT be the overwritten 101th element + assert_eq!(*result, expected); + } + + #[test] + fn iter_ordering() { + let mut estimate: Estimate = Estimate::new(); + + let events = vec![ + TestEvent::pre_and_rtt(1000, 100), + TestEvent::pre_and_rtt(2000, 90), + TestEvent::pre_and_rtt(3000, 80), + ]; + + for event in &events { + estimate.push(event.clone(), event.tsc_post()); + } + + let collected: Vec<_> = estimate.iter().collect(); + assert_eq!(collected.len(), events.len()); + + // Verify ordering from oldest to newest + for (i, event) in collected.iter().enumerate() { + assert_eq!(event.tsc_post(), events[i].tsc_post()); + } + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs index 55872eb..214d90b 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs @@ -109,10 +109,12 @@ impl Local { /// /// # Starvation warning /// This will clear out anything older than the cutoff. This CAN leave the ring buffer EMPTY - pub fn expunge_old_events(&mut self, period: Period, now_post_tsc: TscCount) { - if self.is_empty() { - return; - } + pub fn expunge_old_events(&mut self, period: Period) { + let Some(head) = self.inner.head() else { + return; // we empty + }; + let now_post_tsc = head.tsc_post(); + // We need to calculate the corresponding TSC for an SKM window ago let cutoff_tsc = now_post_tsc - (Self::SKM_WINDOW / period); @@ -205,7 +207,7 @@ mod tests { buffer.feed(event).unwrap(); } - buffer.expunge_old_events(period, TscCount::new(1_500_000_000_000)); + buffer.expunge_old_events(period); // Should only retain events within last 1024 seconds for event in buffer.iter() { From 437798599205fb41f0f16064bff74960256a1a54 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Fri, 24 Oct 2025 12:15:43 -0400 Subject: [PATCH 041/177] Add support for retrieving offset between Clocks (#34) * Add support for retrieving offset between `Clock`s This commit adds a function `get_offset_and_rtt`, which retrieves the offset and RTT of 3 interleaved reads between "our clock" and "other clock", to compare two clocks. This lets us get an estimate of the difference between the two clocks at call time. It stores these in a struct ClockOffsetAndRtt, so that we can also get the quality of the estimate as well (e.g. a larger RTT of `our_clock` reads means a worse sample quality) Co-authored-by: Shamik Chakraborty --- clock-bound/src/daemon/time.rs | 1 + clock-bound/src/daemon/time/inner.rs | 93 +++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/clock-bound/src/daemon/time.rs b/clock-bound/src/daemon/time.rs index 0f42b7d..f598739 100644 --- a/clock-bound/src/daemon/time.rs +++ b/clock-bound/src/daemon/time.rs @@ -9,5 +9,6 @@ pub mod instant; pub mod timex; pub mod tsc; +pub use inner::{Clock, ClockExt}; pub use instant::{Duration, Instant}; pub use tsc::{TscCount, TscDiff}; diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 2a634ab..70e629d 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -18,11 +18,61 @@ pub trait Type {} /// Abstraction for time type whose tick unit is approximately one femtosecond pub trait FemtoType: Type {} +/// An offset and round-trip-time associated with a comparison of two clocks. +/// +/// Generally, the "offset" of two clocks can be estimated via use of interleaved reads, +/// e.g. reads: `our_clock_t1`, `other_clock_t2`, `our_clock_t3` +/// and the offset would be approximately the difference between the midpoint of the `our_clock_t1` and `our_clock_t3` +/// reads and the `other_clock_t2` reads. +/// The comparison is bounded by the round-trip-time of this measurement, and thus is useful for +/// determining the quality of the sample or bounding the clock error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ClockOffsetAndRtt { + /// Offset + offset: Diff, + /// RTT + rtt: Diff, +} + +impl ClockOffsetAndRtt { + fn new(offset: Diff, rtt: Diff) -> Self { + Self { offset, rtt } + } + + pub fn offset(&self) -> Diff { + self.offset + } + + pub fn rtt(&self) -> Diff { + self.rtt + } +} + +#[cfg_attr(test, mockall::automock)] pub trait Clock { /// Read the current clock time. fn get_time(&self) -> Time; } +/// Extension trait for Clock that provides offset and RTT measurement functionality +pub trait ClockExt: Clock { + /// Get the offset and RTT measurement for this reading between this clock and another clock. + /// The "offset" is from the caller to the `other` clock - e.g. if `self` is running behind `other`, + /// we would expect a negative value, and if it `self` running ahead of `other`, we expect a positive value. + fn get_offset_and_rtt(&self, other: &impl Clock) -> ClockOffsetAndRtt { + let our_read1 = self.get_time(); + let their_read = other.get_time(); + let our_read2 = self.get_time(); + let mid = our_read1.midpoint(our_read2); + let offset = mid - their_read; + let rtt = our_read2 - our_read1; + ClockOffsetAndRtt::new(offset, rtt) + } +} + +/// Blanket implementation of `ClockExt` for all types that implement Clock +impl> ClockExt for C {} + /// Abstract type for Time while keeping arithmetic consistent /// /// This type is not usually used directly, but rather through the [`Instant`](super::Instant) and [`Tsc`](super::TscCount) types. @@ -578,8 +628,10 @@ pub(crate) const NANOS_PER_SECOND: i128 = 1_000_000_000; #[cfg(test)] mod test { + use std::sync::{Arc, Mutex}; + use super::*; - use crate::daemon::time::{Duration, Instant}; + use crate::daemon::time::{Duration, Instant, instant::Utc}; #[derive(Clone, Copy)] struct TestType; @@ -876,4 +928,43 @@ mod test { assert_eq!(tv.tv_sec, tv_sec); assert_eq!(tv.tv_usec, tv_usec_nanos); } + + #[test] + fn clock_get_offset_and_rtt() { + // Create mock clocks + let mut mock_clock1 = MockClock::::new(); + let mut mock_clock2 = MockClock::::new(); + + // Use a counter to track calls and return different values + let call_count = Arc::new(Mutex::new(0)); + let call_count_clone = call_count.clone(); + + // Set up expectations for clock1: first read 100, second read 300 + mock_clock1.expect_get_time().times(2).returning(move || { + let mut count = call_count_clone.lock().unwrap(); + *count += 1; + if *count == 1 { + Time::new(100) + } else { + Time::new(300) + } + }); + + // Set up expectations for clock2: single read 195 + mock_clock2 + .expect_get_time() + .times(1) + .returning(|| Time::new(195)); + + // Test the get_offset_and_rtt method + let result = mock_clock1.get_offset_and_rtt(&mock_clock2); + + // Expected calculation: + // our_read1 = 100, their_read = 195, our_read2 = 300 + // mid = (100 + 300) / 2 = 200 + // offset = mid - their_read = 200 - 195 = 5 + // rtt = our_read2 - our_read1 = 300 - 100 = 200 + assert_eq!(result.offset().get(), 5); + assert_eq!(result.rtt().get(), 200); + } } From 6ca102e411eba78305ebf89648826430643075cb Mon Sep 17 00:00:00 2001 From: Myles N <95256483+nelomsmn@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:41:58 -0400 Subject: [PATCH 042/177] Adding NTPSource struct and relevant SourceIO logic (#41) This commit provides functionality to retrieve NTP Packets using a specific NTP Host's IP. Changes include: - Added NTPServer struct - Update to SourceIO struct implementation to add "create_ntp_source" function - Updated "create_link_local" method to run async.(Avoiding resource initialization in tokio::spawn block) - Updated io runners to send events in run() loop instead of sample function - Moved LinkLocal struct to link_local.rs in clock-bound/daemon/io module - Added Integration testing for NTPSource struct --- .github/workflows/ntp_source.yml | 49 ++++++++ Cargo.lock | 9 ++ Cargo.toml | 1 + clock-bound/src/bin/clockbound.rs | 2 +- clock-bound/src/daemon.rs | 12 +- clock-bound/src/daemon/io.rs | 94 +++++++++++--- clock-bound/src/daemon/io/link_local.rs | 153 +++++++++++++++++++++++ clock-bound/src/daemon/io/ntp.rs | 148 ++-------------------- clock-bound/src/daemon/io/ntp_source.rs | 156 ++++++++++++++++++++++++ test/link-local/src/main.rs | 8 +- test/ntp-source/Cargo.toml | 24 ++++ test/ntp-source/Makefile.toml | 8 ++ test/ntp-source/README.md | 113 +++++++++++++++++ test/ntp-source/src/main.rs | 73 +++++++++++ 14 files changed, 680 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/ntp_source.yml create mode 100644 clock-bound/src/daemon/io/link_local.rs create mode 100644 clock-bound/src/daemon/io/ntp_source.rs create mode 100644 test/ntp-source/Cargo.toml create mode 100644 test/ntp-source/Makefile.toml create mode 100644 test/ntp-source/README.md create mode 100644 test/ntp-source/src/main.rs diff --git a/.github/workflows/ntp_source.yml b/.github/workflows/ntp_source.yml new file mode 100644 index 0000000..9408622 --- /dev/null +++ b/.github/workflows/ntp_source.yml @@ -0,0 +1,49 @@ +name: NTP Source + +permissions: + contents: read + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run cargo build + run: cargo build --bin ntp-source-test --release + + - name: Upload ntp-source-test artifact + uses: actions/upload-artifact@v4 + with: + name: ntp-source-test + path: target/release/ntp-source-test + + NTP_Server_Tests: + name: NTP Server tests + needs: build + runs-on: + - codebuild-StagingClockBound-${{ github.run_id }}-${{ github.run_attempt }} + buildspec-override:true + + steps: + - name: Download coverage artifact + uses: actions/download-artifact@v5 + with: + name: ntp-source-test + + - run: ls + - run: echo "Change permissions of artifact." + - run: chmod 755 ntp-source-test + - run: echo "Run ntp server source test!" + - run: ./ntp-source-test \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 9cb3c25..902e26c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -790,6 +790,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ntp-source" +version = "2.0.3" +dependencies = [ + "clock-bound", + "tokio", + "tracing-subscriber", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" diff --git a/Cargo.toml b/Cargo.toml index a864b46..bc99f47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "examples/client/rust", "test/clock-bound-vmclock-client-test", "test/link-local", + "test/ntp-source", "test/vmclock-updater", "test/clock-bound-adjust-clock", ] diff --git a/clock-bound/src/bin/clockbound.rs b/clock-bound/src/bin/clockbound.rs index 4e06bdf..0f69f33 100644 --- a/clock-bound/src/bin/clockbound.rs +++ b/clock-bound/src/bin/clockbound.rs @@ -4,6 +4,6 @@ use clock_bound::daemon::Daemon; #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { tracing_subscriber::fmt::init(); - let mut d = Daemon::construct(); + let mut d = Daemon::construct().await; tokio::spawn(async move { d.run().await }).await.unwrap(); } diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index b08b99a..b24a00b 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -15,18 +15,18 @@ pub mod time; pub mod event; use crate::daemon::clock_sync_algorithm::ClockSyncAlgorithm; -use tokio::sync::mpsc; pub struct Daemon { _io_front_end: io::SourceIO, clock_sync_algorithm: ClockSyncAlgorithm, - link_local_receiver: mpsc::Receiver, + link_local_receiver: async_ring_buffer::Receiver, } impl Daemon { /// Construct and initialize a new daemon - pub fn construct() -> Self { - let (tx, rx) = mpsc::channel(2); + /// FIXME: Make this function not async. (Currently required for the io.run methods) + pub async fn construct() -> Self { + let (tx, rx) = async_ring_buffer::create(2); let mut io_front_end = io::SourceIO::construct(); @@ -45,7 +45,7 @@ impl Daemon { // let link_local = io::LinkLocal::construct(rx, ..Args); // let io_front_end = SourceIo::builder().link_local(tx, link_local).build(); // ``` - io_front_end.create_link_local(tx); + let () = io_front_end.create_link_local(tx).await; Self { _io_front_end: io_front_end, @@ -59,7 +59,7 @@ impl Daemon { loop { // TODO: add live migration watch and statements tokio::select! { - Some(event) = self.link_local_receiver.recv() => { + Ok(event) = self.link_local_receiver.recv() => { self.handle_event(event); } } diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index bb1b48d..38107b2 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -6,6 +6,7 @@ #![allow(dead_code)] use std::collections::HashMap; +use std::net::SocketAddr; use tokio::net::UdpSocket; use tokio::sync::{mpsc, watch}; @@ -13,8 +14,15 @@ use tokio::task::spawn; use tracing::{debug, info}; pub mod ntp; -use super::event; -use ntp::LinkLocal; +use crate::daemon::{async_ring_buffer, event}; + +// use super::event; +// use ntp::LinkLocal; +mod link_local; +use link_local::LinkLocal; + +mod ntp_source; +use ntp_source::NTPSource; mod tsc; @@ -44,29 +52,76 @@ impl SourceIO { /// # Panics /// - If not called within the `tokio` runtime. /// - If socket binding fails. - pub fn create_link_local(&mut self, event_sender: mpsc::Sender) { - info!("Creating link local source."); + pub async fn create_link_local(&mut self, event_sender: async_ring_buffer::Sender) { + if !self.sources.contains_key(&TimeSource::LinkLocal) { + let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); + let clock_disruption_receiver = self.clock_disruption_channels.sender.subscribe(); + + let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) + .await + .unwrap(); + let mut link_local = LinkLocal::construct( + socket, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ); + + spawn(async move { + if let Err(e) = link_local.run().await { + debug!("LinkLocal runner exited with an error: {:#?}", e); + } + }); + self.sources.insert(TimeSource::LinkLocal, ctrl_sender); + } + + info!("Source update complete."); + } - let entry = self.sources.entry(TimeSource::LinkLocal); - debug!(?entry, "Current source entry status"); - entry.or_insert_with(|| { + /// Spawns the IO task for sampling a specific NTP Server source. + /// + /// # Panics + /// - If not called within the `tokio` runtime. + /// - If socket binding fails. + pub async fn create_ntp_source( + &mut self, + server_address: SocketAddr, + event_sender: async_ring_buffer::Sender, + ) { + info!("Creating custom ntp server IO source."); + + if !self + .sources + .contains_key(&TimeSource::NTPSource(server_address)) + { let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); let clock_disruption_receiver = self.clock_disruption_channels.sender.subscribe(); + let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) + .await + .unwrap(); + + let mut ntp_source = NTPSource::construct( + socket, + server_address, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ); + spawn(async move { - let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) - .await - .unwrap(); - let mut link_local = LinkLocal::construct( - socket, - event_sender, - ctrl_receiver, - clock_disruption_receiver, - ); - link_local.run().await; + if let Err(e) = ntp_source.run().await { + debug!( + "NTPSource({}) runner exited with an error: {:#?}", + server_address.ip().to_string(), + e + ); + } }); - ctrl_sender - }); + + self.sources + .insert(TimeSource::NTPSource(server_address), ctrl_sender); + } info!("Source update complete."); } @@ -99,4 +154,5 @@ pub struct ControlRequest {} enum TimeSource { /// The internal AWS EC2 link local source `169.254.169.123`. LinkLocal, + NTPSource(SocketAddr), } diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs new file mode 100644 index 0000000..d041384 --- /dev/null +++ b/clock-bound/src/daemon/io/link_local.rs @@ -0,0 +1,153 @@ +//! Link Local IO Source + +use thiserror::Error; +use tokio::{ + io, + net::UdpSocket, + sync::{mpsc, watch}, + time::{self, Interval, interval, timeout}, +}; +use tracing::{debug, info}; + +use super::tsc::read_timestamp_counter; +use super::{ClockDisruptionEvent, ControlRequest}; +use crate::daemon::{ + async_ring_buffer, + event::{self, NtpData}, + time::tsc::TscCount, +}; + +use super::ntp::{INTERVAL_DURATION, LINK_LOCAL_ADDRESS, LINK_LOCAL_TIMEOUT, packet}; +use packet::Packet; + +#[derive(Debug, Error)] +pub enum LinkLocalError { + #[error("IO failure.")] + Io(#[from] io::Error), + #[error("Failed to parse NTP packet.")] + PacketParsing(String), + #[error("Send NtpEvent message failed.")] + SendEventMessage(#[from] mpsc::error::SendError), + #[error("Operation timed out.")] + Timeout(#[from] time::error::Elapsed), + #[error("TSC order failure. tsc_pre: {pre}. tsc_post: {post}")] + TscOrder { pre: u64, post: u64 }, +} + +/// Contains the data needed to run the link local runner. +#[derive(Debug)] +pub struct LinkLocal { + socket: UdpSocket, + event_sender: async_ring_buffer::Sender, + ctrl_receiver: mpsc::Receiver, + clock_disruption_receiver: watch::Receiver, + ntp_buffer: [u8; Packet::SIZE], + interval: Interval, +} + +impl LinkLocal { + /// Constructs a new `LinkLocal` with using given parameters. + pub fn construct( + socket: UdpSocket, + event_sender: async_ring_buffer::Sender, + ctrl_receiver: mpsc::Receiver, + clock_disruption_receiver: watch::Receiver, + ) -> Self { + LinkLocal { + socket, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ntp_buffer: [0u8; Packet::SIZE], + interval: interval(INTERVAL_DURATION), + } + } + + /// Samples the Link local source. + /// + /// When sampling from a NTP source we first collect the current time stamp counter + /// value. We then send a NTP request and await for a response. Once we receive a + /// response we again collect the current time stamp counter value. After we've + /// collected the NTP sample we construct the `Event` and push that event through + /// to the ring buffer. + async fn sample(&mut self) -> Result { + let packet = Packet::new_request(0); + packet.emit_bytes(&mut self.ntp_buffer); + + // TODO: tsc reads and ntp samples need to be fenced. + // We are currently investigating how to implement this appropriately. + let sent_timestamp = read_timestamp_counter(); + + // Request and Receive NTP sample. + let recv_packet_result = timeout(LINK_LOCAL_TIMEOUT, { + self.socket + .send_to(&self.ntp_buffer, LINK_LOCAL_ADDRESS) + .await?; + self.socket.recv_from(&mut self.ntp_buffer) + }) + .await?; + + let received_timestamp = read_timestamp_counter(); + + let (_, ntp_packet) = Packet::parse_from_bytes(&self.ntp_buffer) + .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; + + let ntp_data = NtpData::try_from(ntp_packet) + .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; + + let ntp_event = event::Ntp::builder() + .tsc_pre(TscCount::new(sent_timestamp.into())) + .tsc_post(TscCount::new(received_timestamp.into())) + .ntp_data(ntp_data) + .build() + .ok_or(LinkLocalError::TscOrder { + pre: sent_timestamp, + post: received_timestamp, + })?; + + debug!(?recv_packet_result, "Received packet."); + Ok(ntp_event) + } + + /// NTP Link Local task runner. + /// + /// Sampling NTP packets from the AWS EC2 internal Link Local address. + /// + /// # Panics + /// Function will panic if not called within the `tokio` runtime. + /// + /// # Errors + /// Returns error if loop exits unexpectedly + pub async fn run(&mut self) -> Result<(), LinkLocalError> { + // Sampling loop + info!("Starting link local sampling loop."); + loop { + tokio::select! { + _ = self.interval.tick() => { + match self.sample().await { + Err(e) => {debug!(?e, "Failed to sample link local source.");} + Ok(ntp_event) => { + if self.event_sender + .send(ntp_event.clone()).is_err() { + unreachable!("Buffer Closing is not expected in alpha.") + } + debug!(?ntp_event, "Successfully sent Link Local IO event."); + } + + } + } + _ = self.ctrl_receiver.recv() => { + // Ctrl logic here. + // Currently we breakout of the loop if we receive a control event. + break; + } + _ = self.clock_disruption_receiver.changed() => { + // Clock Disruption logic here + todo!("Clock disruption logic has yet to be implemented."); + } + } + } + info!("Link local runner exiting."); + Ok(()) + } +} diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index b6e393d..5bd9133 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -1,148 +1,16 @@ -//! Ntp IO Sources +//! Ntp IO Source constants use std::net::{Ipv4Addr, SocketAddrV4}; - -use thiserror::Error; -use tokio::{ - io, - net::UdpSocket, - sync::{mpsc, watch}, - time::{self, Duration, Interval, interval, timeout}, -}; -use tracing::{debug, info}; - -use super::tsc::read_timestamp_counter; -use super::{ClockDisruptionEvent, ControlRequest}; -use crate::daemon::{ - event::{self, NtpData}, - time::tsc::TscCount, -}; +use tokio::time::Duration; pub mod packet; pub use packet::Packet; pub const UNSPECIFIED_SOCKET_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); -const LINK_LOCAL_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(169, 254, 169, 123), 123); -const INTERVAL_DURATION: Duration = Duration::from_secs(1); -const LINK_LOCAL_TIMEOUT: Duration = Duration::from_millis(100); - -#[derive(Debug, Error)] -pub enum LinkLocalError { - #[error("IO failure.")] - Io(#[from] io::Error), - #[error("Failed to parse NTP packet.")] - PacketParsing(String), - #[error("Send NtpEvent message failed.")] - SendEventMessage(#[from] mpsc::error::SendError), - #[error("Operation timed out.")] - Timeout(#[from] time::error::Elapsed), - #[error("TSC order failure. tsc_pre: {pre}. tsc_post: {post}")] - TscOrder { pre: u64, post: u64 }, -} - -/// Contains the data needed to run the link local runner. -#[derive(Debug)] -pub struct LinkLocal { - socket: UdpSocket, - event_sender: mpsc::Sender, - ctrl_receiver: mpsc::Receiver, - clock_disruption_receiver: watch::Receiver, - ntp_buffer: [u8; Packet::SIZE], - interval: Interval, -} - -impl LinkLocal { - /// Constructs a new `LinkLocal` with using given parameters. - pub fn construct( - socket: UdpSocket, - event_sender: mpsc::Sender, - ctrl_receiver: mpsc::Receiver, - clock_disruption_receiver: watch::Receiver, - ) -> Self { - LinkLocal { - socket, - event_sender, - ctrl_receiver, - clock_disruption_receiver, - ntp_buffer: [0u8; Packet::SIZE], - interval: interval(INTERVAL_DURATION), - } - } - - /// Samples the Link local source. - /// - /// When sampling from a NTP source we first collect the current time stamp counter - /// value. We then send a NTP request and await for a response. Once we receive a - /// response we again collect the current time stamp counter value. After we've - /// collected the NTP sample we construct the `Event` and push that event through - /// to the ring buffer. - async fn sample(&mut self) -> Result<(), LinkLocalError> { - let packet = Packet::new_request(0); - packet.emit_bytes(&mut self.ntp_buffer); - - // TODO: tsc reads and ntp samples need to be fenced. - // We are currently investigating how to implement this appropriately. - let sent_timestamp = read_timestamp_counter(); - - // Request and Receive NTP sample. - let recv_packet_result = timeout(LINK_LOCAL_TIMEOUT, { - self.socket - .send_to(&self.ntp_buffer, LINK_LOCAL_ADDRESS) - .await?; - self.socket.recv_from(&mut self.ntp_buffer) - }) - .await?; - let received_timestamp = read_timestamp_counter(); - - let (_, ntp_packet) = Packet::parse_from_bytes(&self.ntp_buffer) - .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; - - let ntp_data = NtpData::try_from(ntp_packet) - .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; - - let ntp_event = event::Ntp::builder() - .tsc_pre(TscCount::new(sent_timestamp.into())) - .tsc_post(TscCount::new(received_timestamp.into())) - .ntp_data(ntp_data) - .build() - .ok_or(LinkLocalError::TscOrder { - pre: sent_timestamp, - post: received_timestamp, - })?; - - debug!(?recv_packet_result, "Received packet."); - self.event_sender.send(ntp_event.clone()).await?; - debug!(?ntp_event, "Successfully send link local event."); - Ok(()) - } +pub const LINK_LOCAL_ADDRESS: SocketAddrV4 = + SocketAddrV4::new(Ipv4Addr::new(169, 254, 169, 123), 123); +pub const INTERVAL_DURATION: Duration = Duration::from_secs(1); +pub const LINK_LOCAL_TIMEOUT: Duration = Duration::from_millis(100); - /// NTP Link Local task runner. - /// - /// Sampling NTP packets from the AWS EC2 internal Link Local address. - /// - /// # Panics - /// Function will panic if not called within the `tokio` runtime. - pub async fn run(&mut self) { - // Sampling loop - info!("Starting link local sampling loop."); - loop { - tokio::select! { - _ = self.interval.tick() => { - if let Err(e) = self.sample().await { - debug!(?e, "Failed to sample link local source."); - } - } - _ = self.ctrl_receiver.recv() => { - // Ctrl logic here. - // Currently we breakout of the loop if we receive a control event. - break; - } - _ = self.clock_disruption_receiver.changed() => { - // Clock Disruption logic here - todo!("Clock disruption logic has yet to be implemented."); - } - } - } - info!("Link local runner exiting."); - } -} +pub const NTP_SERVER_INTERVAL_DURATION: Duration = Duration::from_secs(16); +pub const NTP_SERVER_TIMEOUT: Duration = Duration::from_millis(100); diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs new file mode 100644 index 0000000..8471b4e --- /dev/null +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -0,0 +1,156 @@ +//! NTP Server IO Source + +use std::net::SocketAddr; + +use thiserror::Error; +use tokio::{ + io, + net::UdpSocket, + sync::{mpsc, watch}, + time::{self, Interval, interval, timeout}, +}; +use tracing::{debug, info}; + +use super::tsc::read_timestamp_counter; +use crate::daemon::{ + async_ring_buffer, + event::{self, NtpData}, + io::{ClockDisruptionEvent, ControlRequest}, + time::tsc::TscCount, +}; + +use super::ntp::{NTP_SERVER_INTERVAL_DURATION, NTP_SERVER_TIMEOUT, packet}; +use packet::Packet; + +#[derive(Debug, Error)] +pub enum NTPSourceError { + #[error("IO failure.")] + Io(#[from] io::Error), + #[error("Failed to parse NTP packet.")] + PacketParsing(String), + #[error("Send NtpEvent message failed.")] + SendEventMessage(#[from] mpsc::error::SendError), + #[error("Operation timed out.")] + Timeout(#[from] time::error::Elapsed), + #[error("TSC order failure. tsc_pre: {pre}. tsc_post: {post}")] + TscOrder { pre: u64, post: u64 }, +} + +/// Contains data used to run `NTPSource` runner. +/// Notably, the IP address passed to this struct should be associated +/// with an NTP host. +pub struct NTPSource { + socket: UdpSocket, + address: SocketAddr, + event_sender: async_ring_buffer::Sender, + ctrl_receiver: mpsc::Receiver, + clock_disruption_receiver: watch::Receiver, + ntp_buffer: [u8; Packet::SIZE], + interval: Interval, +} + +impl NTPSource { + /// Constructs a new `NTPSource` with using given parameters. + pub fn construct( + socket: UdpSocket, + address: SocketAddr, + event_sender: async_ring_buffer::Sender, + ctrl_receiver: mpsc::Receiver, + clock_disruption_receiver: watch::Receiver, + ) -> Self { + NTPSource { + socket, + address, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ntp_buffer: [0u8; Packet::SIZE], + interval: interval(NTP_SERVER_INTERVAL_DURATION), + } + } + + /// Samples the NTP Source source. + /// + /// When sampling from a NTP source we first collect the current time stamp counter + /// value. We then send a NTP request and await for a response. Once we receive a + /// response we again collect the current time stamp counter value. After we've + /// collected the NTP sample we construct the `Event` and push that event through + /// to the ring buffer. + async fn sample(&mut self) -> Result { + let packet = Packet::new_request(0); + packet.emit_bytes(&mut self.ntp_buffer); + + // TODO: tsc reads and ntp samples need to be fenced. + // We are currently investigating how to implement this appropriately. + let sent_timestamp = read_timestamp_counter(); + + // Request and Receive NTP sample. + let recv_packet_result = timeout(NTP_SERVER_TIMEOUT, { + self.socket.send_to(&self.ntp_buffer, self.address).await?; + self.socket.recv_from(&mut self.ntp_buffer) + }) + .await?; + + let received_timestamp = read_timestamp_counter(); + + let (_, ntp_packet) = Packet::parse_from_bytes(&self.ntp_buffer) + .map_err(|e| NTPSourceError::PacketParsing(e.to_string()))?; + + let ntp_data = NtpData::try_from(ntp_packet) + .map_err(|e| NTPSourceError::PacketParsing(e.to_string()))?; + + let ntp_event = event::Ntp::builder() + .tsc_pre(TscCount::new(sent_timestamp.into())) + .tsc_post(TscCount::new(received_timestamp.into())) + .ntp_data(ntp_data) + .build() + .ok_or(NTPSourceError::TscOrder { + pre: sent_timestamp, + post: received_timestamp, + })?; + + debug!(?recv_packet_result, "Received packet."); + Ok(ntp_event) + } + + /// `NTPSource` task runner. + /// + /// Sampling NTP packets from the IP Address defined at initialization. + /// + /// # Panics + /// Function will panic if not called within the `tokio` runtime. + /// + /// # Errors + /// Returns error if loop exits unexpectedly + pub async fn run(&mut self) -> Result<(), NTPSourceError> { + // Sampling loop + info!("Starting NTP Source IO sampling loop."); + loop { + tokio::select! { + _ = self.interval.tick() => { + match self.sample().await { + Err(e) => {debug!(?e, "Failed to sample NTP source source.");}, + Ok(ntp_event) => { + if self.event_sender + .send(ntp_event.clone()).is_err() { + unreachable!("Buffer Closing is not expected in alpha.") + } + debug!(?ntp_event, "Successfully sent NTP Source IO event."); + } + } + } + _ = self.ctrl_receiver.recv() => { + // Ctrl logic here. + // Currently we breakout of the loop if we receive a control event. + break; + } + _ = self.clock_disruption_receiver.changed() => { + // Clock Disruption logic here + todo!("Clock disruption logic has yet to be implemented."); + } + } + } + info!("NTP Source IO runner exiting."); + Ok(()) + } +} diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index cedc68f..f81a99f 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -3,10 +3,10 @@ //! This executable tests that the link local runner is able to send and receive packets from the //! link local address and that the polling rate is roughly once a second. -use clock_bound::daemon::event; +use clock_bound::daemon::async_ring_buffer; use clock_bound::daemon::io::SourceIO; use std::time; -use tokio::sync::mpsc; + use tokio::time::{Duration, timeout}; use tracing_subscriber::EnvFilter; @@ -17,12 +17,12 @@ async fn main() { .init(); println!("Lets get a NTP packet!"); - let (link_local_sender, mut link_local_receiver) = mpsc::channel::(1); + let (link_local_sender, link_local_receiver) = async_ring_buffer::create(1); let mut start = time::Instant::now(); let mut sourceio = SourceIO::construct(); - sourceio.create_link_local(link_local_sender); + sourceio.create_link_local(link_local_sender).await; let mut polling_rate = time::Duration::from_secs(0); for i in 0..11 { diff --git a/test/ntp-source/Cargo.toml b/test/ntp-source/Cargo.toml new file mode 100644 index 0000000..e4c4122 --- /dev/null +++ b/test/ntp-source/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ntp-source" +description = "A test program that attempts to sample NTP packets from a custom NTP Server." +license = "Apache-2.0" +publish = false + +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +keywords.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "ntp-source-test" +path = "src/main.rs" + +[dependencies] +clock-bound = { version = "2.0", path = "../../clock-bound", features = [ + "daemon", +] } +tokio = { version = "1.47.1", features = ["macros", "rt"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/ntp-source/Makefile.toml b/test/ntp-source/Makefile.toml new file mode 100644 index 0000000..0346956 --- /dev/null +++ b/test/ntp-source/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in test/ntp-source" +''' diff --git a/test/ntp-source/README.md b/test/ntp-source/README.md new file mode 100644 index 0000000..4571131 --- /dev/null +++ b/test/ntp-source/README.md @@ -0,0 +1,113 @@ +# Test program: link-local-test + +This directory contains the source code for a test program written to +validate the implementation of the NTPSource runner. The NTPSource +runner sends NTP packets to a specified NTP host's IP address. + +## Prerequisites + +This program must be run on an instance with internet access + +## Building with Cargo + +Run the following command to build the test program. + +```sh +cargo build --release +``` + +## Running the program after a Cargo build + +Run the following commands to run the test program. + +```sh +cd target/release/ +./ntp-server-test +``` + + +The output should look something like the following: + +```sh +$ ./ntp-server-test +Lets get a NTP packet! +NTP Server creation complete! +Polling 0 +It looks like we got an ntp packet from 1st Source +Ok( + Ntp { + tsc_pre: Time { + instant: 3299176641588978, + _marker: PhantomData, + }, + tsc_post: Time { + instant: 3299176649716686, + _marker: PhantomData, + }, + data: NtpData { + server_recv_time: Time { + instant: 1761157887289808405000000, + _marker: PhantomData, + }, + server_send_time: Time { + instant: 1761157887289811367000000, + _marker: PhantomData, + }, + root_delay: Diff { + duration: 366210937500, + _marker: PhantomData, + }, + root_dispersion: Diff { + duration: 289916992187, + _marker: PhantomData, + }, + stratum: Level( + ValidStratumLevel( + 4, + ), + ), + }, + }, +) +5 ms +It looks like we got an ntp packet from 2nd Source +Ok( + Ntp { + tsc_pre: Time { + instant: 3299176641709894, + _marker: PhantomData, + }, + tsc_post: Time { + instant: 3299176652154514, + _marker: PhantomData, + }, + data: NtpData { + server_recv_time: Time { + instant: 1761157887290555565000000, + _marker: PhantomData, + }, + server_send_time: Time { + instant: 1761157887290567787000000, + _marker: PhantomData, + }, + root_delay: Diff { + duration: 335693359375, + _marker: PhantomData, + }, + root_dispersion: Diff { + duration: 335693359375, + _marker: PhantomData, + }, + stratum: Level( + ValidStratumLevel( + 4, + ), + ), + }, + }, +) +5 ms +Polling 1 +... +Polling 6 +``` diff --git a/test/ntp-source/src/main.rs b/test/ntp-source/src/main.rs new file mode 100644 index 0000000..aaee958 --- /dev/null +++ b/test/ntp-source/src/main.rs @@ -0,0 +1,73 @@ +//! NTP Server executable. +//! +//! This executable tests that the NTP Server runner is able to send and receive packets from the +//! specified NTP Server address and that the polling rate is roughly once a second. + +use clock_bound::daemon::async_ring_buffer; +use clock_bound::daemon::io::SourceIO; + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time; + +use tracing_subscriber::EnvFilter; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + println!("Lets get a NTP packet!"); + let (first_ntp_source_sender, first_ntp_source_receiver) = async_ring_buffer::create(1); + let (second_ntp_source_sender, second_ntp_source_receiver) = async_ring_buffer::create(1); + + let mut start = time::Instant::now(); + + let mut sourceio = SourceIO::construct(); + // Currently hardcoded with a public NTP server IP + // TODO: Update this IP to new AGA IP after resource provisioning is complete + let first_ntp_source_public_ip = + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(54, 210, 225, 137)), 123); + let second_ntp_source_public_ip = + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(3, 33, 208, 232)), 123); + + sourceio + .create_ntp_source(first_ntp_source_public_ip, first_ntp_source_sender) + .await; + sourceio + .create_ntp_source(second_ntp_source_public_ip, second_ntp_source_sender) + .await; + println!("NTP Server creation complete!"); + + let mut polling_rate = time::Duration::from_secs(0); + for i in 0..6 { + println!("Polling {i}"); + + // Get NTP packet from both specified servers + let ntpevent_a = first_ntp_source_receiver.recv().await; + let ntpevent_b = second_ntp_source_receiver.recv().await; + + let now = time::Instant::now(); + let d = now - start; + println!( + "It looks like we got an ntp packet from 1st Source \n{ntpevent_a:#?}\n{:?} ms", + d.as_millis() + ); + println!( + "It looks like we got an ntp packet from 2nd Source \n{ntpevent_b:#?}\n{:?} ms", + d.as_millis() + ); + + // Skip the first sample, the IO runner will poll immediately after it's created. + if i > 0 { + polling_rate += d; + } + + start = now; + } + polling_rate /= 5; + println!("Polling rate avg: {polling_rate:?}"); + assert!( + polling_rate.abs_diff(time::Duration::from_secs(16)) < time::Duration::from_millis(200) + ); +} From 3b99de7cdec668fce0efed0c896ff2b22e5034fb Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 24 Oct 2025 20:09:14 -0400 Subject: [PATCH 043/177] [ff] Calculate ntp clock error bound and compare value against ClockParameters (#51) * [ff] Calculate ntp clock error bound and compare value against ClockParameters * Revision: rename fn to more_accurate_than --- clock-bound/src/daemon/clock_parameters.rs | 238 ++++++++++++++++++++- clock-bound/src/daemon/event.rs | 5 + clock-bound/src/daemon/event/ntp.rs | 131 +++++++++++- 3 files changed, 372 insertions(+), 2 deletions(-) diff --git a/clock-bound/src/daemon/clock_parameters.rs b/clock-bound/src/daemon/clock_parameters.rs index c6ae545..095099c 100644 --- a/clock-bound/src/daemon/clock_parameters.rs +++ b/clock-bound/src/daemon/clock_parameters.rs @@ -2,7 +2,10 @@ //! //! The output of the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) -use crate::daemon::time::{Duration, Instant, TscCount, tsc::Period}; +use crate::daemon::time::{ + Duration, Instant, TscCount, + tsc::{Period, Skew}, +}; /// Clock parameters /// @@ -22,6 +25,33 @@ pub struct ClockParameters { pub period_max_error: Period, } +impl ClockParameters { + /// Compare another `ClockParameter` + /// + /// Returns true if `self` is more accurate than `rhs` clock parameters + /// + /// # Parameters + /// - `rhs`: the other `ClockParameters` to compare against + /// - `max_dispersion`: The maximum potential CPU drift + pub fn more_accurate_than(&self, rhs: &ClockParameters, max_dispersion: Skew) -> bool { + let mut self_ceb = self.clock_error_bound; + let mut rhs_ceb = rhs.clock_error_bound; + + // Apply max dispersion aging to older sample + let tsc_age = (rhs.tsc_count - self.tsc_count).abs(); + let accumulated_dispersion = + (tsc_age * self.period).as_seconds_f64() * max_dispersion.get(); + let accumulated_dispersion = Duration::from_seconds_f64(accumulated_dispersion); + if self.tsc_count < rhs.tsc_count { + self_ceb += accumulated_dispersion; + } else { + rhs_ceb += accumulated_dispersion; + } + + rhs_ceb > self_ceb + } +} + /// Information on the selected clock /// /// This struct is stored in the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) as the @@ -39,3 +69,209 @@ pub struct SelectedClockInfo { /// None if reading from a non-NTP device pub stratum: Option, // TODO: use the enum in another PR } + +#[cfg(test)] +mod test { + use super::*; + use crate::daemon::event::{self, Stratum, TscRtt}; + use rstest::rstest; + + #[rstest] + #[case::same_events_zero_skew( + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event (identical) + event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(10), + root_dispersion: Duration::from_micros(5), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Skew::from_ppm(0.0), + false // First event should be chosen when equal + )] + #[case::different_rtt_zero_skew( + // First event with better RTT + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event with worse RTT + event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_002_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(5), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Skew::from_ppm(15.0), + false, + )] + #[case::time_difference_with_skew( + // First event (older) + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event (newer, 1 second later) + event::Ntp::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + Duration::from_secs(1), + server_send_time: Instant::from_days(1) + Duration::from_secs(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(10), + root_dispersion: Duration::from_micros(5), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Skew::from_ppm(25.0), + true + )] + #[case::different_period( + // First event + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event + event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_003_300)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(10), + root_dispersion: Duration::from_micros(5), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(3.3e-9), + Skew::from_ppm(10.0), + false, + )] + #[case::first_better_despite_age( + // First event + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event + event::Ntp::builder() + .tsc_pre(TscCount::new(5_000_000_000)) + .tsc_post(TscCount::new(5_000_003_300)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(10), + root_dispersion: Duration::from_micros(50), // CEB of second degraded + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(0.303e-9), + Skew::from_ppm(10.0), + false + )] + fn compare_clock_error_bound( + #[case] first: ClockParameters, + #[case] second: event::Ntp, + #[case] period: Period, + #[case] max_dispersion: Skew, + #[case] expected: bool, + ) { + let val = ClockParameters { + tsc_count: second.tsc_midpoint(), + time: second + .data() + .server_recv_time + .midpoint(second.data().server_send_time), + clock_error_bound: second.calculate_clock_error_bound(period), + period, + period_max_error: Period::from_seconds(1e-11), // unused + }; + let result = val.more_accurate_than(&first, max_dispersion); + assert_eq!(result, expected); + } + + #[rstest] + #[case::high_skew_old_vs_new( + // First event (old) + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event (new, 10 seconds later) + event::Ntp::builder() + .tsc_pre(TscCount::new(11_000_000_000)) + .tsc_post(TscCount::new(11_000_001_500)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + Duration::from_secs(10), + server_send_time: Instant::from_days(1) + Duration::from_secs(10) + Duration::from_micros(1), + root_delay: Duration::from_micros(12), + root_dispersion: Duration::from_micros(6), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Skew::from_ppm(25.0), + true // Despite slightly worse metrics, newer sample should win due to age + )] + fn compare_clock_error_bound_with_aging( + #[case] first: ClockParameters, + #[case] second: event::Ntp, + #[case] period: Period, + #[case] max_dispersion: Skew, + #[case] expected: bool, + ) { + let val = ClockParameters { + tsc_count: second.tsc_midpoint(), + time: second + .data() + .server_recv_time + .midpoint(second.data().server_send_time), + clock_error_bound: second.calculate_clock_error_bound(period), + period, + period_max_error: Period::from_seconds(1e-11), // unused + }; + let result = val.more_accurate_than(&first, max_dispersion); + assert_eq!(result, expected); + } +} diff --git a/clock-bound/src/daemon/event.rs b/clock-bound/src/daemon/event.rs index 6720ca5..b24d393 100644 --- a/clock-bound/src/daemon/event.rs +++ b/clock-bound/src/daemon/event.rs @@ -24,4 +24,9 @@ pub trait TscRtt { fn rtt(&self) -> TscDiff { self.tsc_post() - self.tsc_pre() } + + /// The TSC midpoint + fn tsc_midpoint(&self) -> TscCount { + self.tsc_pre().midpoint(self.tsc_post()) + } } diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index 84769a5..3b43caa 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -5,7 +5,7 @@ use std::{ }; use super::TscRtt; -use crate::daemon::time::{Duration, Instant, TscCount}; +use crate::daemon::time::{Duration, Instant, TscCount, tsc::Period}; /// Contains the NTP and time stamp counter samples to be used by synchronization algorithm. /// @@ -54,6 +54,24 @@ impl Ntp { pub fn data(&self) -> &NtpData { &self.data } + + /// Calculate the clock error bound of this event at the time of the event + /// + /// This is different from the clock error bound that would be reported to a user outside of the daemon. + /// + /// First, because this is a sans-IO input, there is no concept of reading this "after" the event comes in. + /// Because of this, there is no additional value added to the root-dispersion. + /// + /// Second, the round trip time needs a calculation of the period to be able to convert the TSC rtt into + /// a duration of time. + /// + /// Third, there is no "ntp offset" value. That is a parameter exclusive to modifying the system clock, which this component does not do. + /// Instead it just calculates the time at a TSC event, and then passes that on to the [`ClockState`](crate::daemon::clock_state) component. + pub fn calculate_clock_error_bound(&self, period_local: Period) -> Duration { + let rtt = self.rtt() * period_local; + let root_delay = self.data().root_delay + rtt; + self.data().root_dispersion + (root_delay / 2) + } } impl TscRtt for Ntp { @@ -177,6 +195,7 @@ impl ValidStratumLevel { #[cfg(test)] mod tests { use super::*; + use rstest::rstest; #[test] fn valid_ntp_event() { @@ -274,4 +293,114 @@ mod tests { assert!(matches!(Stratum::try_from(17), Err(TryFromU8Error))); assert!(matches!(Stratum::try_from(255), Err(TryFromU8Error))); } + + #[rstest] + #[case::minimal_delays( + Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_002_000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(10), + root_dispersion: Duration::from_micros(5), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Duration::from_micros(11) // Expected: root_dispersion(5) + (root_delay(10) + rtt(2))/2 + )] + #[case::larger_rtt( + Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_010_000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(20), + root_dispersion: Duration::from_micros(10), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Duration::from_micros(25) // Expected: root_dispersion(10) + (root_delay(20) + rtt(10))/2 + )] + #[case::period_scaling( + Ntp::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_002_000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(8), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(2e-9), // Different period scaling + Duration::from_nanos(17_500) // Expected: root_dispersion(8) + (root_delay(15) + rtt(4))/2 + )] + fn calculate_clock_error_bound( + #[case] event: Ntp, + #[case] period: Period, + #[case] expected: Duration, + ) { + let result = event.calculate_clock_error_bound(period); + approx::assert_abs_diff_eq!( + result.as_seconds_f64(), + expected.as_seconds_f64(), + epsilon = 1e-9 + ); + } + + #[rstest] + #[case( + // Zero root delay and dispersion + Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_002_000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(0), + root_dispersion: Duration::from_micros(0), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Duration::from_micros(1) // Expected: only RTT contribution + )] + #[case( + // Large root delay and dispersion + Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_millis(1), + root_dispersion: Duration::from_millis(1), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Duration::from_nanos(1_500_500) // Expected: root_dispersion(1ms) + (root_delay(1ms) + rtt(1µs))/2 + )] + fn calculate_clock_error_bound_edge_cases( + #[case] event: Ntp, + #[case] period: Period, + #[case] expected: Duration, + ) { + let result = event.calculate_clock_error_bound(period); + approx::assert_abs_diff_eq!( + result.as_seconds_f64(), + expected.as_seconds_f64(), + epsilon = 1e-9 + ); + } } From d1e7c18b6dc5960fddda7cf93eb7fbebd59c506b Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Mon, 27 Oct 2025 10:59:44 -0400 Subject: [PATCH 044/177] Interval skip behavior set to `MissedTickBehavior::Delay` (#56) * Interval skip behavior set to `MissedTickBehavior::Delay` This commit changes the behavior of `Interval` `tick` so that if a tick is missed we do not spam the tick to catch up. * Added missed tick behavior to both link_local and ntp_source sources. --- clock-bound/src/daemon/io/link_local.rs | 6 ++++-- clock-bound/src/daemon/io/ntp_source.rs | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index d041384..9b9df3f 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -5,7 +5,7 @@ use tokio::{ io, net::UdpSocket, sync::{mpsc, watch}, - time::{self, Interval, interval, timeout}, + time::{self, Interval, MissedTickBehavior, interval, timeout}, }; use tracing::{debug, info}; @@ -53,13 +53,15 @@ impl LinkLocal { ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, ) -> Self { + let mut link_local_interval = interval(INTERVAL_DURATION); + link_local_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); LinkLocal { socket, event_sender, ctrl_receiver, clock_disruption_receiver, ntp_buffer: [0u8; Packet::SIZE], - interval: interval(INTERVAL_DURATION), + interval: link_local_interval, } } diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 8471b4e..1686db1 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -7,7 +7,7 @@ use tokio::{ io, net::UdpSocket, sync::{mpsc, watch}, - time::{self, Interval, interval, timeout}, + time::{self, Interval, MissedTickBehavior, interval, timeout}, }; use tracing::{debug, info}; @@ -58,6 +58,8 @@ impl NTPSource { ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, ) -> Self { + let mut ntp_server_interval = interval(NTP_SERVER_INTERVAL_DURATION); + ntp_server_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); NTPSource { socket, address, @@ -65,7 +67,7 @@ impl NTPSource { ctrl_receiver, clock_disruption_receiver, ntp_buffer: [0u8; Packet::SIZE], - interval: interval(NTP_SERVER_INTERVAL_DURATION), + interval: ntp_server_interval, } } From 9b4efe6e1d53bc6b20bab3492b4521c06a6a3ba5 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Mon, 27 Oct 2025 12:10:44 -0400 Subject: [PATCH 045/177] Introduce `adjust_clock_test` skeleton and explanations (#58) This commit introduces a test program `adjust-clock-test` which does some validation that the parameters we pass into `adjtimex` are sane and treated by the kernel in a way that aligns with our expectations. It adds additional plots and doc comments to explain the test intention and execution in detail. --- Cargo.lock | 10 + Cargo.toml | 1 + test/clock-bound-adjust-clock-test/Cargo.toml | 26 +++ .../Makefile.toml | 8 + test/clock-bound-adjust-clock-test/README.md | 69 +++++++ .../sample_plot1.png | Bin 0 -> 145756 bytes .../sample_plot2.png | Bin 0 -> 148699 bytes .../src/adjust_clock_test.rs | 180 ++++++++++++++++++ 8 files changed, 294 insertions(+) create mode 100644 test/clock-bound-adjust-clock-test/Cargo.toml create mode 100644 test/clock-bound-adjust-clock-test/Makefile.toml create mode 100644 test/clock-bound-adjust-clock-test/README.md create mode 100644 test/clock-bound-adjust-clock-test/sample_plot1.png create mode 100644 test/clock-bound-adjust-clock-test/sample_plot2.png create mode 100644 test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs diff --git a/Cargo.lock b/Cargo.lock index 902e26c..c97a280 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,16 @@ dependencies = [ "clock-bound", ] +[[package]] +name = "clock-bound-adjust-clock-test" +version = "2.0.3" +dependencies = [ + "clock-bound", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "clock-bound-ff-tester" version = "2.0.3" diff --git a/Cargo.toml b/Cargo.toml index bc99f47..bcb2797 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "test/ntp-source", "test/vmclock-updater", "test/clock-bound-adjust-clock", + "test/clock-bound-adjust-clock-test", ] resolver = "3" diff --git a/test/clock-bound-adjust-clock-test/Cargo.toml b/test/clock-bound-adjust-clock-test/Cargo.toml new file mode 100644 index 0000000..c8242c3 --- /dev/null +++ b/test/clock-bound-adjust-clock-test/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "clock-bound-adjust-clock-test" +description = "An integration test of the ClockBound daemon's clock adjustment function." +license = "MIT OR Apache-2.0" +publish = false + +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +keywords.workspace = true +readme.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "adjust-clock-test" +path = "src/adjust_clock_test.rs" + +[dependencies] +clock-bound = { version = "2.0", path = "../../clock-bound", features = [ + "daemon", +] } +tokio = { version = "1.47.1", features = ["macros", "rt"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/clock-bound-adjust-clock-test/Makefile.toml b/test/clock-bound-adjust-clock-test/Makefile.toml new file mode 100644 index 0000000..6e20968 --- /dev/null +++ b/test/clock-bound-adjust-clock-test/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in test/clock-bound-adjust-clock-test" +''' diff --git a/test/clock-bound-adjust-clock-test/README.md b/test/clock-bound-adjust-clock-test/README.md new file mode 100644 index 0000000..224aeb4 --- /dev/null +++ b/test/clock-bound-adjust-clock-test/README.md @@ -0,0 +1,69 @@ +# Test program: `adjust-clock-test` + +This directory contains the source code for a test program `adjust-clock-test`. + +### `adjust-clock-test` +`adjust-clock-test` is a test program validating that the phase correction and frequency correction utilities implemented in `ClockAdjust` +work as expected. **To ensure valid results, ensure that no time sync daemon is currently disciplining the clock.** + +It asserts the following: + - We can modify the frequency of the clock via ClockAdjust. We expect the rate of CLOCK_REALTIME to approximately match our inputs, with some room for error due to jitter and wander. + - We can modify the phase offset of the clock via ClockAdjust. We expect the rate of CLOCK_REALTIME to speed up or slow down to our inputs, with some room for error due to jitter and wander. + +To do so, the implementation does these setup steps: +1. Reset the kernel NTP parameters via `adjtimex`. `CLOCK_REALTIME` should then tick at the same rate as `CLOCK_MONOTONIC_RAW`, but with a large phase offset. +2. Calculate the offset between `CLOCK_REALTIME` and `CLOCK_MONOTONIC_RAW`. This will be used as a baseline for determining how our `ClockAdjust` component is steering + the clock. + +Then, each test does: +1. Perform an adjustment of the clock via `ClockAdjust`, and continually calculate the offset of `CLOCK_REALTIME` and `CLOCK_MONOTONIC_RAW`, ensuring that this offset + is steered in the direction intended by the clock adjustment. We should be able to estimate the offset of these two clocks at some given time based on our parameters. + +This is diagrammed below - we are trying to estimate the offset of `Clock if not adjusted` and the `Combined Correction`, after we call into `ClockAdjust`. + +| Description | Image | +|---|---| +|A plot where skew and phase correction parameters are negative, causing our offset to be negative.|![Clock Adjustments Plot 1](sample_plot1.png)| +|A plot where the skew parameter is positive and phase correction is negative. Harder to understand with opposite signs.|![Clock Adjustments Plot 2](sample_plot2.png)| + + +## Prerequisites + +The program must be run as a user with sufficient permissions to adjust the clock (generally `root`). + +Currently only Linux is supported. + +## Building with Cargo + +Run the following command to build the example programs. + +``` +cargo build --release +``` + +## Running `adjust-clock-test` + +The build artifact should show up at +``` +./target/release/adjust-clock-test +``` + +You can run the command like below, and the output should look similar: +``` +$ ./target/release/adjust-clock-test +2025-10-24T17:39:50.676899Z INFO adjust_clock_test: Resetting clock parameters.. +2025-10-24T17:39:51.677761Z INFO adjust_clock_test: Running test with phase correction +0.000000000s and skew +0.000000000 and allowed diff of expected vs measured 0.000010000s +2025-10-24T17:39:51.677820Z INFO adjust_clock_test: Test start time is 2025-10-24T17:39:51.677810217Z and initial `CLOCK_REALTIME` <---> `CLOCK_MONOTONIC_RAW` offset is 1760213155.9888163s +2025-10-24T17:40:01.428996Z INFO adjust_clock_test: Test passed! +2025-10-24T17:40:01.429123Z INFO adjust_clock_test: | Event Timestamp | Expected Change In Offset | Measured Change In Offset | Measurement RTT | Expected - Measured | +| ----------------| --------------------------| --------------------------| ----------------| --------------------| +| 2025-10-24T17:39:51.678936928Z | +0.000000000 | +0.000000232 | +0.000000271 | -0.000000232 | +| 2025-10-24T17:39:51.929307255Z | +0.000000000 | +0.000000231 | +0.000000289 | -0.000000231 | +| 2025-10-24T17:39:52.178717116Z | +0.000000000 | +0.000000236 | +0.000000287 | -0.000000236 | +| 2025-10-24T17:39:52.429095764Z | +0.000000000 | +0.000000243 | +0.000000315 | -0.000000243 | +| 2025-10-24T17:39:52.679471608Z | +0.000000000 | +0.000000267 | +0.000000371 | -0.000000267 | +| 2025-10-24T17:39:52.928861091Z | +0.000000000 | +0.000000269 | +0.000000365 | -0.000000269 | +| 2025-10-24T17:39:53.179252162Z | +0.000000000 | +0.000000270 | +0.000000377 | -0.000000270 | + +[..more output..] +``` diff --git a/test/clock-bound-adjust-clock-test/sample_plot1.png b/test/clock-bound-adjust-clock-test/sample_plot1.png new file mode 100644 index 0000000000000000000000000000000000000000..d2a1b13c41c89caf3e90f8429209f2ef32af250c GIT binary patch literal 145756 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 U>-VCR`0&RiYjmYc44$m}0hD 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 B>WBaU literal 0 HcmV?d00001 diff --git a/test/clock-bound-adjust-clock-test/sample_plot2.png b/test/clock-bound-adjust-clock-test/sample_plot2.png new file mode 100644 index 0000000000000000000000000000000000000000..cd0fc34220957541e1b283c6fe08bfb1d608ac77 GIT binary patch literal 148699 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�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/HVTp*NpGtIy&{|2ou7G?Q}YyE2bw(wOUz{s%X9~ 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

sh;E(CG1V#C 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 z zLyZ51pJiGAhCT<=qz+IBpzQ4bk(U8&bPTL=r}4Nc+@XzbfcfsYp`Zps!ex`r-wmMm zL6rL4`h#!;`;MHun0f^s_qulPB&n&)RL0ZA?z24x5vPn$lemhUQ?ybm+V|vKnZr|k z;+s?ktkAybrix_Y?{=>e=H0k7g--iJV+3CAJ5_mMmn3+Sl;PqoL2O1dH~d~iQAht* zptM9dxx?Y-=eGouUdeC3=$9it0Q#N%H7{C!(fcyR-yDoA3QG&G1Z3WeqfO=6zTx3$ zX)}*m%>lt^mJh!Z!fL|FsP_M$V(hp?YXp!yb z*C{a!T(fUQn$?{99WT5a?)f!Tv@MD1t=*zhSvjhzEiKQ}b$!jB0!cJ>#bvHvb9<=N zXK`+7{Sm%Kss&%VNk+*ZB9iDcSGDkI%0K?wcGG7GM-p{=b%Z3*(a|Z0!}y*6-k(q) zV#A3$7@l$n=H9%OasGCovprt&R

=i`0$im51qU+HvNI{;+VW!(W6H1h@_Nzot~E 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 WR&Mghc literal 0 HcmV?d00001 diff --git a/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs b/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs new file mode 100644 index 0000000..ddb359d --- /dev/null +++ b/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs @@ -0,0 +1,180 @@ +//! Our test intends to validate that the clock steering of our `ClockAdjust` component is within our expectations, solely based +//! on the parameters passed to the kernel via `ClockAdjust`. We can improve on this in later iterations, by using actual `ClockParameters` +//! along with the TSC values/frequency we use for our baseline. +//! +//! After the clock parameters of `CLOCK_REALTIME` are reset, we mutate them again via `ClockAdjust` to steer the clock. +//! +//! The test then continually calculates the offset of `CLOCK_REALTIME` and `CLOCK_MONOTONIC_RAW`, ensuring that this offset +//! is steered in the direction intended by the clock adjustment. +//! We should be able to estimate the offset of these two clocks at some given time based on our parameters, by adding the components +//! of the clock offset from phase correction and skew parameters supplied. +//! +//! ========================================================================================================= +//! PHASE CORRECTION +//! ========================================================================================================= +//! Phase correction follows an exponentially decaying curve. +//! +//! This graph is the offset of `CLOCK_REALTIME` from `CLOCK_MONOTONIC_RAW`, with `CLOCK_REALTIME` starting 500ms ahead +//! and being given a -500ms phase correction. +//! +//! Offset (s), with `CLOCK_REALTIME` starting 500ms ahead and being handed a -500ms phase correction. +//! ^ +//! 0.50 | * +//! | +//! 0.45 | +//! | +//! 0.40 | +//! | * +//! 0.35 | +//! | +//! 0.30 | +//! | * +//! 0.25 | +//! | +//! 0.20 | * +//! | +//! 0.15 | * +//! | +//! 0.10 | * +//! | +//! 0.05 | * +//! | +//! 0.00 | +//! +------------+-------------|------------|------------|------------|------------|------------> Time (s) +//! 0 1 2 3 4 5 6 +//! +//! +//! ========================================================================================================= +//! SKEW/FREQUENCY CORRECTION +//! ========================================================================================================= +//! A skew/frequency correction applied to the clock is a linear component, and simply changes the slope of the clock correction. +//! +//! This graph is the offset of `CLOCK_REALTIME` from `CLOCK_MONOTONIC_RAW`, with `CLOCK_REALTIME` starting aligned, and being given a +//! +0.05s skew correction (this is invalid in kernel but showing for demo's sake) +//! +//! Offset (s) +//! ^ +//! 0.50 | +//! | +//! 0.45 | +//! | +//! 0.40 | +//! | +//! 0.35 | +//! | +//! 0.30 | * +//! | +//! 0.25 | * +//! | +//! 0.20 | * +//! | +//! 0.15 | * +//! | +//! 0.10 | * +//! | +//! 0.05 | * +//! | +//! 0.00 |* +//! +------------+-------------|------------|------------|------------|------------|------------> Time (s) +//! 0 1 2 3 4 5 6 +//! This is a test which validates that our `ClockAdjust` functions work as expected, applying the +//! phase correction and skew correction parameters as we expect to via calls to that API, and seeing +//! the `CLOCK_REALTIME` adjustment is steered precisely to where we would expect it to be. +#![allow(clippy::doc_comment_double_space_linebreaks, reason = "hooray ascii")] +use clock_bound::daemon::time::{Duration, Instant, tsc::Skew}; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + tracing_subscriber::fmt().init(); + todo!("implement me"); +} + +#[derive(Debug)] +#[allow(dead_code)] +struct ClockAdjustTestParameters { + /// Frequency correction (skew parameter) passed into `ClockAdjust` + skew: Skew, + /// Phase correction passed into `ClockAdjust` + phase_correction: Duration, + /// Start time of the test/clock adjustment + /// + /// We need this to extrapolate the change in clock offsets + /// at some future `Instant`. + start_time: Instant, + /// Initial offset of the `CLOCK_REALTIME` and `CLOCK_MONOTONIC` at + /// test start. + /// + /// We need this to extrapolate the change in clock offsets + /// at some future `Instant`. + start_offset: Duration, +} + +#[allow(dead_code)] +impl ClockAdjustTestParameters { + /// Return `ClockAdjustTestParameters`, which can be + /// used to calculate a future change in the `CLOCK_REALTIME` change in phase offset + /// with respect to `CLOCK_MONOTONIC_RAW`. + fn new( + skew: Skew, + phase_correction: Duration, + start_offset: Duration, + start_time: Instant, + ) -> Self { + Self { + skew, + phase_correction, + start_time, + start_offset, + } + } + + /// Get the start time of the test, which is needed in order to + /// estimate how much the clock has been adjusted at some future `Instant`. + fn get_start_time(&self) -> Instant { + self.start_time + } + + /// Get the starting offset of the `CLOCK_REALTIME` and `CLOCK_MONOTONIC_RAW` + /// for the test, which is needed in order to estimate how much the clock has + /// been adjusted at some future `Instant`. + /// + /// The starting offset should be taken at a point where `CLOCK_REALTIME` and `CLOCK_MONOTONIC_RAW` + /// are both running with equivalent frequency and no active slew, so they should be ticking + /// at the same rate. + /// + /// The caller can then compare this initial offset with an offset at some point in the future, and + /// the difference represents how much the clock has been steered by `ClockAdjust`. + fn get_start_offset(&self) -> Duration { + self.start_offset + } + + /// The total expected change in the offset between `CLOCK_REALTIME` and `CLOCK_MONOTONIC_RAW` after + /// a clock adjustment has begun. + fn expected_offset_change_at(&self, time: Instant) -> Duration { + let phase_offset_change = self.expected_offset_change_due_to_phase_correction_at(time); + let skew_offset_change = self.expected_offset_change_due_to_skew_at(time); + phase_offset_change + skew_offset_change + } + + /// Returns the expected change in the offset between `CLOCK_REALTIME` and `CLOCK_MONOTONIC_RAW` based on the + /// `phase_correction` factor. + /// + /// Linux kernel PLL correction begins at the top of a second (once fractional part of second overflows) + /// It calculates a PLL correction to make over the next second, and does so repeatedly. + /// The calculation of the slew the kernel uses at some point is done via a bitshift of the + /// current remaining offset. + #[allow( + clippy::cast_precision_loss, + reason = "`current_pll_correction_nanos` is not expected to be large enough to cause precision loss" + )] + fn expected_offset_change_due_to_phase_correction_at(&self, _time: Instant) -> Duration { + todo!("implement me"); + } + + /// Returns the expected change in the offset between `CLOCK_REALTIME` and `CLOCK_MONOTONIC_RAW` based on the + /// `skew` factor. This is much more straightforward - `freq` in the kernel is applied immediately, so we can + /// just do a simple linear extrapolation of the expected time. + fn expected_offset_change_due_to_skew_at(&self, _time: Instant) -> Duration { + todo!("implement me"); + } +} From e332a6070d0cfdad92aa197510d749eadfe7d9ca Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Mon, 27 Oct 2025 13:23:24 -0400 Subject: [PATCH 046/177] Initial vmclock runner implementation (#52) This commit implements the vmclock runner which will sample the vmclock shared memory page, determine if a clock disruption event has occurred and send a clock disruption message event through the associated watch channel. --- clock-bound/Cargo.toml | 1 + clock-bound/src/daemon/io.rs | 2 + clock-bound/src/daemon/io/vmclock.rs | 331 +++++++++++++++++++++++++++ clock-bound/src/shm.rs | 26 +++ 4 files changed, 360 insertions(+) create mode 100644 clock-bound/src/daemon/io/vmclock.rs diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index e288ae4..19772e6 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -26,6 +26,7 @@ nom = { version = "8", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } thiserror = { version = "2.0", optional = true } tokio = { version = "1.47.1", features = [ + "fs", "net", "macros", "rt", diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 38107b2..e81a4d7 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -26,6 +26,8 @@ use ntp_source::NTPSource; mod tsc; +mod vmclock; + /// `SourceIO` acts as the front end for IO tasks. /// /// `SourceIO` contains the interface from which new IO tasks can be spawned, as well as an interface diff --git a/clock-bound/src/daemon/io/vmclock.rs b/clock-bound/src/daemon/io/vmclock.rs new file mode 100644 index 0000000..90b9266 --- /dev/null +++ b/clock-bound/src/daemon/io/vmclock.rs @@ -0,0 +1,331 @@ +//! VMClock Source + +use thiserror::Error; +use tokio::{ + fs, io, + sync::{mpsc, watch}, + time::{Duration, Interval, MissedTickBehavior, interval}, +}; +use tracing::{debug, info}; + +use crate::shm::ShmError; +use crate::vmclock::{shm::VMClockShmBody, shm_reader::VMClockShmReader}; + +use super::{ClockDisruptionEvent, ControlRequest}; + +const VMCLOCK_TIMEOUT: Duration = Duration::from_millis(100); + +/// Indicates the current status of the VMClock. +#[derive(Debug, PartialEq)] +pub enum ClockDisruptionStatus { + Normal, + Disrupted, +} + +#[derive(Debug, Error)] +pub enum VMClockConstructionError { + #[error("IO failure.")] + Io(#[from] io::Error), + #[error("Error with shared memory file.")] + ShmError(#[from] ShmError), + #[error("File does not exist")] + FileNonexistent(String), +} + +/// Contains the data needed to run the VMClock runner. +/// +/// The struct contains the data needed to access the VMClock shared memory file, +/// to determine if a clock disruption event has occurred, and send clock disruption events to +/// channel subscribers. +pub struct VMClock { + /// Path to the vmclock shared memory file. + path: String, + /// Interface used to read the shared memory file. + reader: VMClockShmReader, + /// Data from the previously read shared memory file. + previous_shm_body: VMClockShmBody, + /// The polling interval. + interval: Interval, + /// The message channel used to receive control requests. + ctrl_receiver: mpsc::Receiver, + /// The message channel used to send clock disruption events. + clock_disruption_sender: watch::Sender, +} + +impl VMClock { + /// Construct a new `VMClock` instance. + /// + /// Upon creation the VMClock shared memory page existence is verified, a reader is + /// constructed, and the memory page is read to determine the current state of the clock. + pub async fn construct( + vmclock_shm_path: &str, + ctrl_receiver: mpsc::Receiver, + clock_disruption_sender: watch::Sender, + ) -> Result { + if !fs::try_exists(vmclock_shm_path).await? { + return Err(VMClockConstructionError::FileNonexistent( + vmclock_shm_path.into(), + )); + } + + let mut reader = VMClockShmReader::new(vmclock_shm_path)?; + let vmclock_snapshot = *reader.snapshot()?; + let mut vmclock_interval = interval(VMCLOCK_TIMEOUT); + vmclock_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + Ok(VMClock { + path: vmclock_shm_path.into(), + reader, + previous_shm_body: vmclock_snapshot, + interval: interval(VMCLOCK_TIMEOUT), + ctrl_receiver, + clock_disruption_sender, + }) + } + + /// Reads the VMClock shared memory page and returns the current clock state. + fn sample(&mut self) -> Result { + let vmclock_snapshot = self.reader.snapshot()?; + + // The marker increments by an indeterminate amount every clock disruption event. + if self.previous_shm_body.disruption_marker != vmclock_snapshot.disruption_marker { + self.previous_shm_body.disruption_marker = vmclock_snapshot.disruption_marker; + return Ok(ClockDisruptionStatus::Disrupted); + } + Ok(ClockDisruptionStatus::Normal) + } + + /// VMClock runner. + /// + /// Reads the VMClock shared memory file and sends clock disruption events to channel + /// subscribers. + /// + /// # Panics + /// - If the `clock_disruption_sender` is unable to send a clock disruption event. + pub async fn run(&mut self) { + // Sampling loop + info!("Starting VMClock sampling loop."); + loop { + tokio::select! { + _ = self.interval.tick() => { + match self.sample() { + Ok(s) => { + if matches!(s, ClockDisruptionStatus::Disrupted) { + self.clock_disruption_sender.send(ClockDisruptionEvent{}).unwrap(); + debug!(?self, "A clock disruption event occurred and a disruption event was sent."); + } + }, + Err(e) => debug!(?e, "Failed to sample the VMClock.") + } + } + _ = self.ctrl_receiver.recv() => { + // Ctrl logic here. + // Currently we breakout of the loop if we receive a control event. + break; + } + } + } + info!("VMCLock runner exiting."); + } +} + +impl std::fmt::Debug for VMClock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.debug_struct("VMClock") + .field("path", &self.path) + .field("previous_shm_body", &self.previous_shm_body) + .field("interval", &self.interval) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod test { + use super::*; + + use crate::vmclock::shm::VMClockClockStatus; + use std::fs::{File, OpenOptions}; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Test struct used to hold the expected fields in the VMClock shared memory segment. + #[repr(C)] + #[derive(Debug, Copy, Clone, PartialEq)] + struct VMClockContent { + magic: u32, + size: u32, + version: u16, + counter_id: u8, + time_type: u8, + seq_count: u32, + disruption_marker: u64, + flags: u64, + _padding: [u8; 2], + clock_status: VMClockClockStatus, + leap_second_smearing_hint: u8, + tai_offset_sec: i16, + leap_indicator: u8, + counter_period_shift: u8, + counter_value: u64, + counter_period_frac_sec: u64, + counter_period_esterror_rate_frac_sec: u64, + counter_period_maxerror_rate_frac_sec: u64, + time_sec: u64, + time_frac_sec: u64, + time_esterror_nanosec: u64, + time_maxerror_nanosec: u64, + } + + impl Default for VMClockContent { + fn default() -> Self { + VMClockContent { + magic: 0x4B4C4356, + size: 104_u32, + version: 1_u16, + counter_id: 1_u8, + time_type: 0_u8, + seq_count: 10_u32, + disruption_marker: 888888_u64, + flags: 0_u64, + _padding: [0x00, 0x00], + clock_status: VMClockClockStatus::Synchronized, + leap_second_smearing_hint: 0_u8, + tai_offset_sec: 0_i16, + leap_indicator: 0_u8, + counter_period_shift: 0_u8, + counter_value: 123456_u64, + counter_period_frac_sec: 0_u64, + counter_period_esterror_rate_frac_sec: 0_u64, + counter_period_maxerror_rate_frac_sec: 0_u64, + time_sec: 0_u64, + time_frac_sec: 0_u64, + time_esterror_nanosec: 0_u64, + time_maxerror_nanosec: 0_u64, + } + } + } + + fn write_vmclock_content(file: &mut File, vmclock_content: &VMClockContent) { + // Convert the VMClockShmBody struct into a slice so we can write it all out, fairly magic. + // Definitely needs the #[repr(C)] layout. + let slice = unsafe { + ::core::slice::from_raw_parts( + (vmclock_content as *const VMClockContent) as *const u8, + ::core::mem::size_of::(), + ) + }; + + file.write_all(slice).expect("Write failed VMClockContent"); + file.sync_all().expect("Sync to disk failed"); + } + + #[tokio::test] + async fn vmclock_construction_success() { + // Create the shared memory file + let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); + let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); + let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); + let mut vmclock_shm_file = OpenOptions::new() + .write(true) + .open(vmclock_shm_path) + .expect("open vmclock file failed"); + let vmclock_content = VMClockContent::default(); + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + let (_, ctrl_receiver) = mpsc::channel::(1); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let _ = VMClock::construct(vmclock_shm_path, ctrl_receiver, clock_disruption_sender) + .await + .unwrap(); + } + + #[tokio::test] + async fn vmclock_construction_failure() { + let filename = "name/of/file/that/shouldnt_exist"; + + let (_, ctrl_receiver) = mpsc::channel::(1); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let construct_result = + VMClock::construct(filename, ctrl_receiver, clock_disruption_sender).await; + assert!(construct_result.is_err()); + } + + #[tokio::test] + async fn vmclock_sample_success() { + // Create the shared memory file + let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); + let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); + let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); + let mut vmclock_shm_file = OpenOptions::new() + .write(true) + .open(vmclock_shm_path) + .expect("open vmclock file failed"); + let vmclock_content = VMClockContent::default(); + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + let (_, ctrl_receiver) = mpsc::channel::(1); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let mut vmclock = + VMClock::construct(vmclock_shm_path, ctrl_receiver, clock_disruption_sender) + .await + .unwrap(); + let _ = vmclock.sample().unwrap(); + } + + #[tokio::test] + async fn vmclock_sample_no_clock_disruption() { + // Create the shared memory file + let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); + let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); + let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); + let mut vmclock_shm_file = OpenOptions::new() + .write(true) + .open(vmclock_shm_path) + .expect("open vmclock file failed"); + let vmclock_content = VMClockContent::default(); + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + // Construct the VMClock runner + let (_, ctrl_receiver) = mpsc::channel::(1); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let mut vmclock = + VMClock::construct(vmclock_shm_path, ctrl_receiver, clock_disruption_sender) + .await + .unwrap(); + + // Sample the shared memory page + let clock_status = vmclock.sample().unwrap(); + assert_eq!(clock_status, ClockDisruptionStatus::Normal); + } + + #[tokio::test] + async fn vmclock_sample_clock_disruption() { + // Create the shared memory file + let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); + let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); + let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); + let mut vmclock_shm_file = OpenOptions::new() + .write(true) + .open(vmclock_shm_path) + .expect("open vmclock file failed"); + let vmclock_content = VMClockContent::default(); + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + // Construct the vmclock runner + let (_, ctrl_receiver) = mpsc::channel::(1); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let mut vmclock = + VMClock::construct(vmclock_shm_path, ctrl_receiver, clock_disruption_sender) + .await + .unwrap(); + + // Update the shared memory file + let mut vmclock_content = VMClockContent::default(); + vmclock_content.disruption_marker += 1; + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + // Sample the vmclock + let clock_status = vmclock.sample().unwrap(); + assert_eq!(clock_status, ClockDisruptionStatus::Normal); + } +} diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 98fd166..60254f7 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -19,7 +19,9 @@ pub use writer::{ShmWrite, ShmWriter}; use errno::Errno; use nix::sys::time::{TimeSpec, TimeValLike}; +use std::error::Error; use std::ffi::CStr; +use std::fmt; use common::{CLOCK_MONOTONIC, CLOCK_REALTIME, clock_gettime_safe}; @@ -58,6 +60,30 @@ pub enum ShmError { SegmentVersionNotSupported, } +impl fmt::Display for ShmError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ShmError::SyscallError(errno, c_str) => { + write!(f, "Errno: {errno:?} Details: {c_str:?}") + } + ShmError::SegmentNotInitialized => { + write!(f, "The shared memory segment is not initialized.") + } + ShmError::SegmentMalformed => { + write!(f, "The shared memory segment is initialized but malformed.") + } + ShmError::CausalityBreach => { + write!(f, "Failed causality check when comparing timestamps.") + } + ShmError::SegmentVersionNotSupported => { + write!(f, "The shared memory segment version is not supported.") + } + } + } +} + +impl Error for ShmError {} + /// Definition of mutually exclusive clock status exposed to the reader. #[repr(C)] #[derive(Debug, Copy, Clone, PartialEq)] From d52af475867e461bd53bfd313987e2b3dd6971ec Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 28 Oct 2025 09:28:17 -0400 Subject: [PATCH 047/177] [time] changed fmt::Debug to output more readable values (#55) --- .../src/time/estimate_instant.rs | 5 +- .../src/time/true_instant.rs | 5 +- clock-bound/src/daemon/time/inner.rs | 110 +++++++++++++++--- clock-bound/src/daemon/time/instant.rs | 5 +- clock-bound/src/daemon/time/tsc.rs | 24 ++++ 5 files changed, 129 insertions(+), 20 deletions(-) diff --git a/clock-bound-ff-tester/src/time/estimate_instant.rs b/clock-bound-ff-tester/src/time/estimate_instant.rs index 0eb9e6a..17667c6 100644 --- a/clock-bound-ff-tester/src/time/estimate_instant.rs +++ b/clock-bound-ff-tester/src/time/estimate_instant.rs @@ -16,7 +16,10 @@ use clock_bound::daemon::time as cb_time; pub struct Estimate; impl super::inner::Type for Estimate {} -impl super::inner::FemtoType for Estimate {} +impl super::inner::FemtoType for Estimate { + const INSTANT_PREFIX: &'static str = "EstimateInstant"; + const DURATION_PREFIX: &'static str = "EstimateDuration"; +} /// Representation of an absolute time timestamp /// diff --git a/clock-bound-ff-tester/src/time/true_instant.rs b/clock-bound-ff-tester/src/time/true_instant.rs index be0533c..29bc02d 100644 --- a/clock-bound-ff-tester/src/time/true_instant.rs +++ b/clock-bound-ff-tester/src/time/true_instant.rs @@ -11,7 +11,10 @@ use super::{EstimateDuration, EstimateInstant}; pub struct True; impl Type for True {} -impl FemtoType for True {} +impl FemtoType for True { + const INSTANT_PREFIX: &'static str = "TrueInstant"; + const DURATION_PREFIX: &'static str = "TrueDuration"; +} /// Representation of true time timestamps /// diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 70e629d..ea52ac6 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -16,7 +16,11 @@ use libc::timeval; pub trait Type {} /// Abstraction for time type whose tick unit is approximately one femtosecond -pub trait FemtoType: Type {} +pub trait FemtoType: Type { + /// The type prefix to use in the `Debug` impl + const INSTANT_PREFIX: &'static str; + const DURATION_PREFIX: &'static str; +} /// An offset and round-trip-time associated with a comparison of two clocks. /// @@ -27,14 +31,14 @@ pub trait FemtoType: Type {} /// The comparison is bounded by the round-trip-time of this measurement, and thus is useful for /// determining the quality of the sample or bounding the clock error. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ClockOffsetAndRtt { +pub struct ClockOffsetAndRtt { /// Offset offset: Diff, /// RTT rtt: Diff, } -impl ClockOffsetAndRtt { +impl ClockOffsetAndRtt { fn new(offset: Diff, rtt: Diff) -> Self { Self { offset, rtt } } @@ -55,7 +59,7 @@ pub trait Clock { } /// Extension trait for Clock that provides offset and RTT measurement functionality -pub trait ClockExt: Clock { +pub trait ClockExt: Clock { /// Get the offset and RTT measurement for this reading between this clock and another clock. /// The "offset" is from the caller to the `other` clock - e.g. if `self` is running behind `other`, /// we would expect a negative value, and if it `self` running ahead of `other`, we expect a positive value. @@ -71,13 +75,13 @@ pub trait ClockExt: Clock { } /// Blanket implementation of `ClockExt` for all types that implement Clock -impl> ClockExt for C {} +impl> ClockExt for C {} /// Abstract type for Time while keeping arithmetic consistent /// /// This type is not usually used directly, but rather through the [`Instant`](super::Instant) and [`Tsc`](super::TscCount) types. #[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, + Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, )] #[serde(transparent)] #[repr(transparent)] @@ -86,6 +90,12 @@ pub struct Time { _marker: std::marker::PhantomData, } +impl std::fmt::Debug for Time { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + debug_femtos(f, T::INSTANT_PREFIX, self.as_femtos()) + } +} + impl Time { /// Get the inner value pub const fn get(&self) -> i128 { @@ -262,6 +272,16 @@ impl Time { (self.get() + FEMTOS_PER_SEC / 2) / FEMTOS_PER_SEC } + /// Returns the total number of seconds, truncated + pub const fn as_seconds_trunc(self) -> i128 { + self.get() / FEMTOS_PER_SEC + } + + /// Returns the total number of nanoseconds, truncated + pub const fn as_nanos_trunc(self) -> i128 { + self.get() / FEMTOS_PER_NANO + } + /// Returns the total number of minutes, rounded, since the Unix Epoch pub const fn as_minutes(self) -> i128 { const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE; @@ -285,17 +305,7 @@ impl Time { /// /// It is not recommended to use this directly, but use the [`Duration`](super::Duration) or [`TscDiff`](super::TscDiff) types #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - Default, - serde::Serialize, - serde::Deserialize, + Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, serde::Serialize, serde::Deserialize, )] #[serde(transparent)] #[repr(transparent)] @@ -304,6 +314,12 @@ pub struct Diff { _marker: PhantomData, } +impl std::fmt::Debug for Diff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + debug_femtos(f, T::DURATION_PREFIX, self.as_femtos()) + } +} + impl Diff { /// Inner Count value pub const fn get(&self) -> i128 { @@ -616,6 +632,39 @@ impl Diff { } } +// print in .__ format. +fn debug_femtos( + f: &mut std::fmt::Formatter<'_>, + prefix: &'static str, + femtos: i128, +) -> std::fmt::Result { + let secs = femtos / FEMTOS_PER_SEC; + let nanos = (femtos / FEMTOS_PER_NANO - secs * 1_000_000_000).abs(); + if nanos == 0 { + return f + .debug_tuple(prefix) + .field(&format_args!("{secs}.0")) + .finish(); + } + let millis = nanos / 1_000_000; + let micros = (nanos / 1_000) % 1_000; + let nanos = nanos % 1_000; + if nanos == 0 && micros == 0 { + return f + .debug_tuple(prefix) + .field(&format_args!("{secs}.{millis:0>3}")) + .finish(); + } + if nanos == 0 { + return f + .debug_tuple(prefix) + .field(&format_args!("{secs}.{millis:0>3}_{micros:0>3}")) + .finish(); + } + let formatted = format_args!("{secs}.{millis:0>3}_{micros:0>3}_{nanos:0>3}"); + f.debug_tuple(prefix).field(&formatted).finish() +} + pub(crate) const FEMTOS_PER_SEC: i128 = 1_000_000_000_000_000; pub(crate) const FEMTOS_PER_MILLI: i128 = 1_000_000_000_000; pub(crate) const FEMTOS_PER_MICRO: i128 = 1_000_000_000; @@ -628,6 +677,7 @@ pub(crate) const NANOS_PER_SECOND: i128 = 1_000_000_000; #[cfg(test)] mod test { + use rstest::rstest; use std::sync::{Arc, Mutex}; use super::*; @@ -929,6 +979,32 @@ mod test { assert_eq!(tv.tv_usec, tv_usec_nanos); } + #[rstest] + #[case(Duration::from_seconds_f64(1.123456789), "Duration(1.123_456_789)")] + #[case(Duration::from_seconds_f64(1.123456), "Duration(1.123_456)")] + #[case(Duration::from_seconds_f64(1.123), "Duration(1.123)")] + #[case(Duration::from_secs(1234567), "Duration(1234567.0)")] + #[case(Duration::from_seconds_f64(-1.123456), "Duration(-1.123_456)")] + #[case(Duration::from_secs(0), "Duration(0.0)")] + #[case(Duration::from_picos(6500), "Duration(0.000_000_006)")] + #[case(Duration::from_picos(50), "Duration(0.0)")] + fn duration_debug(#[case] duration: Duration, #[case] expected: &str) { + assert_eq!(format!("{duration:?}"), expected); + } + + #[rstest] + #[case(Instant::from_nanos(1123456789), "Instant(1.123_456_789)")] + #[case(Instant::from_nanos(1123456000), "Instant(1.123_456)")] + #[case(Instant::from_nanos(1123000000), "Instant(1.123)")] + #[case(Instant::from_secs(1234567), "Instant(1234567.0)")] + #[case(Instant::from_micros(-1123456), "Instant(-1.123_456)")] + #[case(Instant::from_secs(0), "Instant(0.0)")] + #[case(Instant::from_picos(6500), "Instant(0.000_000_006)")] + #[case(Instant::from_picos(50), "Instant(0.0)")] + fn instant_debug(#[case] instant: Instant, #[case] expected: &str) { + assert_eq!(format!("{instant:?}"), expected); + } + #[test] fn clock_get_offset_and_rtt() { // Create mock clocks diff --git a/clock-bound/src/daemon/time/instant.rs b/clock-bound/src/daemon/time/instant.rs index f94ad3f..aa16ca1 100644 --- a/clock-bound/src/daemon/time/instant.rs +++ b/clock-bound/src/daemon/time/instant.rs @@ -7,7 +7,10 @@ use super::inner::{Diff, Time}; pub struct Utc; impl super::inner::Type for Utc {} -impl super::inner::FemtoType for Utc {} +impl super::inner::FemtoType for Utc { + const INSTANT_PREFIX: &'static str = "Instant"; + const DURATION_PREFIX: &'static str = "Duration"; +} /// Representation of an absolute time timestamp /// diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 03b9489..a6a60de 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -38,6 +38,18 @@ pub type TscCount = Time; /// Corresponding duration type for [`TscCount`] pub type TscDiff = Diff; +impl std::fmt::Debug for TscCount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("TscCount").field(&self.get()).finish() + } +} + +impl std::fmt::Debug for TscDiff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("TscDiff").field(&self.get()).finish() + } +} + impl TscCount { /// Get an uncorrected time value /// @@ -669,4 +681,16 @@ mod tests { use std::str::FromStr; let _ = Period::from_str(input).unwrap_err(); } + + #[test] + fn debug_tsc_count() { + let tsc = TscCount::new(1_000_000_000); + assert_eq!(format!("{tsc:?}"), "TscCount(1000000000)"); + } + + #[test] + fn debug_tsc_diff() { + let diff = TscDiff::new(1_000_000_000); + assert_eq!(format!("{diff:?}"), "TscDiff(1000000000)"); + } } From cfad5b895a5be35bee692d09ff7a4a4dbe695f52 Mon Sep 17 00:00:00 2001 From: mk <55758543+mekabir@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:37:25 -0400 Subject: [PATCH 048/177] Add the 0xFEC2 extension field to Packet as well as builder pattern (#66) * Add the 0xFEC2 extension field to Packet as well as builder pattern This commit: - adds the 0xFEC2 extension field to Packet (types, serializing, parsing, etc) - enables building an NTP packet with the builder pattern --------- Co-authored-by: MOHAMMED KABIR --- clock-bound/src/daemon/io/ntp/packet.rs | 54 +++- .../src/daemon/io/ntp/packet/extension.rs | 240 ++++++++++++++++++ 2 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 clock-bound/src/daemon/io/ntp/packet/extension.rs diff --git a/clock-bound/src/daemon/io/ntp/packet.rs b/clock-bound/src/daemon/io/ntp/packet.rs index a076f65..b9ccdff 100644 --- a/clock-bound/src/daemon/io/ntp/packet.rs +++ b/clock-bound/src/daemon/io/ntp/packet.rs @@ -13,13 +13,16 @@ )] use std::fmt::Display; +use bon::Builder; use nom::Parser; use nom::number::{be_i8, be_u8}; +mod extension; mod header; mod short; mod timestamp; +pub use extension::ExtensionField; pub use header::{LeapIndicator, Mode, Version}; pub use short::Short; pub use timestamp::Timestamp; @@ -30,34 +33,50 @@ use crate::daemon::time::{Duration as ClockBoundDuration, Instant as ClockBoundI /// An NTP packet, as defined in RFC 5905 /// /// See each of the fields for more information -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Builder)] pub struct Packet { /// warning of impending leap second + #[builder(default = LeapIndicator::NoWarning)] pub leap_indicator: LeapIndicator, /// NTP version + #[builder(default = Version::V4)] pub version: Version, /// Association mode + #[builder(default = Mode::Client)] pub mode: Mode, /// Stratum + #[builder(default = 0)] pub stratum: u8, /// Maximum interval between successive messages, in log2 seconds + #[builder(default = 0)] pub poll: u8, /// Precision of system clock in log2 seconds + #[builder(default = 0)] pub precision: i8, /// Total round trip delay to the reference clock + #[builder(default = Short::new(0))] pub root_delay: Short, /// Total dispersion to the reference clock + #[builder(default = Short::new(0))] pub root_dispersion: Short, /// 32 bit code identifying the particular server/reference clock + #[builder(default = [0u8; 4])] pub reference_id: [u8; 4], /// Time when the system clock was last corrected + #[builder(default = Timestamp::new(0))] pub reference_timestamp: Timestamp, /// Time at the client when the request departed for the server + #[builder(default = Timestamp::new(0))] pub origin_timestamp: Timestamp, /// Time at the server when the request arrived from the client + #[builder(default = Timestamp::new(0))] pub receive_timestamp: Timestamp, /// Time at the server when the response left for the client + #[builder(default = Timestamp::new(0))] pub transmit_timestamp: Timestamp, + /// Extension field(s), if any + #[builder(default = Vec::new())] + pub extensions: Vec, } impl Display for Packet { @@ -135,6 +154,7 @@ impl Packet { origin_timestamp: Timestamp::new(0), receive_timestamp: Timestamp::new(0), transmit_timestamp, + extensions: Vec::new(), } } @@ -190,6 +210,7 @@ impl Packet { origin_timestamp, receive_timestamp, transmit_timestamp, + extensions: Vec::new(), }; Ok((input, rv)) } @@ -223,6 +244,8 @@ mod test { use chrono::{DateTime, TimeDelta}; use hex_literal::hex; + use crate::daemon::io::ntp::packet::extension::Fec2V1Value; + use super::*; // test packet grabbed from wireshark @@ -310,4 +333,33 @@ mod test { ); assert_eq!(ntp_data.stratum, Stratum::ONE); } + + #[test] + fn packet_builder() { + // Test with defaults + let default_packet = Packet::builder().build(); + assert_eq!(default_packet.leap_indicator, LeapIndicator::NoWarning); + assert_eq!(default_packet.version, Version::V4); + assert_eq!(default_packet.mode, Mode::Client); + assert_eq!(default_packet.stratum, 0); + assert_eq!(default_packet.reference_id, [0u8; 4]); + assert!(default_packet.extensions.is_empty()); + + // Test with specified values + let custom_packet = Packet::builder() + .mode(Mode::Server) + .stratum(1) + .reference_id([0xa9, 0xfe, 0xa9, 0x7a]) + .extensions(vec![ExtensionField::Fec2V1(Fec2V1Value { + major_version: 1, + minor_version: 0, + startup_id: 0xdeadbeef, + })]) + .build(); + + assert_eq!(custom_packet.mode, Mode::Server); + assert_eq!(custom_packet.stratum, 1); + assert_eq!(custom_packet.reference_id, [0xa9, 0xfe, 0xa9, 0x7a]); + assert_eq!(custom_packet.extensions.len(), 1); + } } diff --git a/clock-bound/src/daemon/io/ntp/packet/extension.rs b/clock-bound/src/daemon/io/ntp/packet/extension.rs new file mode 100644 index 0000000..78a95ba --- /dev/null +++ b/clock-bound/src/daemon/io/ntp/packet/extension.rs @@ -0,0 +1,240 @@ +//! NTP Extension Fields +//! +//! This module implements NTP extension fields as defined in RFC 5905 Section 7.5. +//! Extension fields provide additional data in NTP packets and are inserted +//! after the NTP header and before the MAC (when present). +//! +//! See: + +use bytes::BufMut; +use nom::error::{Error, ErrorKind}; +use nom::number::complete::{be_u8, be_u16, be_u64}; + +/// NTP Extension Field variants +/// +/// Extension fields follow the standard NTP extension format from RFC 5905: +/// - Field Type (2 bytes): Identifies the extension type +/// - Length (2 bytes): Total length including padding, in octets +/// - Value (variable): Extension-specific payload +/// - Padding (as needed): Zero-padded to 4-octet boundary +/// +/// 0 1 2 3 +/// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// | Field Type | Length | +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// . . +/// . Value . +/// . . +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// | Padding (as needed) | +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExtensionField { + Fec2V1(Fec2V1Value), +} + +/// 0xFEC2 Extension Field (v1) +/// +/// This extension provides ClockBound version information. +/// Field Type: 0xFEC2, Total Length: 16 octets (4-octet aligned). +/// Note 0xFEC2 is in the range of field types reserved for private and +/// experimental use (0xF000 - 0xFFFF). +/// See: +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fec2V1Value { + /// Major version of this ClockBound + pub major_version: u8, + /// Minor version of this ClockBound + pub minor_version: u8, + /// Unique id generated on each daemon startup + pub startup_id: u64, +} + +impl ExtensionField { + /// Returns the NTP extension Field Type as defined in RFC 5905 + /// + /// A 16-bit value that uniquely identifies the specific extension field and its function + /// + /// # Returns + /// - `0xFEC2` for `Fec2V1` extensions + pub fn field_type(&self) -> u16 { + match self { + ExtensionField::Fec2V1 { .. } => 0xFEC2, + } + } + + /// Returns the total Length field value as defined in RFC 5905 + /// + /// The Length field is a 16-bit unsigned integer that indicates the length + /// of the entire extension field in octets, including any padding needed + /// to align the whole extension field to a 4-octet boundary. + /// + /// # Returns + /// - `16` octets for `Fec2V1` extensions + pub fn length(&self) -> u16 { + match self { + ExtensionField::Fec2V1 { .. } => 16, // 4 header + 12 payload, already aligned + } + } + + /// Serializes the extension field to bytes in network byte order + /// + /// # Arguments + /// * `buffer` - Mutable byte slice to write the extension data to + /// + /// # Panics + /// Panics if the buffer is smaller than the extension length + pub fn emit_bytes(&self, buffer: &mut [u8]) { + let mut buf = buffer; + buf.put_u16_ne(self.field_type().to_be()); + buf.put_u16_ne(self.length().to_be()); + + match self { + ExtensionField::Fec2V1(fec2v1) => { + buf.put_u8(1); // 0xFEC2v1 + buf.put_u8(fec2v1.major_version); + buf.put_u8(fec2v1.minor_version); + buf.put_u8(0); + buf.put_u64_ne(fec2v1.startup_id.to_be()); + } + } + } + + /// Parses an extension field from bytes in network byte order + /// + /// Attempts to parse an NTP extension field from the provided byte slice + /// according to RFC 5905 format. The input should contain the complete + /// extension including Field Type, Length, Value, and any padding. + /// + /// # Arguments + /// * `input` - Byte slice containing the extension field data + /// + /// # Returns + /// * `Ok((remaining_slice, extension))` - Successfully parsed extension and remaining bytes + /// * `Err(_)` - Parse error for unknown field types or malformed data + /// + /// # Errors + /// Returns a nom parsing error if: + /// - The Field Type is not recognized + /// - The input is too short for the expected format + /// - The data is malformed + pub fn parse_from_bytes(input: &[u8]) -> nom::IResult<&[u8], ExtensionField> { + let (input, field_type) = be_u16(input)?; + let (input, _length) = be_u16(input)?; + + match field_type { + 0xFEC2 => { + let (input, _ext_version) = be_u8(input)?; + let (input, major_version) = be_u8(input)?; + let (input, minor_version) = be_u8(input)?; + let (input, _) = be_u8(input)?; + let (input, startup_id) = be_u64(input)?; + + Ok(( + input, + ExtensionField::Fec2V1(Fec2V1Value { + major_version, + minor_version, + startup_id, + }), + )) + } + _ => Err(nom::Err::Error(Error::new(input, ErrorKind::Switch))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_field_wire_format_emit() { + let ext = ExtensionField::Fec2V1(Fec2V1Value { + major_version: 2, + minor_version: 3, + startup_id: 0x123456789ABCDEF0, + }); + + let mut buffer = vec![0u8; ext.length() as usize]; + ext.emit_bytes(&mut buffer); + + let expected = [ + 0xFE, 0xC2, // Field Type (0xFEC2) + 0x00, 0x10, // Length (16 octets) + 0x01, // ext_version (1) + 0x02, // major_version (2) + 0x03, // minor_version (3) + 0x00, // reserved (0) + 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, // startup_id + ]; + assert_eq!(buffer, expected); + } + + #[test] + fn extension_field_wire_format_parse() { + let wire_bytes = [ + 0xFE, 0xC2, // Field Type (0xFEC2) + 0x00, 0x10, // Length (16 octets) + 0x01, // ext_version (1) + 0x02, // major_version (2) + 0x03, // minor_version (3) + 0x00, // reserved (0) + 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, // startup_id + ]; + + let (leftover, parsed) = ExtensionField::parse_from_bytes(&wire_bytes).unwrap(); + assert!(leftover.is_empty()); + + match parsed { + ExtensionField::Fec2V1(fec2value) => { + assert_eq!(fec2value.major_version, 2); + assert_eq!(fec2value.minor_version, 3); + assert_eq!(fec2value.startup_id, 0x123456789ABCDEF0); + } + } + } + + #[test] + fn extension_field_methods() { + let ext = ExtensionField::Fec2V1(Fec2V1Value { + major_version: 1, + minor_version: 2, + startup_id: 0xFFC0FFEE, + }); + + assert_eq!(ext.field_type(), 0xFEC2); + assert_eq!(ext.length(), 16); + } + + #[test] + fn parse_unknown_field_type_fails() { + let wire_bytes = [ + 0xFF, 0xFF, // unknown Field Type + 0x00, 0x08, // Length (8 octets) + 0x01, 0x02, 0x03, 0x04, // payload + ]; + + let result = ExtensionField::parse_from_bytes(&wire_bytes); + assert!(result.is_err()); + } + + #[test] + fn extension_field_roundtrip() { + let original = ExtensionField::Fec2V1(Fec2V1Value { + major_version: 1, + minor_version: 2, + startup_id: 0xDEADBEEFCAFEBABE, + }); + + // Serialize + let mut buffer = vec![0u8; original.length() as usize]; + original.emit_bytes(&mut buffer); + + // Parse back + let (leftover, parsed) = ExtensionField::parse_from_bytes(&buffer).unwrap(); + assert!(leftover.is_empty()); + assert_eq!(original, parsed); + } +} From ad30b74a3704359941177e90dec0e3de35e46c84 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 28 Oct 2025 14:53:47 -0400 Subject: [PATCH 049/177] [ff::ntp] add local period calculation function (#54) --- .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 145 +++++++++++++++++- clock-bound/src/daemon/event/ntp.rs | 74 +++++++++ clock-bound/src/daemon/time/tsc.rs | 12 +- 3 files changed, 226 insertions(+), 5 deletions(-) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 9293960..36a5d28 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -3,7 +3,12 @@ use std::num::NonZeroUsize; use super::event_buffer; -use crate::daemon::{clock_parameters::ClockParameters, event, time::tsc::Period}; +use crate::daemon::{ + clock_parameters::ClockParameters, + clock_sync_algorithm::ring_buffer::Quarter, + event::{self, TscRtt}, + time::tsc::Period, +}; /// Feed forward time synchronization algorithm for a single NTP source #[derive(Debug, Clone)] @@ -47,4 +52,142 @@ impl Ntp { pub fn clock_parameters(&self) -> Option<&ClockParameters> { self.clock_parameters.as_ref() } + + /// Calculate the local period and associated error + /// + /// Returns `None` if `local` has less than 2 data points + #[expect( + clippy::cast_precision_loss, + reason = "Needed an escape hatch. Error units weren't lining up" + )] + fn calculate_local_period_and_error( + local: &event_buffer::Local, + ) -> Option { + if local.as_ref().len() < 2 { + return None; + } + // unwrap okay, length of 2 means both quarters and min check will succeed + let old = local.as_ref().min_rtt_in_quarter(Quarter::Oldest).unwrap(); + let new = local.as_ref().min_rtt_in_quarter(Quarter::Newest).unwrap(); + + let period_local = new.calculate_period_backward(old); + + // unwrap okay, local is not empty + let min = local.as_ref().min_rtt().unwrap(); + let min_rtt = min.rtt(); + let new_sample_error = new.rtt() - min_rtt; + let old_sample_error = old.rtt() - min_rtt; + + assert!(new_sample_error.get() >= 0); + assert!(old_sample_error.get() >= 0); + + // We have used this somewhere else, but I don't see the math for this. + // units also come out as GHz... (ticks / nanoseconds) + // seems sus... + // + // Currently this value is not used within the FF algorithm nor client, but we need to address this calculation + // in the future + let error = ((new_sample_error + old_sample_error).get() as f64) + / (new.data().server_recv_time - old.data().server_recv_time).as_nanos() as f64; + let error = Period::from_seconds(error); + tracing::debug!( + ?old, + ?new, + min_rtt = ?min, + %period_local, + %error, + "Calculated local period and error" + ); + Some(LocalPeriodAndError { + period_local, + error, + }) + } +} + +/// Used as the output for [`Ntp::calculate_local_period_and_error`] +struct LocalPeriodAndError { + /// period calculation + period_local: Period, + /// period error + error: Period, +} + +#[cfg(test)] +mod test { + use crate::daemon::{ + event::{NtpData, Stratum}, + time::{Duration, Instant, TscCount}, + }; + + use super::*; + + #[test] + fn calculate_local_period_returns_none() { + // return none if local has < 2 events + + let mut local = event_buffer::Local::new(NonZeroUsize::new(2).unwrap()); + let result = Ntp::calculate_local_period_and_error(&local); + assert!(result.is_none()); + + local + .feed( + event::Ntp::builder() + .tsc_pre(TscCount::new(0)) + .tsc_post(TscCount::new(1_000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(5), + root_delay: Duration::from_micros(6), + root_dispersion: Duration::from_nanos(350), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + ) + .unwrap(); + + let result = Ntp::calculate_local_period_and_error(&local); + assert!(result.is_none()); + } + + #[test] + fn calculate_local_period() { + let mut local = event_buffer::Local::new(NonZeroUsize::new(2).unwrap()); + + let old_event = event::Ntp::builder() + .tsc_pre(TscCount::new(0)) + .tsc_post(TscCount::new(1_000_000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(5), + root_delay: Duration::from_micros(6), + root_dispersion: Duration::from_nanos(350), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + // new event is 100 seconds in the future with tsc at 1GHz + let new_event = event::Ntp::builder() + .tsc_pre(TscCount::new(100_000_000_000)) + .tsc_post(TscCount::new(100_001_000_000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_days(1) + Duration::from_secs(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(100) + + Duration::from_micros(5), + root_delay: Duration::from_micros(6), + root_dispersion: Duration::from_nanos(350), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + local.feed(old_event).unwrap(); + local.feed(new_event).unwrap(); + + let result = Ntp::calculate_local_period_and_error(&local).unwrap(); + assert_eq!(result.period_local, Period::from_seconds(1.0e-9)); + } } diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index 3b43caa..d051d90 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -55,6 +55,31 @@ impl Ntp { &self.data } + /// Calculate a period by using 2 NTP events using return path + /// + /// NTP traffic is characterized in ClockBound with each exchange having a + /// - `tsc_pre`: The TSC reading before sending the NTP packet + /// - `server_recv_system_time`: The server's system time after receiving the NTP packet + /// - `server_send_system_time`: The server's system time after sending the NTP packet + /// - `tsc_post`: The TSC reading after sending the NTP packet + /// + /// The "backward" path here means using the `server_send_system_time` and `tsc_post` from + /// two events to calculate the TSC period. + /// + /// # Panics + /// - Panics if events share the same `tsc_post` (happens if the events are the same). + /// - Panics if events share the same `server_send_time` (also happens if the events are the same) + pub fn calculate_period_backward(&self, other: &Self) -> Period { + let (old, new) = if self.tsc_post < other.tsc_post { + (self, other) + } else { + (other, self) + }; + + (new.data().server_send_time - old.data().server_send_time) + / (new.tsc_post() - old.tsc_post()) + } + /// Calculate the clock error bound of this event at the time of the event /// /// This is different from the clock error bound that would be reported to a user outside of the daemon. @@ -294,6 +319,21 @@ mod tests { assert!(matches!(Stratum::try_from(255), Err(TryFromU8Error))); } + fn create_ntp_event(pre: TscCount, post: TscCount, server_send_time: Instant) -> Ntp { + Ntp::builder() + .tsc_pre(pre) + .tsc_post(post) + .ntp_data(NtpData { + server_recv_time: Instant::from_nanos(0), // Not used in calculation + server_send_time: server_send_time, + root_delay: Duration::from_nanos(0), // Not used in calculation + root_dispersion: Duration::from_nanos(0), // Not used in calculation + stratum: Stratum::ONE, // Not used in calculation + }) + .build() + .unwrap() + } + #[rstest] #[case::minimal_delays( Ntp::builder() @@ -356,6 +396,40 @@ mod tests { ); } + #[rstest] + #[case( + // First event + (TscCount::new(100), TscCount::new(200), Instant::from_days(1000)), + // Second event + (TscCount::new(300), TscCount::new(400), Instant::from_days(1000) + Duration::from_secs(1)), + Period::from_seconds(0.005), + )] + #[case( + // First event + (TscCount::new(1000), TscCount::new(2000), Instant::from_days(0)), + // Second event + (TscCount::new(3000), TscCount::new(4000), Instant::from_millis(500)), + Period::from_seconds(0.00025), + )] + #[case( + // First event with larger values + (TscCount::new(10000), TscCount::new(20000), Instant::from_secs(100000)), + // Second event + (TscCount::new(30000), TscCount::new(40000), Instant::from_secs(200000)), + // Expected period (server_time_diff / tsc_diff = (200000-100000)/(40000-20000) = 5) + Period::from_seconds(5.0), + )] + fn test_calculate_period_backward( + #[case] (first_pre, first_post, first_send): (TscCount, TscCount, Instant), + #[case] (second_pre, second_post, second_send): (TscCount, TscCount, Instant), + #[case] expected_period: Period, + ) { + let event1 = create_ntp_event(first_pre, first_post, first_send); + let event2 = create_ntp_event(second_pre, second_post, second_send); + + let period = event1.calculate_period_backward(&event2); + approx::assert_abs_diff_eq!(period.get(), expected_period.get()); + } #[rstest] #[case( // Zero root delay and dispersion diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index a6a60de..754e60c 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -336,6 +336,10 @@ impl std::str::FromStr for Skew { /// All time durations are stored internally as `i128` values. This means it is possible /// to store frequency values that will have precision loss when converted from /// measurements based on [`Duration`] types +/// +/// ## Note on zero valued periods +/// While not logical for clocks, it can come up for error calculations currently. +/// FIXME, does a zero error ever make sense? #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(transparent)] pub struct Period(f64); @@ -344,18 +348,18 @@ impl Period { /// Construct from seconds /// /// # Panics - /// Panics if `seconds` <= 0 + /// Panics if `seconds` < 0 pub fn from_seconds(seconds: f64) -> Self { - assert!(seconds > 0.0); + assert!(seconds >= 0.0); Self(seconds) } /// Construct from a duration /// /// # Panics - /// Panics if `duration <= 0` + /// Panics if `duration < 0` pub fn from_duration(duration: Duration) -> Self { - assert!(duration.get() > 0); + assert!(duration.get() >= 0); Self(duration.as_seconds_f64()) } From 2eb1567fad1c16b8cc880572e9c6ed701043f4f2 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Tue, 28 Oct 2025 16:03:35 -0400 Subject: [PATCH 050/177] Refactoring sourceio create_link_local into creation and spawn functions (#53) * Refactoring sourceio `create_link_local` into creation and spawn fns This commit refactors the `create_link_local` into two functions. 1. A new `create_link_local` function which will only create `LinkLocal` struct. 2. A `spawn_all` function which will spawn all io tasks. * Updated PR to include NTPSource(s) SourceIO construction and spawning With the addition of NTPSource refactoring the construction and spawning logic needs to be updated. This commit addresses specifics with the NTPSource and interactions with LinkLocal sources. --- clock-bound/src/daemon.rs | 4 +- clock-bound/src/daemon/io.rs | 199 ++++++++++++++++++------ clock-bound/src/daemon/io/ntp_source.rs | 1 + test/link-local/src/main.rs | 1 + test/ntp-source/src/main.rs | 10 +- 5 files changed, 163 insertions(+), 52 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index b24a00b..d656571 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -45,7 +45,9 @@ impl Daemon { // let link_local = io::LinkLocal::construct(rx, ..Args); // let io_front_end = SourceIo::builder().link_local(tx, link_local).build(); // ``` - let () = io_front_end.create_link_local(tx).await; + io_front_end.create_link_local(tx).await; + // TODO: Refactor so that the construction and spawning occurs in separate functions. + io_front_end.spawn_all(); Self { _io_front_end: io_front_end, diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index e81a4d7..8e3bf16 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -7,17 +7,14 @@ use std::collections::HashMap; use std::net::SocketAddr; - use tokio::net::UdpSocket; use tokio::sync::{mpsc, watch}; use tokio::task::spawn; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; pub mod ntp; use crate::daemon::{async_ring_buffer, event}; -// use super::event; -// use ntp::LinkLocal; mod link_local; use link_local::LinkLocal; @@ -33,8 +30,8 @@ mod vmclock; /// `SourceIO` contains the interface from which new IO tasks can be spawned, as well as an interface /// to send control commands to the specific IO tasks. pub struct SourceIO { - /// A mapping between the time source and the control sender. - sources: HashMap>, + /// A mapping between the time source type and the task handle. + sources: Sources, /// Contains the channel used to communicate clock disruption events. clock_disruption_channels: ClockDisruptionChannels, } @@ -44,7 +41,7 @@ impl SourceIO { pub fn construct() -> Self { let (sender, receiver) = watch::channel::(ClockDisruptionEvent {}); SourceIO { - sources: HashMap::new(), + sources: Sources::default(), clock_disruption_channels: ClockDisruptionChannels { sender, receiver }, } } @@ -55,26 +52,29 @@ impl SourceIO { /// - If not called within the `tokio` runtime. /// - If socket binding fails. pub async fn create_link_local(&mut self, event_sender: async_ring_buffer::Sender) { - if !self.sources.contains_key(&TimeSource::LinkLocal) { - let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); - let clock_disruption_receiver = self.clock_disruption_channels.sender.subscribe(); + info!("Creating link local source."); - let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) - .await - .unwrap(); - let mut link_local = LinkLocal::construct( - socket, - event_sender, - ctrl_receiver, - clock_disruption_receiver, - ); + debug!(?self.sources.link_local, "Current source entry status"); + if self.sources.link_local.is_none() { + self.sources.link_local = { + let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); + let clock_disruption_receiver = self.clock_disruption_channels.sender.subscribe(); + + let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) + .await + .unwrap(); - spawn(async move { - if let Err(e) = link_local.run().await { - debug!("LinkLocal runner exited with an error: {:#?}", e); - } - }); - self.sources.insert(TimeSource::LinkLocal, ctrl_sender); + let link_local = LinkLocal::construct( + socket, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ); + Some(Source { + state: SourceState::Initialized(link_local), + ctrl_sender, + }) + }; } info!("Source update complete."); @@ -92,10 +92,7 @@ impl SourceIO { ) { info!("Creating custom ntp server IO source."); - if !self - .sources - .contains_key(&TimeSource::NTPSource(server_address)) - { + if !self.sources.ntp_sources.contains_key(&server_address) { let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); let clock_disruption_receiver = self.clock_disruption_channels.sender.subscribe(); @@ -103,7 +100,7 @@ impl SourceIO { .await .unwrap(); - let mut ntp_source = NTPSource::construct( + let ntp_source = NTPSource::construct( socket, server_address, event_sender, @@ -111,18 +108,11 @@ impl SourceIO { clock_disruption_receiver, ); - spawn(async move { - if let Err(e) = ntp_source.run().await { - debug!( - "NTPSource({}) runner exited with an error: {:#?}", - server_address.ip().to_string(), - e - ); - } - }); - - self.sources - .insert(TimeSource::NTPSource(server_address), ctrl_sender); + let source = Source { + state: SourceState::Initialized(ntp_source), + ctrl_sender, + }; + self.sources.ntp_sources.insert(server_address, source); } info!("Source update complete."); @@ -134,6 +124,42 @@ impl SourceIO { reason = "This is a stubbed function. The async component will be implemented at a later date." )] pub async fn run(&self) {} + + /// Spawns all io tasks which have been initialized. + /// + /// This function will spawn all initialized io tasks. If a task is not initialized it will be + /// skipped. Spawning will occur in an arbitrary order. + pub fn spawn_all(&mut self) { + // Spawn link local source + if let Some(Source { + state, + ctrl_sender: _, + }) = &mut self.sources.link_local + { + debug!("Attempting to spawn link local source."); + if let SourceState::Initialized(mut link_local) = state.transition_to_running() { + spawn(async move { link_local.run().await }); + debug!("Successfully spawned link local source."); + } else { + warn!("Attempted to spawn a link local source when one is currently running."); + } + } else { + debug!("Could not spawn a link local source. No source data provided."); + } + + // Spawn ntp sources + for (key, ntp_source) in &mut self.sources.ntp_sources { + debug!("Attempting to spawn {key:?} ntp source."); + if let SourceState::Initialized(mut ntp_source) = + ntp_source.state.transition_to_running() + { + spawn(async move { ntp_source.run().await }); + debug!("Successfully spawned ntp source."); + } else { + warn!("Attempted to spawn a ntp source when on is currently running."); + } + } + } } /// Communication channels for sending and receiving clock disruption events. @@ -150,11 +176,86 @@ pub struct ClockDisruptionEvent {} #[derive(Debug)] pub struct ControlRequest {} -/// `TimeSource` is a type representing the possible time sources the daemon can collect samples -/// from. -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -enum TimeSource { - /// The internal AWS EC2 link local source `169.254.169.123`. - LinkLocal, - NTPSource(SocketAddr), +/// A helper struct packaging the source state and its control sender together. +#[derive(Debug)] +struct Source { + state: SourceState, + ctrl_sender: mpsc::Sender, +} + +/// `Sources` is a struct that maps sources to their state and control channels. +#[derive(Default, Debug)] +struct Sources { + link_local: Option>, + ntp_sources: HashMap>, +} + +/// The possible states a time source can be in. +#[derive(Debug)] +pub enum SourceState { + Initialized(T), + Running, +} + +impl SourceState { + fn is_initialized(&self) -> bool { + matches!(self, SourceState::Initialized(_)) + } + + fn is_running(&self) -> bool { + matches!(self, SourceState::Running) + } + + /// Changes the state to `Running` and returns the previous state. + fn transition_to_running(&mut self) -> SourceState { + std::mem::replace(self, SourceState::Running) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn source_state_is_initialized() { + let (event_sender, _) = async_ring_buffer::create::(1); + let (_, ctrl_receiver) = mpsc::channel::(1); + let (_, clock_disruption_receiver) = + watch::channel::(ClockDisruptionEvent {}); + + let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) + .await + .unwrap(); + + let link_local = LinkLocal::construct( + socket, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ); + let current_state = SourceState::Initialized(link_local); + assert!(current_state.is_initialized()) + } + + #[test] + fn source_state_is_running() { + let current_state = SourceState::::Running; + assert!(current_state.is_running()) + } + + #[test] + fn source_state_is_transitions() { + let mut current_state = SourceState::::Running; + assert!(current_state.transition_to_running().is_running()) + } + + #[tokio::test] + async fn source_io_verify_link_local_creation() { + let (event_sender, _) = async_ring_buffer::create::(1); + + let mut source_io = SourceIO::construct(); + source_io.create_link_local(event_sender).await; + + assert!(source_io.sources.link_local.is_some()) + } } diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 1686db1..890c424 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -39,6 +39,7 @@ pub enum NTPSourceError { /// Contains data used to run `NTPSource` runner. /// Notably, the IP address passed to this struct should be associated /// with an NTP host. +#[derive(Debug)] pub struct NTPSource { socket: UdpSocket, address: SocketAddr, diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index f81a99f..c9c1d0b 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -23,6 +23,7 @@ async fn main() { let mut sourceio = SourceIO::construct(); sourceio.create_link_local(link_local_sender).await; + sourceio.spawn_all(); let mut polling_rate = time::Duration::from_secs(0); for i in 0..11 { diff --git a/test/ntp-source/src/main.rs b/test/ntp-source/src/main.rs index aaee958..a2d6c8b 100644 --- a/test/ntp-source/src/main.rs +++ b/test/ntp-source/src/main.rs @@ -8,6 +8,7 @@ use clock_bound::daemon::io::SourceIO; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time; +use tokio::time::{Duration, timeout}; use tracing_subscriber::EnvFilter; @@ -37,6 +38,7 @@ async fn main() { sourceio .create_ntp_source(second_ntp_source_public_ip, second_ntp_source_sender) .await; + sourceio.spawn_all(); println!("NTP Server creation complete!"); let mut polling_rate = time::Duration::from_secs(0); @@ -44,8 +46,12 @@ async fn main() { println!("Polling {i}"); // Get NTP packet from both specified servers - let ntpevent_a = first_ntp_source_receiver.recv().await; - let ntpevent_b = second_ntp_source_receiver.recv().await; + let ntpevent_a = timeout(Duration::from_secs(32), first_ntp_source_receiver.recv()) + .await + .unwrap(); + let ntpevent_b = timeout(Duration::from_secs(32), second_ntp_source_receiver.recv()) + .await + .unwrap(); let now = time::Instant::now(); let d = now - start; From fe943362cd261dd3a415123e72b9cb56622d0818 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 29 Oct 2025 11:28:14 -0400 Subject: [PATCH 051/177] [ff::ntp] Add uncorrected clock calculation (#57) * [ff::ntp] Add uncorrected clock calculation * Revision: Add comment calling out why the NTP return path is used for the uncorrected clock calculation --- .../src/daemon/clock_sync_algorithm/ff.rs | 3 + .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 255 +++++++++++++++++- .../ff/uncorrected_clock.rs | 16 ++ 3 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/ff/uncorrected_clock.rs diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs index 2ec75ac..c7dae74 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs @@ -6,3 +6,6 @@ pub mod event_buffer; mod ntp; pub use ntp::Ntp; + +mod uncorrected_clock; +pub use uncorrected_clock::UncorrectedClock; diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 36a5d28..000d5fc 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -5,9 +5,10 @@ use std::num::NonZeroUsize; use super::event_buffer; use crate::daemon::{ clock_parameters::ClockParameters, - clock_sync_algorithm::ring_buffer::Quarter, - event::{self, TscRtt}, - time::tsc::Period, + clock_sync_algorithm::{ff::UncorrectedClock, ring_buffer::Quarter}, + event, + event::TscRtt, + time::{TscDiff, tsc::Period}, }; /// Feed forward time synchronization algorithm for a single NTP source @@ -53,6 +54,98 @@ impl Ntp { self.clock_parameters.as_ref() } + /// Calculate the estimate period and k value based off of the ring buffers + /// + /// Returns `None` if we do not have enough data points to calculate a period. + /// + /// We calculate a period by finding a pair of RTT values that are recent and as far in the past as possible, + /// and using the pair of TSC/reference clock values, + /// + /// ## Steady State + /// While the program is running, it is expected that the `Local` event buffer is completely filled, and the + /// `Estimate` event buffer is partially/completely filled. In this state, the "new" value comes from the min value + /// in the current SKM window (local buffer), and "old" is the oldest datapoint in the estimate buffer. + /// + /// ## Initializing + /// If there are less than 2 values in the estimate buffer, then use the min values in the local buffer + /// for "new" and "old" values. + /// + /// ## Starvation + /// If local buffer is empty but we have multiple values in estimate, we can just use those. + fn calculate_uncorrected_clock( + local: &event_buffer::Local, + estimate: &event_buffer::Estimate, + ) -> Option { + // get the min RTT values in the "old" and "new" time ranges + let (oldest, newest) = if estimate.as_ref().len() < 2 { + // fallback to local only calculation if estimate is small. + // + // rationale for not using "is empty" logic: When there is a single + // value in the estimate buffer, it will end up being the min value + // in the local buff for some time. Best to avoid that. + + // old means it's in the oldest quarter of the local buffer + // new means it's in the newest quarter of the local buffer + if local.as_ref().len() < 2 { + // We need at least 2 data points to start estimating the period + return None; + } + let oldest = local.as_ref().min_rtt_in_quarter(Quarter::Oldest).unwrap(); + let newest = local.as_ref().min_rtt_in_quarter(Quarter::Newest).unwrap(); + (oldest, newest) + } else if local.is_empty() { + // If local is empty but we have values in estimate (can happen if we are starving), + // we can fallback to using the estimate buffer + // unwrap okay. Estimate buffer has at least 2 data points from above if statement + let oldest = estimate.as_ref().tail().unwrap(); + let newest = estimate.as_ref().head().unwrap(); + (oldest, newest) + } else { + // happy case: We have values in the estimate buffer, and values in local + // old is the oldest value in the estimate buffer + // new is the min rtt value of local buffer. + // unwraps okay, if conditions above check that this will never happen + let oldest = estimate.as_ref().tail().unwrap(); + let newest = local.as_ref().min_rtt().unwrap(); + (oldest, newest) + }; + + // calculate using the backward offset (tsc_post and server_send) + // + // When it comes to the time period calculation, the most important factor is network path consistency. + // We have 4 options for doing this right now. + // - Using the return path only (what this code does) + // - Using the forward path only + // - using the midpoint + // - something more complex using period_max_error calculations + // + // The first 2 options (forward or return path) are stable and simple approaches. + // + // The midpoint creates an "averaging" effect. Since the goal is to minimize round trip, it ends + // up creating a "worst of both worlds" scenario. + // + // Anecdotally, we have seen some EC2 instances have more consistent link local RTT on the return + // path as opposed to the forward path. + // + // A complex strategy we could take on would be to calculate error values on slopes and directly compare + // these values in search. That kind of approach is TBD. + let p_estimate = (newest.data().server_send_time - oldest.data().server_send_time) + / (newest.tsc_post() - oldest.tsc_post()); + + let k = p_estimate * TscDiff::new(newest.tsc_post().get()); + let k = newest.data().server_send_time - k; + + tracing::debug!( + ?oldest, + ?newest, + ?p_estimate, + ?k, + "Calculated period and k values" + ); + + Some(UncorrectedClock { p_estimate, k }) + } + /// Calculate the local period and associated error /// /// Returns `None` if `local` has less than 2 data points @@ -114,14 +207,26 @@ struct LocalPeriodAndError { } #[cfg(test)] -mod test { +mod tests { use crate::daemon::{ + clock_sync_algorithm::{ + RingBuffer, + ff::event_buffer::{Estimate, Local}, + }, event::{NtpData, Stratum}, time::{Duration, Instant, TscCount}, }; use super::*; + #[test] + fn empty_buffers() { + let local = Local::new(NonZeroUsize::new(1).unwrap()); + let estimate = Estimate::new(); + let result = Ntp::calculate_uncorrected_clock(&local, &estimate); + assert!(result.is_none()); + } + #[test] fn calculate_local_period_returns_none() { // return none if local has < 2 events @@ -183,11 +288,151 @@ mod test { }) .build() .unwrap(); - local.feed(old_event).unwrap(); local.feed(new_event).unwrap(); let result = Ntp::calculate_local_period_and_error(&local).unwrap(); assert_eq!(result.period_local, Period::from_seconds(1.0e-9)); } + + #[test] + fn calculate_uncorrected_local_has_single_value() { + let mut local = Local::new(NonZeroUsize::new(1).unwrap()); + let event = event::Ntp::builder() + .tsc_pre(TscCount::new(100)) + .tsc_post(TscCount::new(110)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(10), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + local.feed(event.clone()).unwrap(); + let estimate = Estimate::new(); + + // estimate is empty, local has a single value. Cannot calculate a period with a single datapoint + let result = Ntp::calculate_uncorrected_clock(&local, &estimate); + assert!(result.is_none()); + } + + #[test] + fn calculate_uncorrected_local_has_two_values() { + // Create 2 points that are 1 second apart and the TSC is roughly 1 GHz + let mut local = Local::new(NonZeroUsize::new(2).unwrap()); + let server_send_time = Instant::from_days(1) + Duration::from_nanos(900); + let event1 = event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + Duration::from_nanos(100), + server_send_time, + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + let event2 = event::Ntp::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + + Duration::from_secs(1) + + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(1) + + Duration::from_nanos(900), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + local.feed(event1.clone()).unwrap(); + local.feed(event2.clone()).unwrap(); + let estimate = Estimate::new(); + + let result = Ntp::calculate_uncorrected_clock(&local, &estimate).unwrap(); + + approx::assert_abs_diff_eq!(result.p_estimate.get(), 1e-9); + let expected = server_send_time - Duration::from_secs(1) - Duration::from_micros(1); // account for tsc_post time of 1_000_001_000 at 1GHz period + assert_eq!(result.k, expected,); + } + + #[test] + fn calculate_uncorrected_estimate_has_values() { + // Create 2 points that are 1 second apart and the TSC is roughly 1 GHz + let mut local = Local::new(NonZeroUsize::new(2).unwrap()); + let local_event = event::Ntp::builder() + .tsc_pre(TscCount::new(2_000_000_000_000)) + .tsc_post(TscCount::new(2_000_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + + Duration::from_secs(1999) + + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(1999) + + Duration::from_nanos(900), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + local.feed(local_event).unwrap(); + + let estimate_event_1 = event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + Duration::from_nanos(900), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + // larger RTT + let estimate_event_2 = event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000_000)) + .tsc_post(TscCount::new(1_000_000_002_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + + Duration::from_secs(1000) + + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(1000) + + Duration::from_nanos(100), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + let mut estimate_inner = RingBuffer::new(NonZeroUsize::new(5).unwrap()); + estimate_inner.push(estimate_event_1); + estimate_inner.push(estimate_event_2); + + let estimate = Estimate::builder().inner(estimate_inner).build(); + + let result = Ntp::calculate_uncorrected_clock(&local, &estimate).unwrap(); + + approx::assert_abs_diff_eq!(result.p_estimate.get(), 1e-9); + assert_eq!( + result.k, + Instant::from_days(1) + Duration::from_nanos(900) + - Duration::from_secs(1) + - Duration::from_micros(1) + ); + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/uncorrected_clock.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/uncorrected_clock.rs new file mode 100644 index 0000000..8eabb66 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/uncorrected_clock.rs @@ -0,0 +1,16 @@ +//! A naive uncorrected clock + +use crate::daemon::time::{Instant, tsc::Period}; + +/// A naive uncorrected clock +/// +/// `C_u(t) = TSC(t) * p_estimate + K`, where +/// - `C_u(t)` is the uncorrected clock +/// - `TSC(t)` is the TSC value at time t +/// - `p_estimate` is the estimated clock period +/// - `K` is a constant offset of the system epoch. In other words, the calculated time at TSC(0) +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct UncorrectedClock { + pub p_estimate: Period, + pub k: Instant, +} From 77d0002558731ab33191553fde40d94282bee7fa Mon Sep 17 00:00:00 2001 From: tphan25 Date: Wed, 29 Oct 2025 11:30:32 -0400 Subject: [PATCH 052/177] Return `Timex` on calls and allow retrieving the time (#59) `Timex` info returned from kernel can be useful for the caller, e.g. to see the approx time at which the call was made in the kernel without another `clock_gettime` call. This commit implements the ability to retrieve this time, as an `Instant`. --- clock-bound/src/daemon/clock_state.rs | 8 +- clock-bound/src/daemon/time/timex.rs | 114 +++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index c2a423c..583fe93 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -66,7 +66,7 @@ impl ClockAdjuster { &self, phase_correction: Duration, skew: Skew, - ) -> Result<(), NtpAdjTimeError> { + ) -> Result { let mut tx = Timex::clock_adjustment() .phase_correction(phase_correction) .skew(skew) @@ -74,7 +74,7 @@ impl ClockAdjuster { debug!("calling ntp_adjtime with {:?}", tx); match self.ntp_adjtime.ntp_adjtime(&mut tx) { - TIME_OK => Ok(()), + TIME_OK => Ok(tx), cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { Err(NtpAdjTimeError::BadState(cs)) } @@ -89,7 +89,7 @@ impl ClockAdjuster { /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno /// `NtpAdjTimeError::BadState` if some state other than `TIME_ERROR` is returned from `ntp_adjtime` /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` - pub fn step_clock(&self, phase_correction: Duration) -> Result<(), NtpAdjTimeError> { + pub fn step_clock(&self, phase_correction: Duration) -> Result { let mut tx = Timex::clock_step() .phase_correction(phase_correction) .call(); @@ -99,7 +99,7 @@ impl ClockAdjuster { // that indicates the clock is now "unsynchronized" (expected after we step the clock // discontinuously) match self.ntp_adjtime.ntp_adjtime(&mut tx) { - TIME_ERROR => Ok(()), + TIME_ERROR => Ok(tx), cs @ (TIME_OK | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { Err(NtpAdjTimeError::BadState(cs)) } diff --git a/clock-bound/src/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs index b4f92ba..cbf5e1f 100644 --- a/clock-bound/src/daemon/time/timex.rs +++ b/clock-bound/src/daemon/time/timex.rs @@ -5,11 +5,11 @@ use bon::bon; use libc::timeval; use libc::{ ADJ_SETOFFSET, MOD_FREQUENCY, MOD_NANO, MOD_OFFSET, MOD_STATUS, MOD_TIMECONST, STA_FREQHOLD, - STA_PLL, timex, + STA_NANO, STA_PLL, timex, }; use tracing::warn; -use crate::daemon::time::{Duration, tsc::Skew}; +use crate::daemon::time::{Duration, Instant, tsc::Skew}; const MAX_PHASE_OFFSET: Duration = Duration::from_millis(500); const MAX_SKEW: Skew = Skew::from_ppm(512.0); @@ -26,6 +26,29 @@ impl Timex { &raw mut self.0 } + /// Read the given `time` from the underlying `timex`. The kernel + /// generally returns this with the time set to the current `CLOCK_REALTIME` + /// reading. + /// + /// Notably, this same field is used in `ADJ_SETOFFSET` mode calls in order to supply an + /// offset value. + /// + /// The value may be expressed in microseconds by default, but if the call is made + /// with `status` bit `STA_NANO` set, `tv_usec` represents a nanosecond value. + /// + /// Arguments + /// * `is_sta_nano` - whether the `tv_usec` should be interpreted as a microsecond or nanosecond + /// value, based on the `status` bit `STA_NANO` in the `timex` struct. + pub fn time(&self) -> Instant { + let tv = self.0.time; + let fractional_part = if self.0.status & STA_NANO > 0 { + Duration::from_nanos(tv.tv_usec.into()) + } else { + Duration::from_micros(tv.tv_usec.into()) + }; + Instant::from_secs(tv.tv_sec.into()) + fractional_part + } + /// Builds a `libc::timex` used for adjustment of the system clock, to apply the given phase correction /// and skew values, in a single system call. /// @@ -201,6 +224,7 @@ impl AsRef for Timex { #[cfg(test)] mod test { + use libc::{STA_NANO, timeval}; use rstest::rstest; use super::*; @@ -287,4 +311,90 @@ mod test { // assert modes is set properly for our adjustment assert_eq!(tx.modes, ADJ_SETOFFSET | MOD_NANO); } + + // Helper function to create a Timex with custom time and status values + fn create_timex_with_time(tv_sec: i64, tv_usec: i64, status: i32) -> Timex { + Timex(timex { + modes: 0, + offset: 0, + freq: 0, + maxerror: 0, + esterror: 0, + status, + constant: 0, + precision: 0, + tolerance: 0, + #[cfg(not(target_os = "macos"))] + time: timeval { tv_sec, tv_usec }, + #[cfg(not(target_os = "macos"))] + tick: 0, + ppsfreq: 0, + jitter: 0, + shift: 0, + stabil: 0, + jitcnt: 0, + calcnt: 0, + errcnt: 0, + stbcnt: 0, + #[cfg(not(target_os = "macos"))] + tai: 0, + #[cfg(not(target_os = "macos"))] + __unused1: 0, + #[cfg(not(target_os = "macos"))] + __unused2: 0, + #[cfg(not(target_os = "macos"))] + __unused3: 0, + #[cfg(not(target_os = "macos"))] + __unused4: 0, + #[cfg(not(target_os = "macos"))] + __unused5: 0, + #[cfg(not(target_os = "macos"))] + __unused6: 0, + #[cfg(not(target_os = "macos"))] + __unused7: 0, + #[cfg(not(target_os = "macos"))] + __unused8: 0, + #[cfg(not(target_os = "macos"))] + __unused9: 0, + #[cfg(not(target_os = "macos"))] + __unused10: 0, + #[cfg(not(target_os = "macos"))] + __unused11: 0, + }) + } + + #[test] + fn test_get_time_microsecond_precision_without_sta_nano() { + // Test that without STA_NANO flag, tv_usec is interpreted as microseconds + let timex = create_timex_with_time(1, 123_456, 0); + let result = timex.time(); + let expected = Instant::from_secs(1) + Duration::from_micros(123_456); + assert_eq!(result, expected); + } + + #[test] + fn test_get_time_nanosecond_precision_with_sta_nano() { + // Test that with STA_NANO flag, tv_usec is interpreted as nanoseconds + let timex = create_timex_with_time(1, 123_456_789, STA_NANO); + let result = timex.time(); + let expected = Instant::from_secs(1) + Duration::from_nanos(123_456_789); + assert_eq!(result, expected); + } + + #[test] + fn test_get_time_zero_values() { + // Test epoch time (zero values) + let timex = create_timex_with_time(0, 0, 0); + let result = timex.time(); + assert_eq!(result, Instant::UNIX_EPOCH); + } + + #[test] + fn test_get_time_negative_seconds() { + // Test negative seconds (before epoch) + let timex = create_timex_with_time(-10, 500_000, 0); + let result = timex.time(); + let expected = Instant::from_secs(-10) + Duration::from_micros(500_000); + assert_eq!(result, expected); + } } From aea87c258037a259596e1480e8186c2b6572f1df Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 29 Oct 2025 16:52:11 -0400 Subject: [PATCH 053/177] [ff] Add theta calculation (#72) --- .../ff/event_buffer/local.rs | 4 + .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 220 +++++++++++++++++- .../ff/uncorrected_clock.rs | 9 +- clock-bound/src/daemon/event/ntp.rs | 113 ++++++++- clock-bound/src/daemon/time/tsc.rs | 8 + 5 files changed, 349 insertions(+), 5 deletions(-) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs index 214d90b..0348619 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs @@ -74,6 +74,10 @@ impl Local { pub fn is_empty(&self) -> bool { self.inner.is_empty() } + + pub fn rtt_threshold_multiplier(&self) -> usize { + self.rtt_threshold_multiplier + } } impl Local { diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 000d5fc..7d7c1b2 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -6,9 +6,11 @@ use super::event_buffer; use crate::daemon::{ clock_parameters::ClockParameters, clock_sync_algorithm::{ff::UncorrectedClock, ring_buffer::Quarter}, - event, - event::TscRtt, - time::{TscDiff, tsc::Period}, + event::{self, TscRtt}, + time::{ + Duration, TscDiff, + tsc::{Period, Skew}, + }, }; /// Feed forward time synchronization algorithm for a single NTP source @@ -196,6 +198,86 @@ impl Ntp { error, }) } + + /// Calculate the theta value, which is the time correction to be applied + /// + /// The theta corresponds to the equation below + /// + /// `C(t) = TSC(t) × p^ + K − θ^(t)` where: + /// - `C(t)` is the absolute time. Corrected. + /// - `TSC(t)` is the tsc reading at a time + /// - `p^` is the estimation of the clock period + /// - `K` is the "epoch" (the uncorrected time at `TSC(0)`) + /// - `θ^(t)` is the time correction + /// + /// # Panics + /// Panics if `Local` is empty + #[expect( + clippy::cast_precision_loss, + reason = "exp and weight require floats. Values are small enough to not lose precision" + )] + fn calculate_theta( + local: &event_buffer::Local, + period_local: Period, + uncorrected_clock: UncorrectedClock, + ) -> CalculateThetaOutput { + // Feed-forward time synchronization algorithm's error normalization factor. + // This constant is used to penalize the feed_forward_samples that have a + // slower reference clock read duration. + const ERROR_NORMALIZATION_FACTOR: f64 = 1e5; + + assert!(!local.is_empty()); + + let now_post = local.as_ref().head().unwrap().tsc_post(); + let skew = Skew::from_ratio(period_local, uncorrected_clock.p_estimate); + let mut numerator = 0.0; + let mut denominator = 0.0; + + let min_event = local.as_ref().min_rtt().unwrap(); + + let mut max_ceb = Duration::from_secs(0); + + for event in local.iter() { + if event.rtt() > (min_event.rtt() * local.rtt_threshold_multiplier()) { + tracing::trace!(?event, ?min_event, "skipping event due to rtt threshold"); + continue; + } + // Use the worst CEB in calculation as the algorithm's CEB + max_ceb = std::cmp::max(max_ceb, event.calculate_clock_error_bound(period_local)); + + // calculate midpoints on client and server side + + let offset = event.calculate_offset(uncorrected_clock); + + // estimate error based off of TSC rtt + let sample_error = event.rtt() - min_event.rtt(); + assert!(sample_error.get() >= 0, "cannot have a negative error"); + + // weight is e^(-sqrt(Error_{i}/E)) + // escaping into f64 to minimize rounding error. Sticking with nanoseconds as the base unit + // NOTE/FIXME: is there a world where we take into account either age of the event or the + // clock_error_bound? + let weight = -((sample_error.get() as f64 / ERROR_NORMALIZATION_FACTOR).sqrt()); + let weight = weight.exp(); + + let offset_nsec = offset.as_seconds_f64() * 1e9; + + let skew_correction_seconds = skew.get() + * uncorrected_clock.p_estimate.get() + * ((now_post - event.tsc_post()).get() as f64); + let skew_correction_nsec = skew_correction_seconds * 1e9; + numerator += weight * (offset_nsec + skew_correction_nsec); + denominator += weight; + } + + let theta_nsec = numerator / denominator; + let theta = Duration::from_seconds_f64(theta_nsec / 1e9); + tracing::debug!(?theta, ?uncorrected_clock, %period_local, "Calculated theta"); + CalculateThetaOutput { + theta, + clock_error_bound: max_ceb, + } + } } /// Used as the output for [`Ntp::calculate_local_period_and_error`] @@ -206,6 +288,14 @@ struct LocalPeriodAndError { error: Period, } +/// Output from [`Ntp::calculate_theta`] +struct CalculateThetaOutput { + /// The time correction to be applied + theta: Duration, + /// The worst clock error bound used in calculation + clock_error_bound: Duration, +} + #[cfg(test)] mod tests { use crate::daemon::{ @@ -435,4 +525,128 @@ mod tests { - Duration::from_micros(1) ); } + + #[test] + fn theta_naive() { + // Naive test case, estimate and local periods are the same + // clock rates 1GHz + // epoch is 1 day after 1970 new years + + let uncorrected_clock = UncorrectedClock { + k: Instant::from_days(1), + p_estimate: Period::from_seconds(1e-9), + }; + let local_period = Period::from_seconds(1e-9); + + let mut local = Local::new(NonZeroUsize::new(2).unwrap()); + let event1 = event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + + Duration::from_secs(1) + + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(1) + + Duration::from_nanos(900), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + let event2 = event::Ntp::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + + Duration::from_secs(2) + + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(2) + + Duration::from_nanos(900), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + local.feed(event1.clone()).unwrap(); + local.feed(event2.clone()).unwrap(); + + let CalculateThetaOutput { + theta, + clock_error_bound, + } = Ntp::calculate_theta(&local, local_period, uncorrected_clock); + + assert_eq!(theta, Duration::from_secs(0)); + assert_eq!( + clock_error_bound, + event1.calculate_clock_error_bound(local_period) + ); + } + + #[test] + fn theta() { + // Less naive test case, estimate and local periods are 10 PPM off + // estimate clock rates 1GHz + // epoch is 1 day after 1970 new years + + let uncorrected_clock = UncorrectedClock { + k: Instant::from_days(1), + p_estimate: Period::from_seconds(1e-9), + }; + let local_period = Period::from_seconds(uncorrected_clock.p_estimate.get() * 1.000_010); + + let mut local = Local::new(NonZeroUsize::new(2).unwrap()); + let event1 = event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + + Duration::from_secs(1) + + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(1) + + Duration::from_nanos(900), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(27), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + let event2 = event::Ntp::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + + Duration::from_secs(2) + + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(2) + + Duration::from_nanos(900), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + local.feed(event1.clone()).unwrap(); + local.feed(event2.clone()).unwrap(); + + let CalculateThetaOutput { + theta, + clock_error_bound, + } = Ntp::calculate_theta(&local, local_period, uncorrected_clock); + + assert_eq!(theta, Duration::from_micros(-5)); + assert_eq!( + clock_error_bound, + event1.calculate_clock_error_bound(local_period) + ); + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/uncorrected_clock.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/uncorrected_clock.rs index 8eabb66..6e407ae 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/uncorrected_clock.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/uncorrected_clock.rs @@ -1,6 +1,6 @@ //! A naive uncorrected clock -use crate::daemon::time::{Instant, tsc::Period}; +use crate::daemon::time::{Instant, TscCount, tsc::Period}; /// A naive uncorrected clock /// @@ -14,3 +14,10 @@ pub struct UncorrectedClock { pub p_estimate: Period, pub k: Instant, } + +impl UncorrectedClock { + /// Calculate the uncorrected time of a corresponding TSC value + pub fn time_at(&self, tsc: TscCount) -> Instant { + tsc.uncorrected_time(self.p_estimate, self.k) + } +} diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index d051d90..9a4ba6c 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -5,7 +5,10 @@ use std::{ }; use super::TscRtt; -use crate::daemon::time::{Duration, Instant, TscCount, tsc::Period}; +use crate::daemon::{ + clock_sync_algorithm::ff::UncorrectedClock, + time::{Duration, Instant, TscCount, tsc::Period}, +}; /// Contains the NTP and time stamp counter samples to be used by synchronization algorithm. /// @@ -97,6 +100,23 @@ impl Ntp { let root_delay = self.data().root_delay + rtt; self.data().root_dispersion + (root_delay / 2) } + + /// Calculate offset using the uncorrected clock + /// + /// Offset is positive if the client is ahead of the server + pub fn calculate_offset(&self, uncorrected_clock: UncorrectedClock) -> Duration { + // calculate midpoints on client and server side + let client_send_time = uncorrected_clock.time_at(self.tsc_pre()); + let client_recv_time = uncorrected_clock.time_at(self.tsc_post()); + let client_midpoint = client_send_time.midpoint(client_recv_time); + + let server = self.data(); + let server_midpoint = server.server_recv_time.midpoint(server.server_send_time); + + // calculate the uncorrected offset from the reference clock + // offset is positive if local clock is ahead of server + client_midpoint - server_midpoint + } } impl TscRtt for Ntp { @@ -477,4 +497,95 @@ mod tests { epsilon = 1e-9 ); } + + // Helper function to create an UncorrectedClock with specific parameters + fn create_uncorrected_clock(k: Instant, p_estimate: Period) -> UncorrectedClock { + UncorrectedClock { k, p_estimate } + } + + #[rstest] + #[case::client_ahead( + // Test case where client is ahead of server + Ntp::builder() + .tsc_pre(TscCount::new(1000)) + .tsc_post(TscCount::new(2000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_secs(10), + server_send_time: Instant::from_secs(11), + root_delay: Duration::from_secs(0), + root_dispersion: Duration::from_secs(0), + stratum: Stratum::ONE, + }) + .build() + .unwrap(), + create_uncorrected_clock( + Instant::from_secs(0), + Period::from_seconds(0.02) // 20ms per tick + ), + Duration::from_seconds_f64(19.5) // Expected positive offset + )] + #[case::client_behind( + // Test case where client is behind server + Ntp::builder() + .tsc_pre(TscCount::new(1000)) + .tsc_post(TscCount::new(2000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_secs(50), + server_send_time: Instant::from_secs(51), + root_delay: Duration::from_secs(0), + root_dispersion: Duration::from_secs(0), + stratum: Stratum::ONE, + }) + .build() + .unwrap(), + create_uncorrected_clock( + Instant::from_secs(0), + Period::from_seconds(0.02) // 20ms per tick + ), + Duration::from_seconds_f64(-20.5) // Expected negative offset + )] + #[case::zero_offset( + // Test case where client and server are synchronized + Ntp::builder() + .tsc_pre(TscCount::new(1000)) + .tsc_post(TscCount::new(2000)) + .ntp_data(NtpData { + server_recv_time: Instant::from_secs(20), + server_send_time: Instant::from_secs(30), + root_delay: Duration::from_secs(0), + root_dispersion: Duration::from_secs(0), + stratum: Stratum::ONE, + }) + .build() + .unwrap(), + create_uncorrected_clock( + Instant::from_secs(10), + Period::from_seconds(0.01) // 10ms per tick + ), + Duration::from_secs(0) // Expected zero offset + )] + fn calculate_offset( + #[case] ntp_event: Ntp, + #[case] uncorrected_clock: UncorrectedClock, + #[case] expected_offset: Duration, + ) { + let client_midpoint = ntp_event.tsc_pre.midpoint(ntp_event.tsc_post); + println!( + "tsc_pre: {:?}", + uncorrected_clock.time_at(ntp_event.tsc_pre) + ); + println!( + "tsc_post: {:?}", + uncorrected_clock.time_at(ntp_event.tsc_post) + ); + let client_midpoint = uncorrected_clock.time_at(client_midpoint); + println!("client_midpoint: {client_midpoint:?}"); + let offset = ntp_event.calculate_offset(uncorrected_clock); + + approx::assert_abs_diff_eq!( + offset.as_seconds_f64(), + expected_offset.as_seconds_f64(), + epsilon = 1e-9 + ); + } } diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 754e60c..4b47798 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -271,6 +271,14 @@ impl Skew { self.0 } + /// Calculate skew from 2 clocks + /// + /// equivalent to `1 - (num / den)` + pub fn from_ratio(num: Period, den: Period) -> Self { + let ratio = num.get() / den.get(); + Self(1.0 - ratio) + } + /// In struct timex, freq, ppsfreq, and stabil are ppm (parts per /// million) with a 16-bit fractional part, which means that a value /// of 1 in one of those fields actually means 2^-16 ppm, and From 168875bf75032819655a184a4b2d1aa699f3b2b8 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 30 Oct 2025 09:50:34 -0400 Subject: [PATCH 054/177] [ClockSyncAlgorithm] handle_disruption (#62) --- .../src/daemon/clock_sync_algorithm.rs | 22 ++++++++++-- .../ff/event_buffer/estimate.rs | 35 ++++++++++++++++++- .../ff/event_buffer/local.rs | 35 ++++++++++++++++++- .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 20 ++++++++++- .../clock_sync_algorithm/ring_buffer.rs | 2 +- .../clock_sync_algorithm/source/link_local.rs | 12 ++++++- 6 files changed, 119 insertions(+), 7 deletions(-) diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 5c2dd68..0c51577 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -1,5 +1,8 @@ //! Feed forward clock sync algorithm -#![expect(dead_code, reason = "remove when RoutableEvent is added")] +#![cfg_attr( + not(test), + expect(dead_code, reason = "remove when RoutableEvent is added") +)] pub mod ff; @@ -22,7 +25,7 @@ pub mod source; /// /// # Usage /// TODO -#[derive(Debug, Clone, bon::Builder)] +#[derive(Debug, Clone, PartialEq, bon::Builder)] pub struct ClockSyncAlgorithm { /// The link-local reference clock's ff algorithm link_local: source::LinkLocal, @@ -34,4 +37,19 @@ impl ClockSyncAlgorithm { pub fn feed_link_local(&mut self, event: event::Ntp) -> Option { self.link_local.feed(event) } + + /// Handle a clock disruption event + /// + /// Call this function after the system detects a VMClock disruption event. + /// + /// It will go through and clear the state (like startup). + pub fn handle_disruption(&mut self) { + // Use the destructure pattern to get a mutable reference to each item. + // + // This makes it a compilation error if we add a new field this Self without handling it here + let Self { link_local } = self; + + link_local.handle_disruption(); + tracing::info!("Handled clock disruption event"); + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs index 420a7f1..fa4a874 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs @@ -48,7 +48,7 @@ use crate::daemon::{ /// # Starvation /// What happens if there are NO good data points for an SKM? That's an issue for [`Local`] to handle. But windows without /// anything in Local will just early exit, as there is nothing to feed into [`Estimate`] -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(test, derive(bon::Builder))] // Workaround the strict invariants that make testing un-ergonomic pub struct Estimate { /// The inner storage @@ -98,6 +98,16 @@ impl Estimate { self.last_tsc_post = Some(now_tsc_post); self.inner.push(event); } + + /// Clear the internal buffer + pub fn handle_disruption(&mut self) { + let Self { + inner, + last_tsc_post, + } = self; + inner.clear(); + *last_tsc_post = None; + } } impl Estimate { @@ -400,4 +410,27 @@ mod tests { assert_eq!(event.tsc_post(), events[i].tsc_post()); } } + + #[test] + fn handle_disruption() { + let mut estimate: Estimate = Estimate::new(); + + let events = vec![ + TestEvent::pre_and_rtt(1000, 100), + TestEvent::pre_and_rtt(2000, 90), + TestEvent::pre_and_rtt(3000, 80), + ]; + + for event in &events { + estimate.push(event.clone(), event.tsc_post()); + } + + assert_eq!(estimate.len(), 3); + assert!(estimate.last_tsc_post.is_some()); + + estimate.handle_disruption(); + + assert_eq!(estimate.len(), 0); + assert!(estimate.last_tsc_post.is_none()); + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs index 0348619..d1f63b9 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs @@ -27,7 +27,7 @@ use crate::daemon::{ /// /// However, this rule **can** cause starvation if the RTT is extremely noisy. That's why the threshold can be tuned /// or even dynamically modified. Changes subject to experimentation. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Local { /// The inner storage inner: RingBuffer, @@ -75,6 +75,15 @@ impl Local { self.inner.is_empty() } + /// Clear the internal buffer + pub fn handle_disruption(&mut self) { + let Self { + inner, + rtt_threshold_multiplier: _rtt, + } = self; + inner.clear(); + } + pub fn rtt_threshold_multiplier(&self) -> usize { self.rtt_threshold_multiplier } @@ -235,4 +244,28 @@ mod tests { assert_eq!(event.tsc_pre().get(), ((i + 2) * 100) as i128); } } + + #[test] + fn handle_disruption() { + let mut local = Local::new(NonZeroUsize::new(3).unwrap()); + + let events = vec![ + TestEvent::pre_and_rtt(1000, 100), + TestEvent::pre_and_rtt(2000, 90), + TestEvent::pre_and_rtt(3000, 80), + ]; + + for event in &events { + local.feed(event.clone()).unwrap(); + } + + let thresh = local.rtt_threshold_multiplier; + + assert_eq!(local.inner.len(), 3); + + local.handle_disruption(); + + assert_eq!(local.inner.len(), 0); + assert_eq!(local.rtt_threshold_multiplier, thresh); // unchanged + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 7d7c1b2..7df07b4 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -14,7 +14,7 @@ use crate::daemon::{ }; /// Feed forward time synchronization algorithm for a single NTP source -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Ntp { /// Events within the current SKM (within 1024 seconds) local: event_buffer::Local, @@ -56,6 +56,24 @@ impl Ntp { self.clock_parameters.as_ref() } + /// Handle a disruption event + /// + /// Clears all event buffers and prior-calculations. + pub fn handle_disruption(&mut self) { + // Destructure pattern makes handling new fields mandatory + let Self { + local, + estimate, + clock_parameters, + period_estimate, + } = self; + + local.handle_disruption(); + estimate.handle_disruption(); + *clock_parameters = None; + *period_estimate = None; + } + /// Calculate the estimate period and k value based off of the ring buffers /// /// Returns `None` if we do not have enough data points to calculate a period. diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs index df080cb..985fc9e 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs @@ -12,7 +12,7 @@ use crate::daemon::{event::TscRtt, time::TscCount}; /// Uses `head` and `tail` terminology. The head is where the most recent values /// are added, and `tail` is where the oldest values are. Values are added to the head /// via [`RingBuffer::push`]. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RingBuffer { buffer: VecDeque, capacity: usize, diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs index 4891127..697c4ee 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs @@ -10,7 +10,7 @@ use crate::daemon::time::Duration; /// A Link Local reference clock source /// /// Wraps around an NTP feed-forward clock-sync algorithm -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct LinkLocal { inner: ff::Ntp, } @@ -39,6 +39,16 @@ impl LinkLocal { pub fn feed(&mut self, event: event::Ntp) -> Option { self.inner.feed(event) } + + /// Handle a disruption event. + /// + /// This clears the internal `ff` and any other state related to the local system's hardware + pub fn handle_disruption(&mut self) { + // Destructure pattern makes handling new fields mandatory + let Self { inner } = self; + + inner.handle_disruption(); + } } impl Default for LinkLocal { From 249e0d72ffa87890e1d469b3b487885d8c7f22b1 Mon Sep 17 00:00:00 2001 From: Myles N <95256483+nelomsmn@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:23:53 -0400 Subject: [PATCH 055/177] Adding the ReceiverStream struct (functionality for aggregating the results of multiple IO sources) (#42) * Creating ReceiverStream struct with functionality to combine multiple async_ring_buffer receiver results into a single stream * Adding integ tests for ReceiverStream. Also includes some formatting changes * Revision 1: - Modified receiver stream to produce a stream of event::Event instead of specifically event::Event::Ntp, to lay groundwork for delivery of other event types. - added builder for ReceiverStream, "link_local_receiver" required on initialization. - Renamed method 'ntp_recv' to 'recv' as it is now capable of receiving any event::Event type objects - Created EventStream type to avoid complex typing error from clippy. - moved stream aggregation to a separate function to improve readability. * Revision 2: - Removing integ test dir for Receive stream - Updated logging in ReceiverStream.recv() function to be accurate - typo fix - Updated comment on result stream fairness --- Cargo.lock | 99 ++++++++++- Cargo.toml | 1 + clock-bound/Cargo.toml | 2 + clock-bound/src/daemon.rs | 2 + clock-bound/src/daemon/event.rs | 1 + clock-bound/src/daemon/receiver_stream.rs | 204 ++++++++++++++++++++++ 6 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 clock-bound/src/daemon/receiver_stream.rs diff --git a/Cargo.lock b/Cargo.lock index c97a280..8dbfc4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,11 +261,13 @@ dependencies = [ "bytes", "chrono", "errno", + "futures", "hex-literal", "libc", "mockall", "nix", "nom", + "rand 0.9.2", "rstest 0.26.1", "serde", "tempfile", @@ -307,8 +309,8 @@ dependencies = [ "mockall", "nalgebra", "num-traits", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "rstest 0.25.0", "serde", "serde_json", @@ -449,12 +451,54 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -466,6 +510,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -484,9 +534,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -761,7 +815,7 @@ dependencies = [ "num-complex", "num-rational", "num-traits", - "rand", + "rand 0.8.5", "rand_distr", "simba", "typenum", @@ -991,8 +1045,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1002,7 +1066,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1014,6 +1088,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_distr" version = "0.4.3" @@ -1021,7 +1104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -1281,7 +1364,7 @@ dependencies = [ "approx", "nalgebra", "num-traits", - "rand", + "rand 0.8.5", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bcb2797..0da15f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,4 @@ keywords = ["aws", "ntp", "ec2", "time"] publish = false repository = "https://github.com/aws/clock-bound" version = "2.0.3" + diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 19772e6..2e42382 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -36,6 +36,8 @@ tokio = { version = "1.47.1", features = [ ], optional = true } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } +futures = "0.3" +rand = "0.9.2" [dev-dependencies] approx = "0.5" diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index d656571..e4a3ef2 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -14,6 +14,8 @@ pub mod time; pub mod event; +pub mod receiver_stream; + use crate::daemon::clock_sync_algorithm::ClockSyncAlgorithm; pub struct Daemon { diff --git a/clock-bound/src/daemon/event.rs b/clock-bound/src/daemon/event.rs index b24d393..5a9b6da 100644 --- a/clock-bound/src/daemon/event.rs +++ b/clock-bound/src/daemon/event.rs @@ -10,6 +10,7 @@ use crate::daemon::time::{TscCount, TscDiff}; pub enum Event { /// NTP Event Ntp(Ntp), + Phc, } /// Simple abstraction around types that have a TSC read before and after reference clock reads diff --git a/clock-bound/src/daemon/receiver_stream.rs b/clock-bound/src/daemon/receiver_stream.rs new file mode 100644 index 0000000..810b7d7 --- /dev/null +++ b/clock-bound/src/daemon/receiver_stream.rs @@ -0,0 +1,204 @@ +//! Receive stream +//! +//! This module implements the logic needed to retrieve `event::Event` objects from provisioned IO components +//! and return `RoutableEvent` objects. + +use futures::StreamExt; +use futures::{ + Stream, + stream::{SelectAll, once, select_all}, +}; +use rand::seq::SliceRandom; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::pin::Pin; +use thiserror::Error; +use tracing::info; + +use crate::daemon::async_ring_buffer::{BufferClosedError, Receiver}; +use crate::daemon::event::{Event, Ntp}; + +#[derive(Debug, Error)] +pub enum ReceiverStreamError { + #[error("Failed to initialize ReceiverStream.")] + InitError(String), +} + +/// Type to hold the stream produced from each `SourceIO` components receiver +type EventStream<'a> = + Pin)> + 'a>>; + +pub struct ReceiverStream { + ntp_sources: HashMap>, + link_local: Receiver, +} + +#[bon::bon] +impl ReceiverStream { + /// Initializer for `ReceiverStream` struct + #[builder] + pub fn new( + link_local_receiver: Receiver, + ntp_source_vec: Vec<(SocketAddr, Receiver)>, + ) -> Self { + let mut rs = ReceiverStream { + ntp_sources: HashMap::new(), + link_local: link_local_receiver, + }; + for (source_ip, source_receiver) in ntp_source_vec { + rs.add_ntp_source(source_ip, source_receiver); + } + + rs + } +} + +/// `ReceiverStream` provides methods for aggregating the events delivered +/// to ring buffers associated to separate `SourceIO` time sources. +impl ReceiverStream { + /// Adds a new ntp source to the `ntp_sources` + fn add_ntp_source(&mut self, id: SocketAddr, receiver: Receiver) { + let _ = &self.ntp_sources.insert(SourceId::NTPSource(id), receiver); + } + + /// Creates an aggregated stream of results from all `SourceIO` component `Receivers`. + fn get_aggregate_stream(&mut self) -> SelectAll> { + let mut streams: Vec> = Vec::new(); + + // Add NTP source streams + for (source_id, source_receiver) in &mut self.ntp_sources { + let source_id = *source_id; + streams.push(Box::pin( + once(source_receiver.recv()).map(move |result| (source_id, result.map(Event::Ntp))), + )); + } + + // Add the link_local receiver to the streams + streams.push(Box::pin( + once(self.link_local.recv()) + .map(|result| (SourceId::LinkLocal, result.map(Event::Ntp))), + )); + + // Shuffles vector to avoid unfair treatment of any preloaded events. + // Context: Without this shuffle, if events are loaded into the buffer before `recv` is called, + // those events will be returned in the order that their relative stream is added to the `streams` vector. + // Ex: In the "receiver_stream_test()" function the `RoutableEvent::LinkLocalEvent` will always be received second, + // although it was sent by through it's relative buffer first) + // + // In the case that an actor produces events faster than we can poll, + // not considering fairness when retrieving events opens the door for starvation. + // In the future, we may implement more robust logic to consider fairness with event delivery. + let mut rng = rand::rng(); + streams.shuffle(&mut rng); + + select_all(streams) + } + + /// Creates an aggregate stream containing all results from `SourceIO` component `Receiver`s tracked by the struct + /// + /// # Returns + /// a `RoutableEvent` wrapping the first event returned by the aggregate stream. + pub async fn recv(&mut self) -> Option { + let mut result_stream = self.get_aggregate_stream(); + // Handle first result from the stream + let Some((source_id, event_result)) = result_stream.next().await else { + info!("Aggregate stream is empty, no futures to await"); + return None; + }; + + match event_result { + Ok(Event::Ntp(ntp_event)) => match source_id { + SourceId::LinkLocal => Some(RoutableEvent::LinkLocalEvent(ntp_event)), + SourceId::NTPSource(id) => Some(RoutableEvent::NTPSourceEvent(id, ntp_event)), + }, + Ok(Event::Phc) => { + // FIXME: returned event should house data delivered from the PHC read + todo!("Implement PHC IO source and data struct"); + } + Err(_) => { + todo!( + "Implement logic for buffers closing. We do not expect this to happen as a part of the alpha release implementation" + ); + } + } + } +} + +#[derive(Debug, Hash, PartialEq, std::cmp::Eq, Clone, Copy)] +enum SourceId { + LinkLocal, + NTPSource(SocketAddr), +} + +#[derive(Debug, PartialEq)] +pub enum RoutableEvent { + LinkLocalEvent(Ntp), + NTPSourceEvent(SocketAddr, Ntp), + // FIXME: The PhcEvent should wrap around phc data. + // The phc data struct has yet to be implemented. Below enum is a placeholder + PhcEvent, +} + +#[tokio::test] +async fn receiver_stream_test() { + use std::net::{IpAddr, Ipv4Addr}; + + use crate::daemon::async_ring_buffer::create; + use crate::daemon::event::{NtpData, Stratum}; + use crate::daemon::time::{Duration, Instant, TscCount}; + + let (link_local_tx, link_local_rx) = create(1); + + let (ntp_source_tx, ntp_source_rx) = create(1); + let dummy_ntp_source_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 123); + + let mut rx_stream = ReceiverStream::builder() + .link_local_receiver(link_local_rx) + .ntp_source_vec(vec![(dummy_ntp_source_ip, ntp_source_rx)]) + .build(); + + let dummy_ntp_data = Ntp::builder() + .tsc_pre(TscCount::new(1)) + .tsc_post(TscCount::new(2)) + .ntp_data(NtpData { + server_recv_time: Instant::new(1), + server_send_time: Instant::new(2), + root_delay: Duration::new(3), + root_dispersion: Duration::new(4), + stratum: Stratum::ONE, + }) + .build() + .unwrap(); + + let _ = link_local_tx.send(dummy_ntp_data.clone()); + let _ = ntp_source_tx.send(dummy_ntp_data.clone()); + let num_events = 2; + let mut counter = 0; + + for _ in 0..num_events { + match rx_stream.recv().await.unwrap() { + RoutableEvent::LinkLocalEvent(data) => { + counter += 1; + assert_eq!( + RoutableEvent::LinkLocalEvent(dummy_ntp_data.clone()), + RoutableEvent::LinkLocalEvent(data) + ); + } + RoutableEvent::NTPSourceEvent(ip, data) => { + counter += 1; + assert_eq!( + RoutableEvent::NTPSourceEvent(dummy_ntp_source_ip, dummy_ntp_data.clone()), + RoutableEvent::NTPSourceEvent(ip, data) + ); + } + RoutableEvent::PhcEvent => { + assert!(false, "Phc event delivery has yet to be implemented") + } + }; + } + assert!( + counter.eq(&num_events), + "{}", + format!("{:#?} :: {:#?}", counter, num_events) + ); +} From 359c1bc4a5804d23fd1f433066bc6bd5edd8463e Mon Sep 17 00:00:00 2001 From: Myles N <95256483+nelomsmn@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:24:36 -0400 Subject: [PATCH 056/177] Code cleanup for changes the NTPSource IO struct. (#64) This commit includes code cleanup for the NTPSource IO struct. Changes include: - Added default public time IPs into ntp constants. - Renamed ntp constants to specify link local and ntp source. - Cleaned up naming to consistently reference "ntp source" in IO element vs "ntp server". - Updates to "ntp-source" integration test directory, to improve logging and readability. - Updated IO source run() methods to avoid explicitly panicking on buffer close. --- .github/workflows/ntp_source.yml | 6 +- clock-bound/src/daemon/io.rs | 5 +- clock-bound/src/daemon/io/link_local.rs | 9 +-- clock-bound/src/daemon/io/ntp.rs | 12 ++-- clock-bound/src/daemon/io/ntp_source.rs | 16 ++--- test/ntp-source/README.md | 93 +++---------------------- test/ntp-source/src/main.rs | 80 ++++++++------------- 7 files changed, 65 insertions(+), 156 deletions(-) diff --git a/.github/workflows/ntp_source.yml b/.github/workflows/ntp_source.yml index 9408622..4f39951 100644 --- a/.github/workflows/ntp_source.yml +++ b/.github/workflows/ntp_source.yml @@ -29,8 +29,8 @@ jobs: name: ntp-source-test path: target/release/ntp-source-test - NTP_Server_Tests: - name: NTP Server tests + NTP_Source_Tests: + name: NTP Source tests needs: build runs-on: - codebuild-StagingClockBound-${{ github.run_id }}-${{ github.run_attempt }} @@ -45,5 +45,5 @@ jobs: - run: ls - run: echo "Change permissions of artifact." - run: chmod 755 ntp-source-test - - run: echo "Run ntp server source test!" + - run: echo "Run ntp source test!" - run: ./ntp-source-test \ No newline at end of file diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 8e3bf16..a16bffd 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -90,7 +90,10 @@ impl SourceIO { server_address: SocketAddr, event_sender: async_ring_buffer::Sender, ) { - info!("Creating custom ntp server IO source."); + info!( + "Creating IO source from ntp server at {:#?}.", + server_address.ip().to_string() + ); if !self.sources.ntp_sources.contains_key(&server_address) { let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index 9b9df3f..d98b2d7 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -17,7 +17,7 @@ use crate::daemon::{ time::tsc::TscCount, }; -use super::ntp::{INTERVAL_DURATION, LINK_LOCAL_ADDRESS, LINK_LOCAL_TIMEOUT, packet}; +use super::ntp::{LINK_LOCAL_ADDRESS, LINK_LOCAL_INTERVAL_DURATION, LINK_LOCAL_TIMEOUT, packet}; use packet::Packet; #[derive(Debug, Error)] @@ -53,7 +53,7 @@ impl LinkLocal { ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, ) -> Self { - let mut link_local_interval = interval(INTERVAL_DURATION); + let mut link_local_interval = interval(LINK_LOCAL_INTERVAL_DURATION); link_local_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); LinkLocal { socket, @@ -129,10 +129,7 @@ impl LinkLocal { match self.sample().await { Err(e) => {debug!(?e, "Failed to sample link local source.");} Ok(ntp_event) => { - if self.event_sender - .send(ntp_event.clone()).is_err() { - unreachable!("Buffer Closing is not expected in alpha.") - } + self.event_sender.send(ntp_event.clone()).expect("Buffer Closing is not expected in alpha."); debug!(?ntp_event, "Successfully sent Link Local IO event."); } diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index 5bd9133..92250d5 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -1,6 +1,6 @@ //! Ntp IO Source constants -use std::net::{Ipv4Addr, SocketAddrV4}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use tokio::time::Duration; pub mod packet; @@ -9,8 +9,12 @@ pub use packet::Packet; pub const UNSPECIFIED_SOCKET_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); pub const LINK_LOCAL_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(169, 254, 169, 123), 123); -pub const INTERVAL_DURATION: Duration = Duration::from_secs(1); +pub const LINK_LOCAL_INTERVAL_DURATION: Duration = Duration::from_secs(1); pub const LINK_LOCAL_TIMEOUT: Duration = Duration::from_millis(100); -pub const NTP_SERVER_INTERVAL_DURATION: Duration = Duration::from_secs(16); -pub const NTP_SERVER_TIMEOUT: Duration = Duration::from_millis(100); +pub const FIRST_PUBLIC_TIME_ADDRESS: std::net::SocketAddr = + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(166, 117, 111, 42)), 123); +pub const SECOND_PUBLIC_TIME_ADDRESS: std::net::SocketAddr = + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(3, 33, 186, 244)), 123); +pub const NTP_SOURCE_INTERVAL_DURATION: Duration = Duration::from_secs(16); +pub const NTP_SOURCE_TIMEOUT: Duration = Duration::from_millis(100); diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 890c424..cff1df8 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -19,7 +19,7 @@ use crate::daemon::{ time::tsc::TscCount, }; -use super::ntp::{NTP_SERVER_INTERVAL_DURATION, NTP_SERVER_TIMEOUT, packet}; +use super::ntp::{NTP_SOURCE_INTERVAL_DURATION, NTP_SOURCE_TIMEOUT, packet}; use packet::Packet; #[derive(Debug, Error)] @@ -59,8 +59,8 @@ impl NTPSource { ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, ) -> Self { - let mut ntp_server_interval = interval(NTP_SERVER_INTERVAL_DURATION); - ntp_server_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + let mut ntp_source_interval = interval(NTP_SOURCE_INTERVAL_DURATION); + ntp_source_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); NTPSource { socket, address, @@ -68,7 +68,7 @@ impl NTPSource { ctrl_receiver, clock_disruption_receiver, ntp_buffer: [0u8; Packet::SIZE], - interval: ntp_server_interval, + interval: ntp_source_interval, } } @@ -88,7 +88,7 @@ impl NTPSource { let sent_timestamp = read_timestamp_counter(); // Request and Receive NTP sample. - let recv_packet_result = timeout(NTP_SERVER_TIMEOUT, { + let recv_packet_result = timeout(NTP_SOURCE_TIMEOUT, { self.socket.send_to(&self.ntp_buffer, self.address).await?; self.socket.recv_from(&mut self.ntp_buffer) }) @@ -134,10 +134,8 @@ impl NTPSource { match self.sample().await { Err(e) => {debug!(?e, "Failed to sample NTP source source.");}, Ok(ntp_event) => { - if self.event_sender - .send(ntp_event.clone()).is_err() { - unreachable!("Buffer Closing is not expected in alpha.") - } + self.event_sender + .send(ntp_event.clone()).expect("Buffer Closing is not expected in alpha."); debug!(?ntp_event, "Successfully sent NTP Source IO event."); } } diff --git a/test/ntp-source/README.md b/test/ntp-source/README.md index 4571131..405b1dc 100644 --- a/test/ntp-source/README.md +++ b/test/ntp-source/README.md @@ -1,9 +1,11 @@ # Test program: link-local-test This directory contains the source code for a test program written to -validate the implementation of the NTPSource runner. The NTPSource +validate the implementation of the `NTPSource` runner. The `NTPSource` runner sends NTP packets to a specified NTP host's IP address. +This test creates two `NTPSource`s using our default public time IPs, and confirms that packets are retrieved from both sources. + ## Prerequisites This program must be run on an instance with internet access @@ -22,92 +24,17 @@ Run the following commands to run the test program. ```sh cd target/release/ -./ntp-server-test +./ntp-source-test ``` The output should look something like the following: ```sh -$ ./ntp-server-test -Lets get a NTP packet! -NTP Server creation complete! -Polling 0 -It looks like we got an ntp packet from 1st Source -Ok( - Ntp { - tsc_pre: Time { - instant: 3299176641588978, - _marker: PhantomData, - }, - tsc_post: Time { - instant: 3299176649716686, - _marker: PhantomData, - }, - data: NtpData { - server_recv_time: Time { - instant: 1761157887289808405000000, - _marker: PhantomData, - }, - server_send_time: Time { - instant: 1761157887289811367000000, - _marker: PhantomData, - }, - root_delay: Diff { - duration: 366210937500, - _marker: PhantomData, - }, - root_dispersion: Diff { - duration: 289916992187, - _marker: PhantomData, - }, - stratum: Level( - ValidStratumLevel( - 4, - ), - ), - }, - }, -) -5 ms -It looks like we got an ntp packet from 2nd Source -Ok( - Ntp { - tsc_pre: Time { - instant: 3299176641709894, - _marker: PhantomData, - }, - tsc_post: Time { - instant: 3299176652154514, - _marker: PhantomData, - }, - data: NtpData { - server_recv_time: Time { - instant: 1761157887290555565000000, - _marker: PhantomData, - }, - server_send_time: Time { - instant: 1761157887290567787000000, - _marker: PhantomData, - }, - root_delay: Diff { - duration: 335693359375, - _marker: PhantomData, - }, - root_dispersion: Diff { - duration: 335693359375, - _marker: PhantomData, - }, - stratum: Level( - ValidStratumLevel( - 4, - ), - ), - }, - }, -) -5 ms -Polling 1 -... -Polling 6 +$ ./ntp-source-test +NTP Source creation complete! +Lets get NTP packets! +Packet received from first NTP host +Packet received from second NTP host +TEST COMPLETE ``` diff --git a/test/ntp-source/src/main.rs b/test/ntp-source/src/main.rs index a2d6c8b..92df6bb 100644 --- a/test/ntp-source/src/main.rs +++ b/test/ntp-source/src/main.rs @@ -3,12 +3,13 @@ //! This executable tests that the NTP Server runner is able to send and receive packets from the //! specified NTP Server address and that the polling rate is roughly once a second. -use clock_bound::daemon::async_ring_buffer; -use clock_bound::daemon::io::SourceIO; +use std::time::Duration; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::time; -use tokio::time::{Duration, timeout}; +use clock_bound::daemon::async_ring_buffer; +use clock_bound::daemon::io::{ + SourceIO, + ntp::{FIRST_PUBLIC_TIME_ADDRESS, SECOND_PUBLIC_TIME_ADDRESS}, +}; use tracing_subscriber::EnvFilter; @@ -18,62 +19,41 @@ async fn main() { .with_env_filter(EnvFilter::from_default_env()) .init(); - println!("Lets get a NTP packet!"); + // Create ring buffer channels for ntp io sources let (first_ntp_source_sender, first_ntp_source_receiver) = async_ring_buffer::create(1); let (second_ntp_source_sender, second_ntp_source_receiver) = async_ring_buffer::create(1); - let mut start = time::Instant::now(); - let mut sourceio = SourceIO::construct(); - // Currently hardcoded with a public NTP server IP - // TODO: Update this IP to new AGA IP after resource provisioning is complete - let first_ntp_source_public_ip = - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(54, 210, 225, 137)), 123); - let second_ntp_source_public_ip = - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(3, 33, 208, 232)), 123); + // Create IO time sources sourceio - .create_ntp_source(first_ntp_source_public_ip, first_ntp_source_sender) + .create_ntp_source(FIRST_PUBLIC_TIME_ADDRESS, first_ntp_source_sender) .await; sourceio - .create_ntp_source(second_ntp_source_public_ip, second_ntp_source_sender) + .create_ntp_source(SECOND_PUBLIC_TIME_ADDRESS, second_ntp_source_sender) .await; - sourceio.spawn_all(); - println!("NTP Server creation complete!"); - - let mut polling_rate = time::Duration::from_secs(0); - for i in 0..6 { - println!("Polling {i}"); + println!("NTP Source creation complete!"); - // Get NTP packet from both specified servers - let ntpevent_a = timeout(Duration::from_secs(32), first_ntp_source_receiver.recv()) - .await - .unwrap(); - let ntpevent_b = timeout(Duration::from_secs(32), second_ntp_source_receiver.recv()) + // Get NTP packet from both specified servers + println!("Lets get NTP packets!"); + sourceio.spawn_all(); + let timeout_err_msg = &format!( + "Timeout was reached before a packet was received from {FIRST_PUBLIC_TIME_ADDRESS:#?}" + ); + let event_result = + tokio::time::timeout(Duration::from_secs(48), first_ntp_source_receiver.recv()) .await - .unwrap(); - - let now = time::Instant::now(); - let d = now - start; - println!( - "It looks like we got an ntp packet from 1st Source \n{ntpevent_a:#?}\n{:?} ms", - d.as_millis() - ); - println!( - "It looks like we got an ntp packet from 2nd Source \n{ntpevent_b:#?}\n{:?} ms", - d.as_millis() - ); + .expect(timeout_err_msg); + event_result.unwrap(); + println!("Packet received from first NTP host"); - // Skip the first sample, the IO runner will poll immediately after it's created. - if i > 0 { - polling_rate += d; - } - - start = now; - } - polling_rate /= 5; - println!("Polling rate avg: {polling_rate:?}"); - assert!( - polling_rate.abs_diff(time::Duration::from_secs(16)) < time::Duration::from_millis(200) + let timeout_err_msg = &format!( + "Timeout was reached before a packet was received from {SECOND_PUBLIC_TIME_ADDRESS:#?}" ); + let event_result = + tokio::time::timeout(Duration::from_secs(48), second_ntp_source_receiver.recv()) + .await + .expect(timeout_err_msg); + event_result.unwrap(); + println!("Packet received from second NTP host\nTEST COMPLETE"); } From 736aaef37c706c8a5d2cd612ba8568793a6f4352 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Thu, 30 Oct 2025 10:54:42 -0400 Subject: [PATCH 057/177] Implement ClockBound clock reads (ClockParameters) and add ReadTsc trait (#73) * Implement clock reads using `ClockBound` clock This commit implements the `ClockBound` clock and its respective `Clock` impl as well. It reads the TSC count at the time the `ClockParameters` were constructed, and the current TSC, and calculates the time by multiplying the delta by the period of the TSC on the ClockParameters. This commit additionally introduces a `ReadTsc` trait, which eases usage of the free functions `read_timestamp_counter` in a mock `MockReadTsc` object for testing's sake. --- clock-bound/src/daemon/io.rs | 2 +- clock-bound/src/daemon/io/tsc.rs | 10 +++ clock-bound/src/daemon/time/clocks.rs | 98 +++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index a16bffd..3aab7ce 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -21,7 +21,7 @@ use link_local::LinkLocal; mod ntp_source; use ntp_source::NTPSource; -mod tsc; +pub mod tsc; mod vmclock; diff --git a/clock-bound/src/daemon/io/tsc.rs b/clock-bound/src/daemon/io/tsc.rs index fd8df69..5cd9926 100644 --- a/clock-bound/src/daemon/io/tsc.rs +++ b/clock-bound/src/daemon/io/tsc.rs @@ -1,4 +1,14 @@ //! Module for reading TSC values. +#[cfg_attr(test, mockall::automock)] +pub trait ReadTsc { + fn read_tsc(&self) -> u64; +} +pub struct ReadTscImpl; +impl ReadTsc for ReadTscImpl { + fn read_tsc(&self) -> u64 { + read_timestamp_counter() + } +} /// Reads the current value of the processor's time-stamp counter. #[cfg(target_arch = "aarch64")] diff --git a/clock-bound/src/daemon/time/clocks.rs b/clock-bound/src/daemon/time/clocks.rs index c034c3b..684dc9d 100644 --- a/clock-bound/src/daemon/time/clocks.rs +++ b/clock-bound/src/daemon/time/clocks.rs @@ -1,13 +1,33 @@ //! Clocks used in ClockBound -use crate::daemon::time::{Instant, inner::Clock, instant::Utc}; +use crate::daemon::{ + clock_parameters::ClockParameters, + io::tsc::ReadTsc, + time::{Instant, TscCount, inner::Clock, instant::Utc}, +}; use nix::time::{ClockId, clock_gettime}; /// Wrapper around reads of the internal clock tracked by the ClockBound `ClockSyncAlgorithm`. -pub struct ClockBound; -impl Clock for ClockBound { +pub struct ClockBound { + clock_parameters: ClockParameters, + read_tsc: T, +} +impl ClockBound { + /// Create a new `ClockBound` clock + pub fn new(clock_parameters: ClockParameters, read_tsc: T) -> Self { + Self { + clock_parameters, + read_tsc, + } + } +} + +impl Clock for ClockBound { /// Get the current `Instant` by reading `ClockParameters` fn get_time(&self) -> Instant { - todo!("implement this clock once we have some sane parameters"); + let current_tsc = TscCount::new(self.read_tsc.read_tsc().into()); + // TODO: disregarding overflow of TSC, that will take a while, but worth thinking of + self.clock_parameters.time + + ((current_tsc - self.clock_parameters.tsc_count) * self.clock_parameters.period) } } @@ -63,6 +83,13 @@ impl Clock for MonotonicRaw { #[cfg(test)] mod tests { + use rstest::rstest; + + use crate::daemon::{ + io::tsc::MockReadTsc, + time::{Duration, tsc::Period}, + }; + use super::*; // Ensure that CLOCK_REALTIME and the std library utility for CLOCK_REALTIME are approximately the same. @@ -85,4 +112,67 @@ mod tests { let later = monotonic_raw.get_time(); assert!(now < later); } + + #[rstest] + #[case::no_tsc_change( + TscCount::new(0), + Instant::from_secs(0), + Period::from_seconds(1e-9), + 0, + Instant::from_secs(0) + )] + #[case::start_from_zero_time( + TscCount::new(0), + Instant::from_secs(0), + Period::from_seconds(1e-9), + 1_000_000_000, + Instant::from_secs(1) + )] + #[case::start_from_nonzero_time( + TscCount::new(0), + Instant::from_secs(1), + Period::from_seconds(1e-9), + 1_000_000_000, + Instant::from_secs(2) + )] + #[case::larger_period( + TscCount::new(0), + Instant::from_secs(0), + Period::from_seconds(1e-6), + 1_000_000_000, + Instant::from_secs(1_000) + )] + #[case::start_from_nonzero_tsc( + TscCount::new(1_000_000_000), + Instant::from_secs(0), + Period::from_seconds(1e-9), + 2_000_000_000, + Instant::from_secs(1) + )] + fn test_clockbound_clock( + #[case] initial_tsc: TscCount, + #[case] initial_time: Instant, + #[case] period: Period, + #[case] read_tsc_output: u64, + #[case] expected_time: Instant, + ) { + let mut mock_read_tsc = MockReadTsc::new(); + mock_read_tsc + .expect_read_tsc() + .returning(move || read_tsc_output); + + let tsc_count = initial_tsc; + let time = initial_time; + let clock_error_bound = Duration::new(0); + let period_max_error = Period::from_seconds(0.0); + let clock_parameters = ClockParameters { + tsc_count, + time, + clock_error_bound, + period, + period_max_error, + }; + let clockbound_clock = ClockBound::new(clock_parameters, mock_read_tsc); + assert_eq!(clockbound_clock.get_time(), expected_time); + } } From 4ad09febeb2b1e418ece403a3bff14a6359e0d06 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Thu, 30 Oct 2025 11:35:49 -0400 Subject: [PATCH 058/177] Implement initial ClockStateWriter without ClockStatus management (+ Refactor clock_state module to have clock_adjust submodule) (#67) * Refactor `clock_state` module to have `clock_adjust` submodule This commit moves `ClockAdjust` component code into a separate module from `clock_state` so that we don't pack `clock_state.rs` too much. * Implement initial `ClockStateWriter` without ClockStatus management This commit implements an initial `ClockStateWriter`. It defaults to `ClockStatus::Unknown` on construction, and the result is that it writes to the SHM segment with these parameters. It takes care of simply calculating the `bound_nsec` value to expose to the client based on `ClockParameters` as well as the `CLOCK_REALTIME` offset from the `ClockBound` clock (eventually the latter component should be dropped). It then delegates to our original `ShmWriter` implementation to handle the rest. The `ClockStatus` management will be implemented in a future set of changes alongside our clock disruption handling. --- clock-bound/src/daemon/clock_state.rs | 225 +------------- .../src/daemon/clock_state/clock_adjust.rs | 224 ++++++++++++++ .../daemon/clock_state/clock_state_writer.rs | 288 ++++++++++++++++++ clock-bound/src/daemon/time/clocks.rs | 21 ++ clock-bound/src/daemon/time/inner.rs | 2 +- clock-bound/src/daemon/time/instant.rs | 47 +++ clock-bound/src/shm.rs | 32 ++ .../src/adjust_clock.rs | 2 +- .../src/step_clock.rs | 2 +- 9 files changed, 617 insertions(+), 226 deletions(-) create mode 100644 clock-bound/src/daemon/clock_state/clock_adjust.rs create mode 100644 clock-bound/src/daemon/clock_state/clock_state_writer.rs diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 583fe93..25e716c 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -1,224 +1,3 @@ //! Adjust system clock and clockbound shared memory -use errno::Errno; -use libc::{TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT, ntp_adjtime}; -use thiserror::Error; -use tracing::debug; - -use crate::daemon::time::{Duration, timex::Timex, tsc::Skew}; - -/// Error type returned when dealing with underlying `adjtimex` or `ntp_adjtime` -/// results. -#[derive(Debug, Error)] -pub enum NtpAdjTimeError { - #[error("Failed to adjust the clock: {0}")] - Failure(Errno), - #[error("Unexpected bad state return value from ntp_adjtime: {0}")] - BadState(i32), - #[error("Invalid return value from ntp_adjtime: {0}")] - InvalidState(i32), -} - -/// Concrete struct implementing `ntp_adjtime` by delegating to the `libc` -/// implementation. Should be the only actual concrete implementation. -pub struct KAPIClockAdjuster; -impl NtpAdjTime for KAPIClockAdjuster { - fn ntp_adjtime(&self, tx: &mut Timex) -> i32 { - // # Safety - // `tx` should point to a valid struct because of validation guarantees of `Timex` - unsafe { ntp_adjtime(tx.expose()) } - } -} - -/// Noop Clock Adjuster, which doesn't actually adjust the clock parameters but just -/// returns `TIME_OK`. -pub struct NoopClockAdjuster; -impl NtpAdjTime for NoopClockAdjuster { - fn ntp_adjtime(&self, _tx: &mut Timex) -> i32 { - TIME_OK - } -} - -/// Lightweight trait around `ntp_adjtime` function (formerly `adjtimex`). -/// Useful for mocking, or potentially as an abstraction around modifying -/// other clocks' parameters in the future. -#[cfg_attr(test, mockall::automock)] -pub trait NtpAdjTime { - fn ntp_adjtime(&self, tx: &mut Timex) -> i32; -} - -pub struct ClockAdjuster { - ntp_adjtime: T, -} - -impl ClockAdjuster { - pub fn new(ntp_adjtime: T) -> Self { - Self { ntp_adjtime } - } - - /// Performs an adjustment of the clock, to apply the given phase correction - /// and skew values, in a single system call. - /// - /// # Errors - /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno - /// `NtpAdjTimeError::BadState` if some state other than `TIME_OK` is returned from `ntp_adjtime` - /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` - pub fn adjust_clock( - &self, - phase_correction: Duration, - skew: Skew, - ) -> Result { - let mut tx = Timex::clock_adjustment() - .phase_correction(phase_correction) - .skew(skew) - .call(); - - debug!("calling ntp_adjtime with {:?}", tx); - match self.ntp_adjtime.ntp_adjtime(&mut tx) { - TIME_OK => Ok(tx), - cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { - Err(NtpAdjTimeError::BadState(cs)) - } - -1 => Err(NtpAdjTimeError::Failure(errno::errno())), - unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), - } - } - - /// Applies an instantaneous step of `CLOCK_REALTIME` based on the passed `phase_correction` value. - /// - /// # Errors - /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno - /// `NtpAdjTimeError::BadState` if some state other than `TIME_ERROR` is returned from `ntp_adjtime` - /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` - pub fn step_clock(&self, phase_correction: Duration) -> Result { - let mut tx = Timex::clock_step() - .phase_correction(phase_correction) - .call(); - - debug!("calling ntp_adjtime with {:?}", tx); - // NOTE: we actually expect TIME_ERROR if the clock adjustment succeeds, since - // that indicates the clock is now "unsynchronized" (expected after we step the clock - // discontinuously) - match self.ntp_adjtime.ntp_adjtime(&mut tx) { - TIME_ERROR => Ok(tx), - cs @ (TIME_OK | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { - Err(NtpAdjTimeError::BadState(cs)) - } - -1 => Err(NtpAdjTimeError::Failure(errno::errno())), - unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), - } - } -} - -#[cfg(test)] -mod test { - use rstest::rstest; - - use super::*; - - #[rstest] - #[case::positives(Duration::from_nanos(500), Skew::from_ppm(1.0))] - #[case::negatives(Duration::from_nanos(-500), Skew::from_ppm(-1.0))] - #[case::zeroes(Duration::from_nanos(0), Skew::from_ppm(0.0))] - #[case::positive_offset_negative_skew(Duration::from_nanos(500), Skew::from_ppm(-1.0))] - #[case::negative_offset_positive_skew(Duration::from_nanos(-500), Skew::from_ppm(1.0))] - fn adjust_clock_happy_paths( - #[case] input_phase_correction: Duration, - #[case] input_skew: Skew, - ) { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(TIME_OK); - - // Call adjust_clock with test values - let result = clock_adjuster.adjust_clock(input_phase_correction, input_skew); - - assert!(result.is_ok()); - } - - #[test] - fn adjust_clock_failure() { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(-1); - - // Call adjust_clock with test values - let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), NtpAdjTimeError::Failure(_))); - } - - #[test] - fn adjust_clock_bad_state() { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(TIME_ERROR); - - // Call adjust_clock with test values - let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), NtpAdjTimeError::BadState(_))); - } - - #[test] - fn adjust_clock_unexpected_value() { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(12345); - - // Call adjust_clock with test values - let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - NtpAdjTimeError::InvalidState(_) - )); - } - - #[rstest] - #[case::positive(Duration::from_millis(100))] - #[case::negative(-Duration::from_millis(100))] - #[case::zero(Duration::from_millis(0))] - fn step_clock_happy_paths(#[case] input_phase_correction: Duration) { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(TIME_ERROR); - - // Call step_clock with test values - let result = clock_adjuster.step_clock(input_phase_correction); - - assert!(result.is_ok()); - } -} +pub mod clock_adjust; +pub mod clock_state_writer; diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs new file mode 100644 index 0000000..bf66b8e --- /dev/null +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -0,0 +1,224 @@ +//! Adjust system clock +use errno::Errno; +use libc::{TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT, ntp_adjtime}; +use thiserror::Error; +use tracing::debug; + +use crate::daemon::time::{Duration, timex::Timex, tsc::Skew}; + +/// Error type returned when dealing with underlying `adjtimex` or `ntp_adjtime` +/// results. +#[derive(Debug, Error)] +pub enum NtpAdjTimeError { + #[error("Failed to adjust the clock: {0}")] + Failure(Errno), + #[error("Unexpected bad state return value from ntp_adjtime: {0}")] + BadState(i32), + #[error("Invalid return value from ntp_adjtime: {0}")] + InvalidState(i32), +} + +/// Concrete struct implementing `ntp_adjtime` by delegating to the `libc` +/// implementation. Should be the only actual concrete implementation. +pub struct KAPIClockAdjuster; +impl NtpAdjTime for KAPIClockAdjuster { + fn ntp_adjtime(&self, tx: &mut Timex) -> i32 { + // # Safety + // `tx` should point to a valid struct because of validation guarantees of `Timex` + unsafe { ntp_adjtime(tx.expose()) } + } +} + +/// Noop Clock Adjuster, which doesn't actually adjust the clock parameters but just +/// returns `TIME_OK`. +pub struct NoopClockAdjuster; +impl NtpAdjTime for NoopClockAdjuster { + fn ntp_adjtime(&self, _tx: &mut Timex) -> i32 { + TIME_OK + } +} + +/// Lightweight trait around `ntp_adjtime` function (formerly `adjtimex`). +/// Useful for mocking, or potentially as an abstraction around modifying +/// other clocks' parameters in the future. +#[cfg_attr(test, mockall::automock)] +pub trait NtpAdjTime { + fn ntp_adjtime(&self, tx: &mut Timex) -> i32; +} + +pub struct ClockAdjuster { + ntp_adjtime: T, +} + +impl ClockAdjuster { + pub fn new(ntp_adjtime: T) -> Self { + Self { ntp_adjtime } + } + + /// Performs an adjustment of the clock, to apply the given phase correction + /// and skew values, in a single system call. + /// + /// # Errors + /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno + /// `NtpAdjTimeError::BadState` if some state other than `TIME_OK` is returned from `ntp_adjtime` + /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` + pub fn adjust_clock( + &self, + phase_correction: Duration, + skew: Skew, + ) -> Result { + let mut tx = Timex::clock_adjustment() + .phase_correction(phase_correction) + .skew(skew) + .call(); + + debug!("calling ntp_adjtime with {:?}", tx); + match self.ntp_adjtime.ntp_adjtime(&mut tx) { + TIME_OK => Ok(tx), + cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { + Err(NtpAdjTimeError::BadState(cs)) + } + -1 => Err(NtpAdjTimeError::Failure(errno::errno())), + unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), + } + } + + /// Applies an instantaneous step of `CLOCK_REALTIME` based on the passed `phase_correction` value. + /// + /// # Errors + /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno + /// `NtpAdjTimeError::BadState` if some state other than `TIME_ERROR` is returned from `ntp_adjtime` + /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` + pub fn step_clock(&self, phase_correction: Duration) -> Result { + let mut tx = Timex::clock_step() + .phase_correction(phase_correction) + .call(); + + debug!("calling ntp_adjtime with {:?}", tx); + // NOTE: we actually expect TIME_ERROR if the clock adjustment succeeds, since + // that indicates the clock is now "unsynchronized" (expected after we step the clock + // discontinuously) + match self.ntp_adjtime.ntp_adjtime(&mut tx) { + TIME_ERROR => Ok(tx), + cs @ (TIME_OK | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { + Err(NtpAdjTimeError::BadState(cs)) + } + -1 => Err(NtpAdjTimeError::Failure(errno::errno())), + unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), + } + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::positives(Duration::from_nanos(500), Skew::from_ppm(1.0))] + #[case::negatives(Duration::from_nanos(-500), Skew::from_ppm(-1.0))] + #[case::zeroes(Duration::from_nanos(0), Skew::from_ppm(0.0))] + #[case::positive_offset_negative_skew(Duration::from_nanos(500), Skew::from_ppm(-1.0))] + #[case::negative_offset_positive_skew(Duration::from_nanos(-500), Skew::from_ppm(1.0))] + fn adjust_clock_happy_paths( + #[case] input_phase_correction: Duration, + #[case] input_skew: Skew, + ) { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_OK); + + // Call adjust_clock with test values + let result = clock_adjuster.adjust_clock(input_phase_correction, input_skew); + + assert!(result.is_ok()); + } + + #[test] + fn adjust_clock_failure() { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(-1); + + // Call adjust_clock with test values + let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), NtpAdjTimeError::Failure(_))); + } + + #[test] + fn adjust_clock_bad_state() { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_ERROR); + + // Call adjust_clock with test values + let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), NtpAdjTimeError::BadState(_))); + } + + #[test] + fn adjust_clock_unexpected_value() { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(12345); + + // Call adjust_clock with test values + let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + NtpAdjTimeError::InvalidState(_) + )); + } + + #[rstest] + #[case::positive(Duration::from_millis(100))] + #[case::negative(-Duration::from_millis(100))] + #[case::zero(Duration::from_millis(0))] + fn step_clock_happy_paths(#[case] input_phase_correction: Duration) { + let mock_ntp_adj_time = MockNtpAdjTime::new(); + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + + // Set up mock expectations + clock_adjuster + .ntp_adjtime + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_ERROR); + + // Call step_clock with test values + let result = clock_adjuster.step_clock(input_phase_correction); + + assert!(result.is_ok()); + } +} diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs new file mode 100644 index 0000000..224b458 --- /dev/null +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -0,0 +1,288 @@ +use nix::sys::time::TimeSpec; + +use crate::{ + daemon::{ + clock_parameters::ClockParameters, + time::{ + Clock, Duration, Instant, clocks::MonotonicCoarse, inner::ClockOffsetAndRtt, + instant::Utc, + }, + }, + shm::{ClockErrorBound, ClockStatus, ShmWrite}, +}; + +/// The drift rate/maximal frequency error in parts-per-billion +/// for the `ClockBound` clock. The error bounds returned from the client +/// expand by this * the duration since `as_of`. +#[expect(unused)] +const FREQUENCY_ERROR_PPB: u32 = 15_000; + +pub struct ClockStateWriter { + clock_status: ClockStatus, + clock_disruption_support_enabled: bool, + shm_writer: T, + max_drift_ppb: u32, + disruption_marker: u64, +} + +#[bon::bon] +impl ClockStateWriter { + #[builder] + pub fn new( + clock_disruption_support_enabled: bool, + shm_writer: T, + max_drift_ppb: u32, + disruption_marker: u64, + ) -> Self { + Self { + clock_status: ClockStatus::Unknown, + clock_disruption_support_enabled, + shm_writer, + max_drift_ppb, + disruption_marker, + } + } + + /// # Panics + /// Panics if error bound calculated exceeds `i64::MAX` + pub fn handle_clock_parameters( + &mut self, + // This is needed to tell ClockAdjust what frequency to use. + clock_parameters: &ClockParameters, + // This is needed to tell ClockAdjust what phase offset to use. + clock_realtime_offset_and_rtt: ClockOffsetAndRtt, + ) { + let bound = get_bound(clock_parameters, clock_realtime_offset_and_rtt); + // Unwrap safety: sane error bound should be less than `i64::MAX` + let bound_nsec = i64::try_from(bound.as_nanos()).unwrap(); + // TODO: we should normally grab the `as_of` timestamp from the `ClockParameters` themselves, + // e.g. use the `time` at which they were valid. + // However, the client implementation today reads from `CLOCK_MONOTONIC_COARSE`. The `ClockParameters` + // readings are not necessarily going to be monotonic nor aligned with `CLOCK_MONOTONIC_COARSE`. + // If we write an `as_of` that is ahead of `CLOCK_MONOTONIC_COARSE`, we could see `ShmError::CausalityBreach` for client. + // For the sake of backwards compatibility, we will have our initial/alpha release continue to work + // using `CLOCK_MONOTONIC_COARSE`. + let as_of = MonotonicCoarse.get_time(); + self.write_shm(as_of, bound_nsec); + } + + fn write_shm(&mut self, as_of: Instant, bound_nsec: i64) { + let void_after = as_of + Duration::from_secs(1000); + // TODO: It may be worthwhile to add to this max drift ppb base the following components: + // - any slew rate for phase correction, since kernel clocks are used on client side + // - error inherent to our frequency calculation e.g. `period_max_error` + let max_drift_ppb = self.max_drift_ppb; + let ceb: ClockErrorBound = ClockErrorBound::new( + // Unwrap safety: unlikely to fail for any value for the distant future, + // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds + TimeSpec::try_from(as_of).unwrap(), + // Unwrap safety: unlikely to fail for any value for the distant future, + // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds + TimeSpec::try_from(void_after).unwrap(), + bound_nsec, + self.disruption_marker, + max_drift_ppb, + self.clock_status, + self.clock_disruption_support_enabled, + ); + self.shm_writer.write(&ceb); + } +} + +/// Calculate the `ClockErrorBound` `bound_nsec` value. This is used to calculate +/// the `earliest` and `latest` readings from a `now` call. +/// +/// # Arguments +/// * `clock_parameters` - Clock Parameters of best clock chosen by `ClockBound` +/// * `clock_realtime_offset_and_rtt` - offset and round trip time of a measurement of offset between `ClockBound` clock +/// and `CLOCK_REALTIME` +fn get_bound( + clock_parameters: &ClockParameters, + clock_realtime_offset_and_rtt: ClockOffsetAndRtt, +) -> Duration { + let realtime_to_clockbound_measured_offset = clock_realtime_offset_and_rtt.offset(); + let measurement_rtt = clock_realtime_offset_and_rtt.rtt(); + let bound_between_realtime_and_clockbound = + realtime_to_clockbound_measured_offset + measurement_rtt / 2; + clock_parameters.clock_error_bound + bound_between_realtime_and_clockbound +} + +#[cfg(test)] +mod tests { + use mockall::mock; + use rstest::rstest; + + use crate::daemon::time::{Duration, TscCount, tsc::Period}; + + use super::*; + + mock! { + ShmWriter {} + impl ShmWrite for ShmWriter { + fn write(&mut self, ceb: &ClockErrorBound); + } + } + + /// Helper function to create a test ClockParameters + #[bon::builder] + fn create_test_clock_parameters( + clock_error_bound_nanos: i128, + tsc_count: i128, + time_nanos: i128, + ) -> ClockParameters { + ClockParameters { + tsc_count: TscCount::new(tsc_count), + time: Instant::from_nanos(time_nanos), + clock_error_bound: Duration::from_nanos(clock_error_bound_nanos), + period: Period::from_seconds(1e-9), + period_max_error: Period::from_seconds(1e-11), + } + } + + /// Helper function to create a test ClockOffsetAndRtt + #[bon::builder] + fn create_test_clock_offset_and_rtt( + offset_nanos: i128, + rtt_nanos: i128, + ) -> ClockOffsetAndRtt { + ClockOffsetAndRtt::new( + Duration::from_nanos(offset_nanos), + Duration::from_nanos(rtt_nanos), + ) + } + + #[rstest] + #[case::synchronized(ClockStatus::Synchronized)] + #[case::unknown(ClockStatus::Unknown)] + #[case::free_running(ClockStatus::FreeRunning)] + #[case::disrupted(ClockStatus::Disrupted)] + fn test_write_shm(#[case] clock_status: ClockStatus) { + let as_of = MonotonicCoarse.get_time(); + let bound_nsec = 1234; + let max_drift_ppb = 234; + let disruption_marker = 345; + let clock_disruption_support_enabled = true; + let expected_ceb = ClockErrorBound::new( + TimeSpec::try_from(as_of).unwrap(), + TimeSpec::try_from(as_of + Duration::from_secs(1000)).unwrap(), + bound_nsec, + disruption_marker, + max_drift_ppb, + clock_status, + clock_disruption_support_enabled, + ); + let mut shm_writer = MockShmWriter::new(); + shm_writer + .expect_write() + .withf(move |ceb: &ClockErrorBound| expected_ceb == *ceb) + .times(1) + .return_const(()); + let mut clock_state_writer = ClockStateWriter::builder() + .clock_disruption_support_enabled(clock_disruption_support_enabled) + .shm_writer(shm_writer) + .max_drift_ppb(max_drift_ppb) + .disruption_marker(disruption_marker) + .build(); + clock_state_writer.clock_status = clock_status; + clock_state_writer.write_shm(as_of, bound_nsec); + } + + #[test] + fn test_handle_clock_parameters() { + let clock_parameters = create_test_clock_parameters() + .clock_error_bound_nanos(1000) + .tsc_count(1000) + .time_nanos(1_000_000_000) + .call(); + let clock_realtime_offset_and_rtt = create_test_clock_offset_and_rtt() + .offset_nanos(1000) + .rtt_nanos(500) + .call(); + let clock_disruption_support_enabled = false; + let max_drift_ppb = 0; + let disruption_marker = 0; + let mut shm_writer = MockShmWriter::new(); + shm_writer + .expect_write() + .withf(move |ceb: &ClockErrorBound| { + ceb.void_after() + == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) + && ceb.bound_nsec() == 2250 + && ceb.disruption_marker() == disruption_marker + && ceb.max_drift_ppb() == max_drift_ppb + && ceb.clock_status() == ClockStatus::Unknown // default on `ClockStateWriter` constructor + && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled + }) + .times(1) + .return_const(()); + let mut clock_state_writer = ClockStateWriter::builder() + .clock_disruption_support_enabled(clock_disruption_support_enabled) + .shm_writer(shm_writer) + .max_drift_ppb(max_drift_ppb) + .disruption_marker(disruption_marker) + .build(); + clock_state_writer + .handle_clock_parameters(&clock_parameters, clock_realtime_offset_and_rtt); + } + + #[test] + #[should_panic] + fn test_handle_clock_parameters_panic_on_overflow_error_bound() { + let clock_parameters = create_test_clock_parameters() + .clock_error_bound_nanos(i64::MAX as i128 + 1) // overflowing CEB + .tsc_count(1000) + .time_nanos(1_000_000_000) + .call(); + let clock_realtime_offset_and_rtt = create_test_clock_offset_and_rtt() + .offset_nanos(1000) + .rtt_nanos(500) + .call(); + let clock_disruption_support_enabled = false; + let max_drift_ppb = 0; + let disruption_marker = 0; + let mut shm_writer = MockShmWriter::new(); + shm_writer.expect_write().never(); + let mut clock_state_writer = ClockStateWriter::builder() + .clock_disruption_support_enabled(clock_disruption_support_enabled) + .shm_writer(shm_writer) + .max_drift_ppb(max_drift_ppb) + .disruption_marker(disruption_marker) + .build(); + clock_state_writer + .handle_clock_parameters(&clock_parameters, clock_realtime_offset_and_rtt); + } + + #[rstest] + #[case( + create_test_clock_parameters() + .clock_error_bound_nanos(0) + .tsc_count(1000) + .time_nanos(1_000_000_000) + .call(), + create_test_clock_offset_and_rtt() + .offset_nanos(0) + .rtt_nanos(0) + .call(), + Duration::from_nanos(0), + )] + #[case( + create_test_clock_parameters() + .clock_error_bound_nanos(1000) + .tsc_count(2000) + .time_nanos(1_000_000_000) + .call(), + create_test_clock_offset_and_rtt() + .offset_nanos(1000) + .rtt_nanos(500) + .call(), + Duration::from_nanos(2250), + )] + fn test_get_bound( + #[case] clock_parameters: ClockParameters, + #[case] clock_realtime_offset_and_rtt: ClockOffsetAndRtt, + #[case] expected: Duration, + ) { + let bound_nsec = get_bound(&clock_parameters, clock_realtime_offset_and_rtt); + assert_eq!(bound_nsec, expected); + } +} diff --git a/clock-bound/src/daemon/time/clocks.rs b/clock-bound/src/daemon/time/clocks.rs index 684dc9d..4bd2b9f 100644 --- a/clock-bound/src/daemon/time/clocks.rs +++ b/clock-bound/src/daemon/time/clocks.rs @@ -81,6 +81,27 @@ impl Clock for MonotonicRaw { } } +pub struct MonotonicCoarse; +impl Clock for MonotonicCoarse { + /// Get the current `Instant` by reading `CLOCK_MONOTONIC_COARSE` + /// + /// # Panics + /// Panics if `clock_gettime` call fails (if pointer allocated for the call is invalid, or `ClockId` supplied is invalid or unavailable on the system) + #[allow( + clippy::cast_possible_truncation, + reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no truncation" + )] + #[allow( + clippy::cast_sign_loss, + reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no loss of sign" + )] + fn get_time(&self) -> Instant { + // Unwrap safety: `nix` crate supplies valid pointer and `ClockId` so the `clock_gettime` call should not be able to fail + let now = clock_gettime(ClockId::CLOCK_MONOTONIC_COARSE).unwrap(); + Instant::from_time(now.tv_sec().into(), now.tv_nsec() as u32) + } +} + #[cfg(test)] mod tests { use rstest::rstest; diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index ea52ac6..5e446a6 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -39,7 +39,7 @@ pub struct ClockOffsetAndRtt { } impl ClockOffsetAndRtt { - fn new(offset: Diff, rtt: Diff) -> Self { + pub(crate) fn new(offset: Diff, rtt: Diff) -> Self { Self { offset, rtt } } diff --git a/clock-bound/src/daemon/time/instant.rs b/clock-bound/src/daemon/time/instant.rs index aa16ca1..78e0826 100644 --- a/clock-bound/src/daemon/time/instant.rs +++ b/clock-bound/src/daemon/time/instant.rs @@ -1,4 +1,5 @@ //! A simplified time type for `ClockBound` +use nix::sys::time::TimeSpec; use super::inner::{Diff, Time}; @@ -24,3 +25,49 @@ pub type Instant = Time; /// The corresponding duration type for [`Instant`] pub type Duration = Diff; + +impl TryFrom for TimeSpec { + // Reuse inner error from failure to convert i128 to i64 + type Error = >::Error; + + fn try_from(value: Instant) -> Result { + let seconds = value.as_seconds_trunc().try_into()?; + // Unwrap safety: 1e9 fits into i64 + let nanoseconds = (value.as_nanos_trunc() % 1_000_000_000).try_into().unwrap(); + Ok(TimeSpec::new(seconds, nanoseconds)) + } +} + +#[cfg(test)] +mod test { + use crate::daemon::time::{Clock, clocks::MonotonicCoarse}; + + use super::*; + use rstest::rstest; + + #[rstest] + #[case(Instant::from_nanos(0), TimeSpec::new(0, 0))] + #[case(Instant::from_nanos(1), TimeSpec::new(0, 1))] + #[case(Instant::from_nanos(1_000), TimeSpec::new(0, 1_000))] + #[case(Instant::from_nanos(1_000_000_000), TimeSpec::new(1, 0))] + #[case::min_val(Instant::from_secs(i64::MIN as i128), TimeSpec::new(i64::MIN, 0))] + #[case::max_val(Instant::from_secs(i64::MAX as i128), TimeSpec::new(i64::MAX, 0))] + fn timespec_try_from_instant(#[case] instant: Instant, #[case] expected: TimeSpec) { + assert_eq!(TimeSpec::try_from(instant).unwrap(), expected); + } + + // Cheeky test to tell us that +/- 1 million years we are safe + #[rstest] + #[case::past(MonotonicCoarse.get_time() - Duration::from_days(365_000_000))] + #[case::future(MonotonicCoarse.get_time() + Duration::from_days(365_000_000))] + fn timespec_try_from_instant_should_not_panic(#[case] instant: Instant) { + TimeSpec::try_from(instant).unwrap(); + } + + #[rstest] + #[case::underflow(Instant::from_secs(i64::MIN as i128 - 1))] + #[case::overflow(Instant::from_secs(i64::MAX as i128 + 1))] + fn timespec_try_from_instant_failure(#[case] instant: Instant) { + TimeSpec::try_from(instant).unwrap_err(); + } +} diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 60254f7..69fd229 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -338,6 +338,38 @@ impl ClockErrorBound { } } +/// Getters exposed for the sake of unit tests across other modules in crate +#[cfg(test)] +impl ClockErrorBound { + pub fn as_of(&self) -> TimeSpec { + self.as_of + } + + pub fn void_after(&self) -> TimeSpec { + self.void_after + } + + pub fn bound_nsec(&self) -> i64 { + self.bound_nsec + } + + pub fn disruption_marker(&self) -> u64 { + self.disruption_marker + } + + pub fn max_drift_ppb(&self) -> u32 { + self.max_drift_ppb + } + + pub fn clock_status(&self) -> ClockStatus { + self.clock_status + } + + pub fn clock_disruption_support_enabled(&self) -> bool { + self.clock_disruption_support_enabled + } +} + #[cfg(test)] mod t_lib { use super::*; diff --git a/test/clock-bound-adjust-clock/src/adjust_clock.rs b/test/clock-bound-adjust-clock/src/adjust_clock.rs index 87e7e45..88fea82 100644 --- a/test/clock-bound-adjust-clock/src/adjust_clock.rs +++ b/test/clock-bound-adjust-clock/src/adjust_clock.rs @@ -2,7 +2,7 @@ //! the timekeeping utilities internal to ClockBound. use clap::Parser; use clock_bound::daemon::{ - clock_state::{ClockAdjuster, KAPIClockAdjuster}, + clock_state::clock_adjust::{ClockAdjuster, KAPIClockAdjuster}, time::{Duration, tsc::Skew}, }; diff --git a/test/clock-bound-adjust-clock/src/step_clock.rs b/test/clock-bound-adjust-clock/src/step_clock.rs index 0c68d30..35aabaa 100644 --- a/test/clock-bound-adjust-clock/src/step_clock.rs +++ b/test/clock-bound-adjust-clock/src/step_clock.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use clap::Parser; use clock_bound::daemon::{ - clock_state::{ClockAdjuster, KAPIClockAdjuster}, + clock_state::clock_adjust::{ClockAdjuster, KAPIClockAdjuster}, time::Duration, }; From 614e378d410fa04653197ab1080920e18ce792d2 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Thu, 30 Oct 2025 12:24:09 -0400 Subject: [PATCH 059/177] Adds functions to construct and spawn vmclock tasks. (#69) To handle clock disruption events the vmclock IO task needs to be added to the `SourceIO` struct. This commit adds the vmclock to the `SourceIO` stuct, as well as adds construction function and spawning logic for the vmclock. In the process of adding the above functionality the vmclock shared memory reader struct needed to be extended. Within the vmclock module it can now be sent to different threads. Additionally, the `Sources` struct was flattened. Each component now resides in the `SourceIO` struct. --- clock-bound/src/daemon/io.rs | 89 ++++++++++++++++++++++------ clock-bound/src/daemon/io/vmclock.rs | 27 ++++++++- 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 3aab7ce..4c34075 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -24,14 +24,19 @@ use ntp_source::NTPSource; pub mod tsc; mod vmclock; +use vmclock::VMClock; /// `SourceIO` acts as the front end for IO tasks. /// /// `SourceIO` contains the interface from which new IO tasks can be spawned, as well as an interface /// to send control commands to the specific IO tasks. pub struct SourceIO { - /// A mapping between the time source type and the task handle. - sources: Sources, + /// The link local source. + link_local: Option>, + /// Mapping between the socket ip-address and the ntp io source + ntp_sources: HashMap>, + /// The VMClock source + vmclock: Option>, /// Contains the channel used to communicate clock disruption events. clock_disruption_channels: ClockDisruptionChannels, } @@ -41,7 +46,9 @@ impl SourceIO { pub fn construct() -> Self { let (sender, receiver) = watch::channel::(ClockDisruptionEvent {}); SourceIO { - sources: Sources::default(), + link_local: None, + ntp_sources: HashMap::new(), + vmclock: None, clock_disruption_channels: ClockDisruptionChannels { sender, receiver }, } } @@ -54,9 +61,9 @@ impl SourceIO { pub async fn create_link_local(&mut self, event_sender: async_ring_buffer::Sender) { info!("Creating link local source."); - debug!(?self.sources.link_local, "Current source entry status"); - if self.sources.link_local.is_none() { - self.sources.link_local = { + debug!(?self.link_local, "Current source entry status"); + if self.link_local.is_none() { + self.link_local = { let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); let clock_disruption_receiver = self.clock_disruption_channels.sender.subscribe(); @@ -95,7 +102,7 @@ impl SourceIO { server_address.ip().to_string() ); - if !self.sources.ntp_sources.contains_key(&server_address) { + if !self.ntp_sources.contains_key(&server_address) { let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); let clock_disruption_receiver = self.clock_disruption_channels.sender.subscribe(); @@ -115,12 +122,50 @@ impl SourceIO { state: SourceState::Initialized(ntp_source), ctrl_sender, }; - self.sources.ntp_sources.insert(server_address, source); + self.ntp_sources.insert(server_address, source); } info!("Source update complete."); } + /// Spawns the IO task for sampling the VMClock shared memory file. + /// + /// # Errors + /// - If the vmclock shared memory file could not be found. + /// - If the vmclock shared memory file is malformed. + pub async fn create_vmclock( + &mut self, + vmclock_shm_path: &str, + ) -> Result<(), vmclock::VMClockConstructionError> { + info!("Creating link local source."); + + debug!(?self.vmclock, "Current source entry status."); + if self.vmclock.is_none() { + self.vmclock = { + let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); + + let vmclock = VMClock::construct( + vmclock_shm_path, + ctrl_receiver, + self.clock_disruption_channels.sender.clone(), + ) + .await?; + + let source = Source { + state: SourceState::Initialized(vmclock), + ctrl_sender, + }; + Some(source) + }; + } + Ok(()) + } + + // Creates a new [`watch::Receiver`] connected to the clock distribution watch [`watch::Sender`]. + pub fn get_clock_disruption_receiver(&self) -> watch::Receiver { + self.clock_disruption_channels.sender.subscribe() + } + /// Starts the control flow task. #[allow( clippy::unused_async, @@ -137,7 +182,7 @@ impl SourceIO { if let Some(Source { state, ctrl_sender: _, - }) = &mut self.sources.link_local + }) = &mut self.link_local { debug!("Attempting to spawn link local source."); if let SourceState::Initialized(mut link_local) = state.transition_to_running() { @@ -150,8 +195,23 @@ impl SourceIO { debug!("Could not spawn a link local source. No source data provided."); } + // Spawn vmclock source + if let Some(Source { + state, + ctrl_sender: _, + }) = &mut self.vmclock + { + if let SourceState::Initialized(mut vmclock) = state.transition_to_running() { + spawn(async move { vmclock.run().await }); + } else { + warn!("Attempted to spawn a vmclock source when one is currently running."); + } + } else { + debug!("Could not spawn a vmclock source. No source data provided."); + } + // Spawn ntp sources - for (key, ntp_source) in &mut self.sources.ntp_sources { + for (key, ntp_source) in &mut self.ntp_sources { debug!("Attempting to spawn {key:?} ntp source."); if let SourceState::Initialized(mut ntp_source) = ntp_source.state.transition_to_running() @@ -186,13 +246,6 @@ struct Source { ctrl_sender: mpsc::Sender, } -/// `Sources` is a struct that maps sources to their state and control channels. -#[derive(Default, Debug)] -struct Sources { - link_local: Option>, - ntp_sources: HashMap>, -} - /// The possible states a time source can be in. #[derive(Debug)] pub enum SourceState { @@ -259,6 +312,6 @@ mod tests { let mut source_io = SourceIO::construct(); source_io.create_link_local(event_sender).await; - assert!(source_io.sources.link_local.is_some()) + assert!(source_io.link_local.is_some()) } } diff --git a/clock-bound/src/daemon/io/vmclock.rs b/clock-bound/src/daemon/io/vmclock.rs index 90b9266..cf5ac67 100644 --- a/clock-bound/src/daemon/io/vmclock.rs +++ b/clock-bound/src/daemon/io/vmclock.rs @@ -13,6 +13,29 @@ use crate::vmclock::{shm::VMClockShmBody, shm_reader::VMClockShmReader}; use super::{ClockDisruptionEvent, ControlRequest}; +/// This is a wrapper for the [`VMClockShmReader`] struct used to bypass its !Send rules. +/// +/// [`VMClockShmReader`] has explicit rules against the implementation of `Send`. These were +/// implemented, in part, to protect those using Clockbound as a library, where the underlying +/// pointers in `VMClockShmReader` could result in unexpected behavior due to their unsafe nature. +/// Unlike the `VMClockShmReader` `Reader` is not apart of the external facing library and in the +/// context of the ClockBound daemon its use is constrained such that it can be utilized safely. +/// Within the Clockbound context that means the Reader cannot be copied, the underlying pointers +/// can not be accessed directly and only one task has access to the struct. +struct Reader(VMClockShmReader); + +impl Reader { + fn new(path: &str) -> Result { + Ok(Reader(VMClockShmReader::new(path)?)) + } + + fn snapshot(&mut self) -> Result<&VMClockShmBody, ShmError> { + self.0.snapshot() + } +} + +unsafe impl Send for Reader {} + const VMCLOCK_TIMEOUT: Duration = Duration::from_millis(100); /// Indicates the current status of the VMClock. @@ -41,7 +64,7 @@ pub struct VMClock { /// Path to the vmclock shared memory file. path: String, /// Interface used to read the shared memory file. - reader: VMClockShmReader, + reader: Reader, /// Data from the previously read shared memory file. previous_shm_body: VMClockShmBody, /// The polling interval. @@ -68,7 +91,7 @@ impl VMClock { )); } - let mut reader = VMClockShmReader::new(vmclock_shm_path)?; + let mut reader = Reader::new(vmclock_shm_path)?; let vmclock_snapshot = *reader.snapshot()?; let mut vmclock_interval = interval(VMCLOCK_TIMEOUT); vmclock_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); From c566b0b41040e9848f76ea2ce5c4b4acc8cc0086 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Thu, 30 Oct 2025 15:49:58 -0400 Subject: [PATCH 060/177] Adding the disruption marker to watch channel struct. (#75) --- clock-bound/src/daemon/io.rs | 12 +++++----- clock-bound/src/daemon/io/vmclock.rs | 33 +++++++++++++++++----------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 4c34075..4d538de 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -44,7 +44,8 @@ pub struct SourceIO { impl SourceIO { /// Constructs a new `SourceIO` object and constructs the necessary resources. pub fn construct() -> Self { - let (sender, receiver) = watch::channel::(ClockDisruptionEvent {}); + let (sender, receiver) = + watch::channel::(ClockDisruptionEvent::default()); SourceIO { link_local: None, ntp_sources: HashMap::new(), @@ -231,9 +232,10 @@ struct ClockDisruptionChannels { receiver: watch::Receiver, } -// TODO: This is a stub for future clock disruption events. -#[derive(Clone, Debug)] -pub struct ClockDisruptionEvent {} +#[derive(Clone, Debug, Default)] +pub struct ClockDisruptionEvent { + disruption_marker: Option, +} // TODO: This is a stub for future control events. #[derive(Debug)] @@ -277,7 +279,7 @@ mod tests { let (event_sender, _) = async_ring_buffer::create::(1); let (_, ctrl_receiver) = mpsc::channel::(1); let (_, clock_disruption_receiver) = - watch::channel::(ClockDisruptionEvent {}); + watch::channel::(ClockDisruptionEvent::default()); let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) .await diff --git a/clock-bound/src/daemon/io/vmclock.rs b/clock-bound/src/daemon/io/vmclock.rs index cf5ac67..a09d230 100644 --- a/clock-bound/src/daemon/io/vmclock.rs +++ b/clock-bound/src/daemon/io/vmclock.rs @@ -42,7 +42,7 @@ const VMCLOCK_TIMEOUT: Duration = Duration::from_millis(100); #[derive(Debug, PartialEq)] pub enum ClockDisruptionStatus { Normal, - Disrupted, + Disrupted(u64), } #[derive(Debug, Error)] @@ -113,7 +113,9 @@ impl VMClock { // The marker increments by an indeterminate amount every clock disruption event. if self.previous_shm_body.disruption_marker != vmclock_snapshot.disruption_marker { self.previous_shm_body.disruption_marker = vmclock_snapshot.disruption_marker; - return Ok(ClockDisruptionStatus::Disrupted); + return Ok(ClockDisruptionStatus::Disrupted( + vmclock_snapshot.disruption_marker, + )); } Ok(ClockDisruptionStatus::Normal) } @@ -133,8 +135,8 @@ impl VMClock { _ = self.interval.tick() => { match self.sample() { Ok(s) => { - if matches!(s, ClockDisruptionStatus::Disrupted) { - self.clock_disruption_sender.send(ClockDisruptionEvent{}).unwrap(); + if let ClockDisruptionStatus::Disrupted(disruption_marker) = s { + self.clock_disruption_sender.send(ClockDisruptionEvent{ disruption_marker: Some(disruption_marker)}).unwrap(); debug!(?self, "A clock disruption event occurred and a disruption event was sent."); } }, @@ -168,7 +170,7 @@ mod test { use crate::vmclock::shm::VMClockClockStatus; use std::fs::{File, OpenOptions}; - use std::io::Write; + use std::io::{Seek, Write}; use tempfile::NamedTempFile; /// Test struct used to hold the expected fields in the VMClock shared memory segment. @@ -256,7 +258,7 @@ mod test { write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); let (_, ctrl_receiver) = mpsc::channel::(1); - let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent::default()); let _ = VMClock::construct(vmclock_shm_path, ctrl_receiver, clock_disruption_sender) .await .unwrap(); @@ -267,7 +269,7 @@ mod test { let filename = "name/of/file/that/shouldnt_exist"; let (_, ctrl_receiver) = mpsc::channel::(1); - let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent::default()); let construct_result = VMClock::construct(filename, ctrl_receiver, clock_disruption_sender).await; assert!(construct_result.is_err()); @@ -287,7 +289,7 @@ mod test { write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); let (_, ctrl_receiver) = mpsc::channel::(1); - let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent::default()); let mut vmclock = VMClock::construct(vmclock_shm_path, ctrl_receiver, clock_disruption_sender) .await @@ -310,7 +312,7 @@ mod test { // Construct the VMClock runner let (_, ctrl_receiver) = mpsc::channel::(1); - let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent::default()); let mut vmclock = VMClock::construct(vmclock_shm_path, ctrl_receiver, clock_disruption_sender) .await @@ -331,24 +333,29 @@ mod test { .write(true) .open(vmclock_shm_path) .expect("open vmclock file failed"); - let vmclock_content = VMClockContent::default(); + let mut vmclock_content = VMClockContent::default(); write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); // Construct the vmclock runner let (_, ctrl_receiver) = mpsc::channel::(1); - let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent {}); + let (clock_disruption_sender, _) = watch::channel(ClockDisruptionEvent::default()); let mut vmclock = VMClock::construct(vmclock_shm_path, ctrl_receiver, clock_disruption_sender) .await .unwrap(); // Update the shared memory file - let mut vmclock_content = VMClockContent::default(); + vmclock_content.seq_count += 10; vmclock_content.disruption_marker += 1; + + vmclock_shm_file.rewind().unwrap(); write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); // Sample the vmclock let clock_status = vmclock.sample().unwrap(); - assert_eq!(clock_status, ClockDisruptionStatus::Normal); + assert_eq!( + clock_status, + ClockDisruptionStatus::Disrupted(vmclock_content.disruption_marker) + ); } } From 4a2c08a3bd8c42ca43868d2f718e92de848dbbdb Mon Sep 17 00:00:00 2001 From: tphan25 Date: Thu, 30 Oct 2025 17:27:53 -0400 Subject: [PATCH 061/177] Implement top-level `ClockState` struct (#74) This commit introduces a struct `ClockState` which owns both a `ClockAdjuster` and a `ClockStateWriter`, and delegates out to them for the sake of handling `ClockParameters`. At this time, since the client relies on `CLOCK_REALTIME` and `CLOCK_MONOTONIC_COARSE` for its reads, we make it so that clock parameter handling is done by `ClockAdjuster` first, and subsequently handled by `ClockStateWriter` if that succeeds. Additionally, some simple logic is introduced to `ClockAdjuster` for the sake of determining whether it is right to step the clock or use a gradual slew/phase correction/frequency correction. --- Cargo.lock | 13 ++ clock-bound/Cargo.toml | 1 + clock-bound/src/daemon/clock_state.rs | 188 ++++++++++++++++++ .../src/daemon/clock_state/clock_adjust.rs | 93 +++++++-- .../daemon/clock_state/clock_state_writer.rs | 16 ++ .../src/step_clock.rs | 2 +- 6 files changed, 293 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8dbfc4f..9a2d882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,6 +265,7 @@ dependencies = [ "hex-literal", "libc", "mockall", + "mockall_double", "nix", "nom", "rand 0.9.2", @@ -803,6 +804,18 @@ dependencies = [ "syn", ] +[[package]] +name = "mockall_double" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1ca96e5ac35256ae3e13536edd39b172b88f41615e1d7b653c8ad24524113e8" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "nalgebra" version = "0.33.2" diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 2e42382..e47e4b3 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -43,6 +43,7 @@ rand = "0.9.2" approx = "0.5" hex-literal = "0.4" mockall = "0.13.1" +mockall_double = "0.3.1" rstest = "0.26" tempfile = "3.13" diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 25e716c..db30ac2 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -1,3 +1,191 @@ //! Adjust system clock and clockbound shared memory pub mod clock_adjust; pub mod clock_state_writer; + +use tracing::error; + +use crate::daemon::clock_state::clock_adjust::NtpAdjTimeError; +#[cfg_attr(test, mockall_double::double)] +use crate::daemon::clock_state::{ + clock_adjust::ClockAdjuster, clock_state_writer::ClockStateWriter, +}; +use crate::daemon::io::tsc::ReadTscImpl; +use crate::daemon::time::clocks::RealTime; +use crate::{ + daemon::{ + clock_parameters::ClockParameters, + clock_state::clock_adjust::NtpAdjTime, + time::{ClockExt, clocks::ClockBound}, + }, + shm::ShmWrite, +}; + +/// The whole `ClockState` component struct. +/// This encompasses both `ClockAdjust` component which interfaces +/// with the `CLOCK_REALTIME` kernel clock to synchronize it with `ClockBound` estimate +/// of UTC (`ClockBound` clock), and `ClockStateWriter` which manages writing +/// the `ClockErrorBound` to SHM segment for the client to read. +pub struct ClockState { + clock_state_writer: ClockStateWriter, + clock_adjuster: ClockAdjuster, +} + +impl ClockState { + pub fn new(clock_state_writer: ClockStateWriter, clock_adjust: ClockAdjuster) -> Self { + Self { + clock_state_writer, + clock_adjuster: clock_adjust, + } + } + + /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. + /// + /// # Panics + /// If the `ClockAdjuster` fails, e.g. an invalid value was supplied to `ntp_adjtime`, or + /// insufficient permissions to adjust the clock. + pub fn handle_clock_parameters( + &mut self, + // This is needed to tell ClockAdjust what frequency to use. + clock_parameters: &ClockParameters, + ) { + let clockbound_clock = ClockBound::new(clock_parameters.clone(), ReadTscImpl); + let clock_realtime_offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); + match self + .clock_adjuster + .handle_clock_parameters(clock_parameters, clock_realtime_offset_and_rtt) + { + failed_adjtime @ Err(NtpAdjTimeError::Failure(_)) => { + failed_adjtime.unwrap(); + } + Err(unexpected_adjtime_status) => { + error!("Unexpected adjtime result: {unexpected_adjtime_status}"); + } + Ok(_) => self + .clock_state_writer + .handle_clock_parameters(clock_parameters, clock_realtime_offset_and_rtt), + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + daemon::{ + clock_parameters::ClockParameters, + clock_state::{ + ClockState, + clock_adjust::{MockClockAdjuster, NoopClockAdjuster, NtpAdjTimeError}, + clock_state_writer::MockClockStateWriter, + }, + time::{ + Duration, Instant, TscCount, + inner::ClockOffsetAndRtt, + instant::Utc, + timex::Timex, + tsc::{Period, Skew}, + }, + }, + shm::ShmWriter, + }; + + fn get_sample_clock_parameters() -> ClockParameters { + ClockParameters { + tsc_count: TscCount::new(0), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period: Period::from_seconds(0.0), + period_max_error: Period::from_seconds(0.0), + } + } + + #[test] + fn handle_clock_parameters() { + let clock_parameters = get_sample_clock_parameters(); + let clock_parameters_clone = clock_parameters.clone(); + let mut mock_clock_adjuster: MockClockAdjuster = + MockClockAdjuster::new(); + mock_clock_adjuster + .expect_handle_clock_parameters() + .times(1) + .withf( + move |clock_params: &ClockParameters, _offset_and_rtt: &ClockOffsetAndRtt| { + *clock_params == clock_parameters_clone + }, + ) + .return_once(|_, _| { + Ok(Timex::clock_adjustment() + .phase_correction(Duration::from(0)) + .skew(Skew::from_ppm(0.0)) + .call()) + }); + + let clock_parameters_clone = clock_parameters.clone(); + let mut mock_clock_state_writer: MockClockStateWriter = + MockClockStateWriter::new(); + mock_clock_state_writer + .expect_handle_clock_parameters() + .once() + .withf( + move |clock_params: &ClockParameters, _offset_and_rtt: &ClockOffsetAndRtt| { + *clock_params == clock_parameters_clone + }, + ) + .return_const(()); + + let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); + clock_state.handle_clock_parameters(&clock_parameters); + } + + #[test] + #[should_panic] + fn handle_clock_parameters_clock_adjust_hard_failure() { + let clock_parameters = get_sample_clock_parameters(); + let clock_parameters_clone = clock_parameters.clone(); + let mut mock_clock_adjuster: MockClockAdjuster = + MockClockAdjuster::new(); + mock_clock_adjuster + .expect_handle_clock_parameters() + .times(1) + .withf( + move |clock_params: &ClockParameters, _offset_and_rtt: &ClockOffsetAndRtt| { + *clock_params == clock_parameters_clone + }, + ) + .return_once(|_, _| Err(NtpAdjTimeError::Failure(errno::errno()))); + + let mut mock_clock_state_writer: MockClockStateWriter = + MockClockStateWriter::new(); + mock_clock_state_writer + .expect_handle_clock_parameters() + .never(); + + let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); + clock_state.handle_clock_parameters(&clock_parameters); + } + + #[test] + fn handle_clock_parameters_clock_adjust_soft_failure() { + let clock_parameters = get_sample_clock_parameters(); + let clock_parameters_clone = clock_parameters.clone(); + let mut mock_clock_adjuster: MockClockAdjuster = + MockClockAdjuster::new(); + mock_clock_adjuster + .expect_handle_clock_parameters() + .times(1) + .withf( + move |clock_params: &ClockParameters, _offset_and_rtt: &ClockOffsetAndRtt| { + *clock_params == clock_parameters_clone + }, + ) + .return_once(|_, _| Err(NtpAdjTimeError::BadState(0))); + + let mut mock_clock_state_writer: MockClockStateWriter = + MockClockStateWriter::new(); + mock_clock_state_writer + .expect_handle_clock_parameters() + .never(); + + let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); + clock_state.handle_clock_parameters(&clock_parameters); + } +} diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index bf66b8e..a4611f4 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -4,7 +4,10 @@ use libc::{TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT, ntp_adj use thiserror::Error; use tracing::debug; -use crate::daemon::time::{Duration, timex::Timex, tsc::Skew}; +use crate::daemon::{ + clock_parameters::ClockParameters, + time::{Duration, inner::ClockOffsetAndRtt, instant::Utc, timex::Timex, tsc::Skew}, +}; /// Error type returned when dealing with underlying `adjtimex` or `ntp_adjtime` /// results. @@ -48,11 +51,48 @@ pub trait NtpAdjTime { pub struct ClockAdjuster { ntp_adjtime: T, + should_step: bool, +} + +#[cfg(test)] +mockall::mock! { + pub ClockAdjuster { + pub fn handle_clock_parameters( + &mut self, + // This is needed to tell ClockAdjust what frequency to use. + _clock_parameters: &ClockParameters, + // This is needed to tell ClockAdjust what phase offset to use. + clock_realtime_offset_and_rtt: ClockOffsetAndRtt, + ) -> Result; + } } impl ClockAdjuster { pub fn new(ntp_adjtime: T) -> Self { - Self { ntp_adjtime } + // Should step on first adjustment. + let should_step = true; + Self { + ntp_adjtime, + should_step, + } + } + + /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. + /// + /// # Errors + /// This method returns [`NtpAdjTimeError`] if the call has failed or has an unexpected return code. + pub fn handle_clock_parameters( + &mut self, + // This is needed to tell ClockAdjust what frequency to use. + _clock_parameters: &ClockParameters, + // This is needed to tell ClockAdjust what phase offset to use. + clock_realtime_offset_and_rtt: ClockOffsetAndRtt, + ) -> Result { + if self.should_step { + self.step_clock(clock_realtime_offset_and_rtt.offset()) + } else { + self.adjust_clock(clock_realtime_offset_and_rtt.offset(), Skew::from_ppm(0.0)) + } } /// Performs an adjustment of the clock, to apply the given phase correction @@ -89,7 +129,7 @@ impl ClockAdjuster { /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno /// `NtpAdjTimeError::BadState` if some state other than `TIME_ERROR` is returned from `ntp_adjtime` /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` - pub fn step_clock(&self, phase_correction: Duration) -> Result { + pub fn step_clock(&mut self, phase_correction: Duration) -> Result { let mut tx = Timex::clock_step() .phase_correction(phase_correction) .call(); @@ -99,7 +139,11 @@ impl ClockAdjuster { // that indicates the clock is now "unsynchronized" (expected after we step the clock // discontinuously) match self.ntp_adjtime.ntp_adjtime(&mut tx) { - TIME_ERROR => Ok(tx), + TIME_ERROR => { + // Step was successful, so we should not step again. + self.should_step = false; + Ok(tx) + } cs @ (TIME_OK | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { Err(NtpAdjTimeError::BadState(cs)) } @@ -127,6 +171,7 @@ mod test { ) { let mock_ntp_adj_time = MockNtpAdjTime::new(); let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + assert!(clock_adjuster.should_step); // Set up mock expectations clock_adjuster @@ -139,12 +184,14 @@ mod test { let result = clock_adjuster.adjust_clock(input_phase_correction, input_skew); assert!(result.is_ok()); + assert!(clock_adjuster.should_step); } #[test] fn adjust_clock_failure() { let mock_ntp_adj_time = MockNtpAdjTime::new(); let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + assert!(clock_adjuster.should_step); // Set up mock expectations clock_adjuster @@ -154,16 +201,20 @@ mod test { .return_const(-1); // Call adjust_clock with test values - let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), NtpAdjTimeError::Failure(_))); + assert!(matches!( + clock_adjuster + .adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)) + .unwrap_err(), + NtpAdjTimeError::Failure(_) + )); + assert!(clock_adjuster.should_step); } #[test] fn adjust_clock_bad_state() { let mock_ntp_adj_time = MockNtpAdjTime::new(); let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + assert!(clock_adjuster.should_step); // Set up mock expectations clock_adjuster @@ -173,16 +224,20 @@ mod test { .return_const(TIME_ERROR); // Call adjust_clock with test values - let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), NtpAdjTimeError::BadState(_))); + assert!(matches!( + clock_adjuster + .adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)) + .unwrap_err(), + NtpAdjTimeError::BadState(_) + )); + assert!(clock_adjuster.should_step); } #[test] fn adjust_clock_unexpected_value() { let mock_ntp_adj_time = MockNtpAdjTime::new(); let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + assert!(clock_adjuster.should_step); // Set up mock expectations clock_adjuster @@ -192,13 +247,13 @@ mod test { .return_const(12345); // Call adjust_clock with test values - let result = clock_adjuster.adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)); - - assert!(result.is_err()); assert!(matches!( - result.unwrap_err(), + clock_adjuster + .adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)) + .unwrap_err(), NtpAdjTimeError::InvalidState(_) )); + assert!(clock_adjuster.should_step); } #[rstest] @@ -208,6 +263,7 @@ mod test { fn step_clock_happy_paths(#[case] input_phase_correction: Duration) { let mock_ntp_adj_time = MockNtpAdjTime::new(); let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + assert!(clock_adjuster.should_step); // Set up mock expectations clock_adjuster @@ -217,8 +273,7 @@ mod test { .return_const(TIME_ERROR); // Call step_clock with test values - let result = clock_adjuster.step_clock(input_phase_correction); - - assert!(result.is_ok()); + clock_adjuster.step_clock(input_phase_correction).unwrap(); + assert!(!clock_adjuster.should_step); } } diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index 224b458..7ab7dbd 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -1,3 +1,4 @@ +//! Write clockbound shared memory and manage clock state use nix::sys::time::TimeSpec; use crate::{ @@ -25,6 +26,19 @@ pub struct ClockStateWriter { disruption_marker: u64, } +#[cfg(test)] +mockall::mock! { + pub ClockStateWriter { + pub fn handle_clock_parameters( + &mut self, + // This is needed to tell ClockAdjust what frequency to use. + _clock_parameters: &ClockParameters, + // This is needed to tell ClockAdjust what phase offset to use. + clock_realtime_offset_and_rtt: ClockOffsetAndRtt, + ); + } +} + #[bon::bon] impl ClockStateWriter { #[builder] @@ -43,6 +57,8 @@ impl ClockStateWriter { } } + /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. + /// /// # Panics /// Panics if error bound calculated exceeds `i64::MAX` pub fn handle_clock_parameters( diff --git a/test/clock-bound-adjust-clock/src/step_clock.rs b/test/clock-bound-adjust-clock/src/step_clock.rs index 35aabaa..8060d8c 100644 --- a/test/clock-bound-adjust-clock/src/step_clock.rs +++ b/test/clock-bound-adjust-clock/src/step_clock.rs @@ -25,7 +25,7 @@ fn main() -> anyhow::Result<()> { let initial_time: DateTime = Utc::now(); println!("Initial time is {initial_time:?}"); - let clock_adjuster = ClockAdjuster::new(KAPIClockAdjuster); + let mut clock_adjuster = ClockAdjuster::new(KAPIClockAdjuster); clock_adjuster .step_clock(phase_correction) .map_err(|e| anyhow::anyhow!(e))?; From 6a4425c4da7a8eff2856c652fa5b7b28f35de068 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 30 Oct 2025 19:35:15 -0400 Subject: [PATCH 062/177] [ff::ntp] Implement ff::Ntp::feed (#76) Current testing is minimal. Upcoming PRs will incorporate testing via ff-tester and side-by-side testing --- clock-bound/src/daemon.rs | 19 +- .../src/daemon/clock_sync_algorithm.rs | 2 +- .../clock_sync_algorithm/ff/event_buffer.rs | 2 +- .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 212 ++++++++++++++++-- .../clock_sync_algorithm/source/link_local.rs | 13 +- clock-bound/src/daemon/time/tsc.rs | 5 + 6 files changed, 224 insertions(+), 29 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index e4a3ef2..4b9d15c 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -16,7 +16,20 @@ pub mod event; pub mod receiver_stream; -use crate::daemon::clock_sync_algorithm::ClockSyncAlgorithm; +use crate::daemon::{clock_sync_algorithm::ClockSyncAlgorithm, time::tsc::Skew}; + +/// The maximum dispersion growth every second +/// +/// Whenever a clock error bound measurement is made, that value increases by this value +/// for every second that the measurement becomes stale. +/// +/// If the value is 15,000 parts per billion, for example, then every second we go without an +/// updated measurement the clock error bound will increase by 15 microseconds. +/// +/// This number is based on CPU spec sheet error tolerances +const MAX_DISPERSION_GROWTH_PBB: u32 = 15_000; + +const MAX_DISPERSION_GROWTH: Skew = Skew::from_ppb(MAX_DISPERSION_GROWTH_PBB as f64); pub struct Daemon { _io_front_end: io::SourceIO, @@ -33,7 +46,9 @@ impl Daemon { let mut io_front_end = io::SourceIO::construct(); let clock_sync_algorithm = ClockSyncAlgorithm::builder() - .link_local(clock_sync_algorithm::source::LinkLocal::new()) + .link_local(clock_sync_algorithm::source::LinkLocal::new( + MAX_DISPERSION_GROWTH, + )) .build(); // FIXME, we are basically starting the application in the constructor diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 0c51577..716e792 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -34,7 +34,7 @@ pub struct ClockSyncAlgorithm { impl ClockSyncAlgorithm { /// Feed event into the link local /// TODO: make this function private and call into it from `fn feed` when we have a routable event - pub fn feed_link_local(&mut self, event: event::Ntp) -> Option { + pub fn feed_link_local(&mut self, event: event::Ntp) -> Option<&ClockParameters> { self.link_local.feed(event) } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs index 7e5b3af..7517b3e 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs @@ -1,7 +1,7 @@ //! Local and Estimate ring buffers used within a Feed Forward Clock Sync Algorithm mod local; -pub use local::Local; +pub use local::{FeedError, Local}; mod estimate; pub use estimate::Estimate; diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 7df07b4..2fb581e 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -5,6 +5,7 @@ use std::num::NonZeroUsize; use super::event_buffer; use crate::daemon::{ clock_parameters::ClockParameters, + clock_sync_algorithm::ff::event_buffer::FeedError, clock_sync_algorithm::{ff::UncorrectedClock, ring_buffer::Quarter}, event::{self, TscRtt}, time::{ @@ -22,8 +23,13 @@ pub struct Ntp { estimate: event_buffer::Estimate, /// Current calculation of [`ClockParameters`] clock_parameters: Option, - /// Current TSC period estimate - period_estimate: Option, + /// Current uncorrected clock + uncorrected_clock: Option, + /// Max dispersion growth + /// + /// Used to compare clock error bounds with older samples while + /// taking into account CEB growth from dispersion + max_dispersion: Skew, } impl Ntp { @@ -32,23 +38,106 @@ impl Ntp { /// `local_capacity` should be the number of data-points to span an SKM window. /// For example, if the source is expected to sample once every second, the `local_capacity` /// should have a max value of 1024. - pub fn new(_local_capacity: NonZeroUsize) -> Self { - todo!() - // Self { - // local: event_buffer::Local::new(local_capacity), - // estimate: event_buffer::Estimate::new(), - // clock_parameters: None, - // period_estimate: None, - // } + pub fn new(local_capacity: NonZeroUsize, max_dispersion: Skew) -> Self { + Self { + local: event_buffer::Local::new(local_capacity), + estimate: event_buffer::Estimate::new(), + clock_parameters: None, + uncorrected_clock: None, + max_dispersion, + } } /// Feed an event into this algorithm /// /// Returns [`Some`] if the event has improved this source's [`ClockParameters`]. - #[expect(clippy::needless_pass_by_value, reason = "impl will remove this lint")] - pub fn feed(&mut self, event: event::Ntp) -> Option { - tracing::debug!(?event, "feed"); - None + #[expect( + clippy::missing_panics_doc, + reason = "panics documented and only occur from bugs" + )] + pub fn feed(&mut self, event: event::Ntp) -> Option<&ClockParameters> { + let tsc_midpoint = event.tsc_midpoint(); + + // First update the internal local (current SKM) and estimate (long term) + // sample buffers + self.feed_internal_buffers(event) + .inspect_err(|error_msg| match error_msg { + FeedError::Old { event, .. } => { + tracing::warn!(?event, ?error_msg); + } + }) + .ok()?; // early exit only if there was an error with the sample + + // Functionality from this point will fill out the equation + // `C(t) = TSC(t) × p^ + K − θ^(t)` where: + // - `C(t)` is the absolute time. Corrected. This is effectively the output of the clock sync algorithm + // - `TSC(t)` is the tsc reading at a time + // - `p^` is the estimation of the clock period + // - `K` is the "epoch" (the uncorrected time at `TSC(0)`) + // - `θ^(t)` is the time correction + + // Calculate uncorrected clock, aka `p^` and `K` + self.uncorrected_clock = Self::calculate_uncorrected_clock(&self.local, &self.estimate); + + // Then calculate the local period using just the local event buffer + // + // This value is not used in the above equation, but IS reported in the final clock parameters + let Some(local_period) = Self::calculate_local_period_and_error(&self.local) else { + tracing::debug!("Early exit. Calculate local period returned none"); + return None; + }; + + // expect because not having an uncorrected clock is a bug at this point + // + // If we are able to calculate a local period, then uncorrected clock must be available + let uncorrected_clock = self + .uncorrected_clock + .expect("No uncorrected period but we have local period"); + + // Calculate `θ^(t)` + let CalculateThetaOutput { + theta, + clock_error_bound, + } = Self::calculate_theta(&self.local, local_period.period_local, uncorrected_clock); + + // Calculate `C(t)` + // + // `uncorrected_clock.time_at(tsc)` is a function that calculates `TSC(t) × p^ + K` + // So this expands to `time = TSC(t) × p^ + K - θ^(t)` + // + // uses `tsc_midpoint` as that is the value that was used during the `Self::calculate_theta` fn + let time = uncorrected_clock.time_at(tsc_midpoint) - theta; + + let clock_parameters = ClockParameters { + tsc_count: tsc_midpoint, + time, + clock_error_bound, + period: local_period.period_local, + period_max_error: local_period.error, + }; + + match &mut self.clock_parameters { + None => { + // This is the first time we have calculated clock parameters. Set it. + tracing::info!(?clock_parameters, "Clock Parameters initialized"); + self.clock_parameters = Some(clock_parameters); + self.clock_parameters.as_ref() + } + Some(current_clock_parameters) + if clock_parameters + .more_accurate_than(current_clock_parameters, self.max_dispersion) => + { + // We currently have clock_parameters, and the new value is more accurate. Replace it. + tracing::debug!(?clock_parameters, "Clock Parameters updated"); + *current_clock_parameters = clock_parameters; + self.clock_parameters.as_ref() + } + Some(current_clock_parameters) => { + // We currently have clock_parameters, and the new value is NOT more accurate. Ignore and return None. + tracing::debug!(new_clock_parameters = ?clock_parameters, ?current_clock_parameters, "Clock Parameters not updated"); + None + } + } } /// Get the current [`ClockParameters`] @@ -56,6 +145,23 @@ impl Ntp { self.clock_parameters.as_ref() } + /// Feed the internal buffers with a new event + /// + /// This updates the local buffer with the event. If a `period` has been calculated already, + /// then we can use this value to update the `estimate` buffer as well. + #[expect(clippy::result_large_err, reason = "value moved on err is idiomatic")] + fn feed_internal_buffers(&mut self, event: event::Ntp) -> Result<(), FeedError> { + self.local.feed(event)?; + + if let Some(uc) = self.uncorrected_clock { + self.local.expunge_old_events(uc.p_estimate); + if let Some(new_estimate) = self.estimate.feed(&self.local, uc.p_estimate) { + tracing::info!(?new_estimate, "New value added to estimate buffer"); + } + } + Ok(()) + } + /// Handle a disruption event /// /// Clears all event buffers and prior-calculations. @@ -65,13 +171,14 @@ impl Ntp { local, estimate, clock_parameters, - period_estimate, + uncorrected_clock, + max_dispersion: _, // value currently does not change } = self; local.handle_disruption(); estimate.handle_disruption(); *clock_parameters = None; - *period_estimate = None; + *uncorrected_clock = None; } /// Calculate the estimate period and k value based off of the ring buffers @@ -228,6 +335,21 @@ impl Ntp { /// - `K` is the "epoch" (the uncorrected time at `TSC(0)`) /// - `θ^(t)` is the time correction /// + /// Calculation requires calculating + /// ```text + /// ∑{ wᵢ × (offsetᵢ + skew × p̂ × (TSCₚₒₛₜ,ₗₐₛₜ − TSCₚₒₛₜ,ᵢ)) + /// θ̂(tₗₐₛₜ) = --------------------------------------------------------- + /// ∑{wᵢ} + /// ``` + /// + /// where: + /// ```text + /// wᵢ = exp(−√(Eᵢ/E)) + /// and + /// Eᵢ = RTTᵢ − min(RTT) + /// ``` + /// + /// /// # Panics /// Panics if `Local` is empty #[expect( @@ -667,4 +789,62 @@ mod tests { event1.calculate_clock_error_bound(local_period) ); } + + #[test] + fn feed_two_events() { + let mut ff = Ntp::new(NonZeroUsize::new(5).unwrap(), Skew::from_ppm(15.0)); + + let event1 = event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + + Duration::from_secs(1) + + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(1) + + Duration::from_nanos(900), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(27), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + let event2 = event::Ntp::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + + Duration::from_secs(2) + + Duration::from_nanos(100), + server_send_time: Instant::from_days(1) + + Duration::from_secs(2) + + Duration::from_nanos(900), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + let result = ff.feed(event1.clone()); + assert!(result.is_none()); + + let result = ff.feed(event2.clone()); + let clock_params = result.unwrap(); + + let expected_time = event2 + .data() + .server_recv_time + .midpoint(event2.data().server_send_time); + assert_eq!(clock_params.time, expected_time); + + // events are symmetric RTT and at 1GHz + approx::assert_abs_diff_eq!(clock_params.period.get(), 1e-9); + + // clock_error_bound should be max value + let expected_ceb = event1.calculate_clock_error_bound(clock_params.period); + assert_eq!(clock_params.clock_error_bound, expected_ceb); + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs index 697c4ee..cccc152 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs @@ -6,6 +6,7 @@ use crate::daemon::clock_parameters::ClockParameters; use crate::daemon::clock_sync_algorithm::ff::{self, event_buffer}; use crate::daemon::event; use crate::daemon::time::Duration; +use crate::daemon::time::tsc::Skew; /// A Link Local reference clock source /// @@ -28,15 +29,15 @@ impl LinkLocal { }; /// Create a new Link Local reference clock source - pub fn new() -> Self { + pub fn new(max_dispersion: Skew) -> Self { Self { - inner: ff::Ntp::new(Self::CAPACITY), + inner: ff::Ntp::new(Self::CAPACITY, max_dispersion), } } /// Feed an event into the link local NTP clock-sync algorithm #[tracing::instrument(level = "info", skip_all)] - pub fn feed(&mut self, event: event::Ntp) -> Option { + pub fn feed(&mut self, event: event::Ntp) -> Option<&ClockParameters> { self.inner.feed(event) } @@ -50,9 +51,3 @@ impl LinkLocal { inner.handle_disruption(); } } - -impl Default for LinkLocal { - fn default() -> Self { - Self::new() - } -} diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 4b47798..8e29894 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -261,6 +261,11 @@ impl Skew { Self(skew * Self::PPM) } + /// Construct a new skew from parts per billion (ppb) + pub const fn from_ppb(skew: f64) -> Self { + Self(skew * Self::PPM * 1_000.0) + } + /// Construct a new skew from percentage pub const fn from_percent(skew: f64) -> Self { Self(skew * Self::PERCENT) From 04b6802a9505cd8f4c025c28064fc590b7956f8b Mon Sep 17 00:00:00 2001 From: tphan25 Date: Fri, 31 Oct 2025 09:39:00 -0400 Subject: [PATCH 063/177] Implement `ClockAdjustTestParameters` and `reset_clock` (#60) To execute the test, we need to be able to estimate what the actual offset of the clock looks like based on our clock adjustments. This is implemented in `ClockAdjustTestParameters`, where we model the PLL and the skew correction components. This commit implements this functionality as well as a `reset_clock` function to be used between tests to end any ongoing slew/frequency corrections. --- Cargo.lock | 1 + test/clock-bound-adjust-clock-test/Cargo.toml | 5 +- .../src/adjust_clock_test.rs | 353 +++++++++++++++++- 3 files changed, 348 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a2d882..39027b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,6 +293,7 @@ name = "clock-bound-adjust-clock-test" version = "2.0.3" dependencies = [ "clock-bound", + "rstest 0.26.1", "tokio", "tracing", "tracing-subscriber", diff --git a/test/clock-bound-adjust-clock-test/Cargo.toml b/test/clock-bound-adjust-clock-test/Cargo.toml index c8242c3..7005e88 100644 --- a/test/clock-bound-adjust-clock-test/Cargo.toml +++ b/test/clock-bound-adjust-clock-test/Cargo.toml @@ -21,6 +21,9 @@ path = "src/adjust_clock_test.rs" clock-bound = { version = "2.0", path = "../../clock-bound", features = [ "daemon", ] } -tokio = { version = "1.47.1", features = ["macros", "rt"] } +tokio = { version = "1.47.1", features = ["macros", "rt", "test-util"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } + +[dev-dependencies] +rstest = "0.26" diff --git a/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs b/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs index ddb359d..a330f11 100644 --- a/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs +++ b/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs @@ -81,7 +81,11 @@ //! phase correction and skew correction parameters as we expect to via calls to that API, and seeing //! the `CLOCK_REALTIME` adjustment is steered precisely to where we would expect it to be. #![allow(clippy::doc_comment_double_space_linebreaks, reason = "hooray ascii")] -use clock_bound::daemon::time::{Duration, Instant, tsc::Skew}; +use clock_bound::daemon::{ + clock_state::{ClockAdjuster, KAPIClockAdjuster, NtpAdjTimeError}, + time::{Duration, Instant, tsc::Skew}, +}; +use tracing::info; #[tokio::main(flavor = "current_thread")] async fn main() { @@ -97,14 +101,15 @@ struct ClockAdjustTestParameters { /// Phase correction passed into `ClockAdjust` phase_correction: Duration, /// Start time of the test/clock adjustment - /// /// We need this to extrapolate the change in clock offsets - /// at some future `Instant`. + /// at some future `Instant`. Notably, since this is calculated + /// separately from when the kernel ACTUALLY processes our clock adjustment, + /// there is some inherent error. It may be possible to reduce that error if we + /// grab this `start_time` from the `ClockAdjust`'s underlying `adjtimex` output `time` + /// instead. start_time: Instant, /// Initial offset of the `CLOCK_REALTIME` and `CLOCK_MONOTONIC` at - /// test start. - /// - /// We need this to extrapolate the change in clock offsets + /// test start. We need this to extrapolate the change in clock offsets /// at some future `Instant`. start_offset: Duration, } @@ -167,14 +172,342 @@ impl ClockAdjustTestParameters { clippy::cast_precision_loss, reason = "`current_pll_correction_nanos` is not expected to be large enough to cause precision loss" )] - fn expected_offset_change_due_to_phase_correction_at(&self, _time: Instant) -> Duration { - todo!("implement me"); + fn expected_offset_change_due_to_phase_correction_at(&self, time: Instant) -> Duration { + // PLL correction begins at top of a second - we calculate when this PLL start time `time_since_pll_start` was based on the start time, + // and also calculate the `Duration` that has passed between `time` and `time_since_pll_start`. + let start_time_fractional_nanos = self.get_start_time().as_nanos() % 1_000_000_000; + let time_to_next_second = 1_000_000_000 - start_time_fractional_nanos; + let pll_start_time = self.get_start_time() + Duration::from_nanos(time_to_next_second); + let time_since_pll_start = time - pll_start_time; + + // Only work with positive values to make our lives easier. We'll deal with it after. + let mut remaining_correction = if self.phase_correction.as_femtos() < 0 { + -self.phase_correction + } else { + self.phase_correction + }; + + // Based on the initial `phase_correction` passed in, we emulate what the kernel is doing - + // take a bitshifted portion of the `remaining_correction` and subtract that each second. + let mut duration_of_correction = Duration::from_secs(0); + while duration_of_correction < time_since_pll_start { + // Bitshifted PLL correction, which is calculated in kernel as + // `RemainingOffset` >> 2 + `TimeConstant` (another kernel clock adjustment parameter, which we use 0 for today). + let current_pll_correction_nanos = remaining_correction.as_nanos() >> 2; + let duration_of_current_pll_correction = time_since_pll_start - duration_of_correction; + // Consume a full second of correction if we can, + // else consume the fractional portion of the second remaining + if time_since_pll_start - duration_of_correction >= Duration::from_secs(1) { + remaining_correction -= Duration::from_nanos(current_pll_correction_nanos); + duration_of_correction += Duration::from_secs(1); + } else { + let correction = (current_pll_correction_nanos as f64 + * duration_of_current_pll_correction.as_seconds_f64()) + / 1e9; + remaining_correction -= Duration::from_seconds_f64(correction); + break; + } + } + + // `remaining_correction` is positive. If initial `phase_correction` was negative, we should add it, + // else subtract it. + if self.phase_correction.as_femtos() < 0 { + self.phase_correction + remaining_correction + } else { + self.phase_correction - remaining_correction + } } /// Returns the expected change in the offset between `CLOCK_REALTIME` and `CLOCK_MONOTONIC_RAW` based on the /// `skew` factor. This is much more straightforward - `freq` in the kernel is applied immediately, so we can /// just do a simple linear extrapolation of the expected time. - fn expected_offset_change_due_to_skew_at(&self, _time: Instant) -> Duration { - todo!("implement me"); + #[allow( + clippy::cast_possible_truncation, + reason = "fine to truncate when we're working at nanosecond granularity for Linux kernel" + )] + #[allow( + clippy::cast_precision_loss, + reason = "scale of `time_since_start` should be small enough that precision is negligible" + )] + fn expected_offset_change_due_to_skew_at(&self, time: Instant) -> Duration { + let time_since_start = time - self.get_start_time(); + let time_in_nanos = time_since_start.as_nanos(); + // This may introduce some loss in precision, but over the duration of these tests + // and because of the nature of the measurement of the offset of the clocks, this is OK + let time_in_nanos_adjusted_for_skew = time_in_nanos as f64 * self.skew.get(); + Duration::from_nanos(time_in_nanos_adjusted_for_skew as i128) + } +} + +/// Reset the system clock, by clearing any `skew` and `phase_correction` running in the kernel. +/// Give some buffer time by sleeping after this too. +#[allow(dead_code)] +async fn reset_clock() -> Result<(), NtpAdjTimeError> { + info!("Resetting clock parameters.."); + let clock_adjuster = ClockAdjuster::new(KAPIClockAdjuster); + // Reset the kernel NTP parameters. + let phase_correction = Duration::from_millis(0); + let skew = Skew::from_ppm(0.0); + clock_adjuster.adjust_clock(phase_correction, skew)?; + // Ensure any ongoing slews are done + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + const FEMTOS_PER_SEC: i128 = 1_000_000_000_000_000; + + struct OffsetChangeSnapshot { + pub time_since_start: Duration, + pub offset_change: Duration, + } + + #[rstest] + #[case::zero_phase_correction( + Duration::from_secs(0), + Instant::from(FEMTOS_PER_SEC / 2), + Duration::from_secs(0), + vec![ + OffsetChangeSnapshot { time_since_start: Duration::from_secs(0), offset_change: Duration::from_secs(0) }, + OffsetChangeSnapshot { time_since_start: Duration::from_secs(10), offset_change: Duration::from_secs(0) }, + OffsetChangeSnapshot { time_since_start: Duration::from_secs(100), offset_change: Duration::from_secs(0) }, + ], + )] + #[case::big_positive_phase_correction( + Duration::from_millis(500), // Correct 0.5 seconds + Instant::from(FEMTOS_PER_SEC / 2), // Start mid second + Duration::from_secs(0), + vec![ + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500), // Skip to next second + offset_change: Duration::from_secs(0) + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500) + Duration::from_secs(1), + offset_change: Duration::from_nanos(125_000_000), // 0.125 seconds corrected + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500) + Duration::from_secs(2), + offset_change: Duration::from_nanos(218_750_000), // ~0.219 seconds corrected + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500) + Duration::from_secs(10), + offset_change: Duration::from_nanos(471_843_242),// ~0.471 seconds corrected + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500) + Duration::from_secs(20), + offset_change: Duration::from_nanos(498_414_392),// ~0.498 seconds corrected + }, + ], + )] + #[case::big_negative_phase_correction( + -Duration::from_millis(500), // Correct 0.5 seconds + Instant::from(FEMTOS_PER_SEC / 2), // Start mid second + Duration::from_secs(0), + vec![ + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500), // Skip to next second + offset_change: Duration::from_secs(0) + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500) + Duration::from_secs(1), + offset_change: -Duration::from_nanos(125_000_000), // 0.125 seconds corrected + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500) + Duration::from_secs(2), + offset_change: -Duration::from_nanos(218_750_000), // ~0.219 seconds corrected + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500) + Duration::from_secs(10), + offset_change: -Duration::from_nanos(471_843_242),// ~0.471 seconds corrected + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500) + Duration::from_secs(20), + offset_change: -Duration::from_nanos(498_414_392),// ~0.498 seconds corrected + }, + ], + )] + fn test_expected_offset_change_due_to_phase_correction_at( + #[case] phase_correction: Duration, + #[case] start_time: Instant, + #[case] start_offset: Duration, + #[case] expected_changes_in_offset: Vec, + ) { + let params = ClockAdjustTestParameters::new( + Skew::from_ppm(0.0), + phase_correction, + start_offset, + start_time, + ); + + for expected_change in expected_changes_in_offset { + let time = params.get_start_time() + expected_change.time_since_start; + let res = params.expected_offset_change_due_to_phase_correction_at(time); + assert_eq!(res, expected_change.offset_change); + } + } + + #[rstest] + #[case::zero_skew( + Skew::from_ppm(0.0), + Instant::from(FEMTOS_PER_SEC / 2), + Duration::from_secs(0), + vec![ + OffsetChangeSnapshot { time_since_start: Duration::from_secs(0), offset_change: Duration::from_secs(0) }, + OffsetChangeSnapshot { time_since_start: Duration::from_secs(1), offset_change: Duration::from_secs(0) }, + OffsetChangeSnapshot { time_since_start: Duration::from_secs(10), offset_change: Duration::from_secs(0) }, + OffsetChangeSnapshot { time_since_start: Duration::from_secs(100), offset_change: Duration::from_secs(0) }, + ], + )] + #[case::positive_skew_100_ppm( + Skew::from_ppm(100.0), // 100 ppm positive skew + Instant::from(FEMTOS_PER_SEC / 2), + Duration::from_secs(0), + vec![ + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(0), + offset_change: Duration::from_secs(0) + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(1), + offset_change: Duration::from_nanos(100_000) // 1s * 100ppm = 100μs = 100,000ns + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(10), + offset_change: Duration::from_nanos(1_000_000) // 10s * 100ppm = 1ms = 1,000,000ns + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(100), + offset_change: Duration::from_nanos(10_000_000) // 100s * 100ppm = 10ms = 10,000,000ns + }, + ], + )] + #[case::negative_skew_50_ppm( + Skew::from_ppm(-50.0), // 50 ppm negative skew + Instant::from(FEMTOS_PER_SEC / 2), + Duration::from_secs(0), + vec![ + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(0), + offset_change: Duration::from_secs(0) + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(1), + offset_change: Duration::from_nanos(-50_000) // 1s * -50ppm = -50μs = -50,000ns + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(10), + offset_change: Duration::from_nanos(-500_000) // 10s * -50ppm = -0.5ms = -500,000ns + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(100), + offset_change: Duration::from_nanos(-5_000_000) // 100s * -50ppm = -5ms = -5,000,000ns + }, + ], + )] + #[case::large_positive_skew_1000_ppm( + Skew::from_ppm(1000.0), // 1000 ppm (0.1%) positive skew + Instant::from(FEMTOS_PER_SEC / 2), + Duration::from_secs(0), + vec![ + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(0), + offset_change: Duration::from_secs(0), + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(1), + offset_change: Duration::from_nanos(1_000_000) // 1s * 1000ppm = 1ms = 1,000,000ns + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(60), + offset_change: Duration::from_nanos(60_000_000) // 60s * 1000ppm = 60ms = 60,000,000ns + }, + ], + )] + #[case::fractional_time_positive_skew( + Skew::from_ppm(200.0), // 200 ppm positive skew + Instant::from(FEMTOS_PER_SEC / 2), + Duration::from_secs(0), + vec![ + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(0), + offset_change: Duration::from_millis(0) + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(500), // 0.5 seconds + offset_change: Duration::from_nanos(100_000) // 0.5s * 200ppm = 100μs = 100,000ns + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(1500), // 1.5 seconds + offset_change: Duration::from_nanos(300_000) // 1.5s * 200ppm = 300μs = 300,000ns + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(2750), // 2.75 seconds + offset_change: Duration::from_nanos(550_000) // 2.75s * 200ppm = 550μs = 550,000ns + }, + ], + )] + #[case::very_small_skew( + Skew::from_ppm(0.1), // Very small 0.1 ppm skew + Instant::from(FEMTOS_PER_SEC / 2), + Duration::from_secs(0), + vec![ + OffsetChangeSnapshot { + time_since_start: Duration::from_millis(0), + offset_change: Duration::from_millis(0) + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(1), + offset_change: Duration::from_nanos(100) // 1s * 0.1ppm = 0.1μs = 100ns + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(10), + offset_change: Duration::from_nanos(1_000) // 10s * 0.1ppm = 1μs = 1,000ns + }, + OffsetChangeSnapshot { + time_since_start: Duration::from_secs(1000), + offset_change: Duration::from_nanos(100_000) // 1000s * 0.1ppm = 100μs = 100,000ns + }, + ], + )] + fn test_expected_offset_change_due_to_skew_at( + #[case] skew: Skew, + #[case] start_time: Instant, + #[case] start_offset: Duration, + #[case] expected_changes_in_offset: Vec, + ) { + let params = ClockAdjustTestParameters::new( + skew, + Duration::from_secs(0), // No phase correction for skew tests + start_offset, + start_time, + ); + + for expected_change in expected_changes_in_offset { + let time = params.get_start_time() + expected_change.time_since_start; + let res = params.expected_offset_change_due_to_skew_at(time); + + // Allow for small floating point precision differences + let diff = if res > expected_change.offset_change { + res - expected_change.offset_change + } else { + expected_change.offset_change - res + }; + + assert!( + diff < Duration::from_nanos(10), + "Expected offset change: {}ns, got: {}ns, diff: {}ns (skew: {} ppm, time_since_start: {}s)", + expected_change.offset_change.as_nanos(), + res.as_nanos(), + diff.as_nanos(), + skew.get() * 1_000_000.0, // Convert to ppm for display + expected_change.time_since_start.as_seconds_f64() + ); + } } } From 7b6ff6606411764422614345b86ce37fdb8877a4 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Fri, 31 Oct 2025 11:26:31 -0400 Subject: [PATCH 064/177] Fix import path for `clock_adjust` in `adjust_clock_test.rs` (#80) ClockAdjust module got moved into a submodule of `clock_state`, which breaks the latest changes in the `adjust_clock_test.rs` (where CI ran before that change was made) --- test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs b/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs index a330f11..8cfeb06 100644 --- a/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs +++ b/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs @@ -82,7 +82,7 @@ //! the `CLOCK_REALTIME` adjustment is steered precisely to where we would expect it to be. #![allow(clippy::doc_comment_double_space_linebreaks, reason = "hooray ascii")] use clock_bound::daemon::{ - clock_state::{ClockAdjuster, KAPIClockAdjuster, NtpAdjTimeError}, + clock_state::clock_adjust::{ClockAdjuster, KAPIClockAdjuster, NtpAdjTimeError}, time::{Duration, Instant, tsc::Skew}, }; use tracing::info; From 6093c38e290a4cc8d881dd11174b5d5298d960a4 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 31 Oct 2025 15:12:15 -0400 Subject: [PATCH 065/177] Fix rounding error on period calculation (#83) Previously, calculation would do integer based division. Although we have femto seconds, this still leads to incorrect answers. Do the calculation as floats. This leads to rounding error to be based on f64 precision, and not on anything specific to the integers. --- clock-bound/src/daemon/time/tsc.rs | 39 ++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 8e29894..e2e9ab3 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -104,10 +104,18 @@ impl std::str::FromStr for TscDiff { /// and this includes period values of ticks . This means it is possible /// to store frequency values that will have precision loss when converted into /// a `period` type and vice versa. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +#[derive(Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(transparent)] pub struct Frequency(f64); +impl std::fmt::Debug for Frequency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Frequency") + .field(&format_args!("{:.17E}", self.0)) + .finish() + } +} + impl Frequency { /// Get inner value in hz pub fn get(self) -> f64 { @@ -353,10 +361,18 @@ impl std::str::FromStr for Skew { /// ## Note on zero valued periods /// While not logical for clocks, it can come up for error calculations currently. /// FIXME, does a zero error ever make sense? -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +#[derive(Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(transparent)] pub struct Period(f64); +impl std::fmt::Debug for Period { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Period") + .field(&format_args!("{:.17E}", self.0)) + .finish() + } +} + impl Period { /// Construct from seconds /// @@ -418,8 +434,8 @@ impl Div for Duration { type Output = Period; fn div(self, rhs: TscDiff) -> Self::Output { - let period = self.as_femtos() / rhs.get(); - Period::from_duration(Duration::from_femtos(period)) + let period = self.as_seconds_f64() / rhs.get() as f64; + Period::from_seconds(period) } } @@ -428,7 +444,7 @@ impl Div for Duration { fn div(self, rhs: Period) -> Self::Output { let diff = self.as_seconds_f64() / rhs.get(); - TscDiff::new(diff as i128) // truncate + TscDiff::new(diff.round() as i128) } } @@ -710,4 +726,17 @@ mod tests { let diff = TscDiff::new(1_000_000_000); assert_eq!(format!("{diff:?}"), "TscDiff(1000000000)"); } + + #[test] + fn duration_div_by_tsc_diff() { + let expected_period = 1.0 / 3.3e9; // 3.3GHz + let one_second = 1.0_f64; + let tsc_diff = one_second / expected_period; + let tsc_diff = TscDiff::new(tsc_diff.round() as i128); + + let one_second = Duration::from_seconds_f64(one_second); + let period = one_second / tsc_diff; + + approx::assert_abs_diff_eq!(period.get(), expected_period); + } } From 620db1805e8ae51016fa3beec2e369cfe7951da0 Mon Sep 17 00:00:00 2001 From: mk <55758543+mekabir@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:10:19 -0500 Subject: [PATCH 066/177] Add thread-safe storage of the selected clock & its stratum (#86) This commit adds the `SelectedClockSource` struct and impls which allow the sharing of the current selected clock source and its stratum among threads for reading and writing. Co-authored-by: MOHAMMED KABIR --- clock-bound/src/daemon.rs | 2 + clock-bound/src/daemon/selected_clock.rs | 229 +++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 clock-bound/src/daemon/selected_clock.rs diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 4b9d15c..4abd76c 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -16,6 +16,8 @@ pub mod event; pub mod receiver_stream; +pub mod selected_clock; + use crate::daemon::{clock_sync_algorithm::ClockSyncAlgorithm, time::tsc::Skew}; /// The maximum dispersion growth every second diff --git a/clock-bound/src/daemon/selected_clock.rs b/clock-bound/src/daemon/selected_clock.rs new file mode 100644 index 0000000..d7559fa --- /dev/null +++ b/clock-bound/src/daemon/selected_clock.rs @@ -0,0 +1,229 @@ +//! Thread-safe storage for the currently selected clock source + +use std::{ + fmt::Display, + net::Ipv4Addr, + sync::atomic::{AtomicU64, Ordering}, +}; + +use crate::daemon::event::Stratum; + +/// Thread-safe storage for the currently selected clock source and its stratum +/// +/// Uses atomic operations to store both the clock source reference ID and stratum +/// in a single 64-bit value for lock-free access across threads. +#[derive(Debug)] +pub struct SelectedClockSource { + /// Bits: 63-40 | 39-32 | 31-0 + /// unused| stratum| refid + source_info: AtomicU64, +} + +impl SelectedClockSource { + /// Get the current clock source and stratum + /// + /// Returns a tuple of (`ClockSource`, `Stratum`) representing the current state. + pub fn get(&self) -> (ClockSource, Stratum) { + let packed = self.source_info.load(Ordering::Relaxed); + let refid = (packed & 0xFFFF_FFFF) as u32; + let stratum = + Stratum::try_from(((packed >> 32) & 0xFF) as u8).unwrap_or(Stratum::Unspecified); + + (Self::params_to_source(refid, stratum), stratum) + } + + /// Set the clock source to PHC + pub fn phc(&self) { + self.set(ClockSource::Phc, Stratum::Unspecified); + } + + /// Set the clock source to a remote NTP server + pub fn server(&self, ip: Ipv4Addr, stratum: Stratum) { + self.set(ClockSource::Server(ip), stratum); + } + + /// Set the clock source to unsynchronized state + pub fn none(&self) { + self.set(ClockSource::None, Stratum::Unsynchronized); + } + + /// Set the clock source to VMClock + pub fn vmclock(&self) { + self.set(ClockSource::VMClock, Stratum::Unspecified); + } + + fn params_to_source(refid: u32, stratum: Stratum) -> ClockSource { + match stratum { + Stratum::Unspecified => { + // Stratum 0 - interpret as kiss codes + match refid { + v if v == u32::from_be_bytes(*b"INIT") => ClockSource::Init, + v if v == u32::from_be_bytes(*b"XPHC") => ClockSource::Phc, + v if v == u32::from_be_bytes(*b"XVMC") => ClockSource::VMClock, + _ => ClockSource::Init, // Defensively fallback to INIT; + // Shouldn't occur given the restricted API + } + } + Stratum::Unsynchronized => ClockSource::None, + Stratum::Level(_) => ClockSource::Server(Ipv4Addr::from(refid)), + } + } + + fn set(&self, source: ClockSource, stratum: Stratum) { + let packed = (u64::from(u8::from(stratum)) << 32) | u64::from(u32::from(source)); + self.source_info.store(packed, Ordering::Relaxed); + } +} + +impl Display for SelectedClockSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (source, stratum) = self.get(); + write!(f, "{} (stratum {})", source, u8::from(stratum)) + } +} + +impl Default for SelectedClockSource { + fn default() -> Self { + let packed = (u64::from(u8::from(Stratum::Unspecified)) << 32) + | u64::from(u32::from(ClockSource::Init)); + Self { + source_info: AtomicU64::new(packed), + } + } +} + +/// Clock source types +/// +/// Represents different types of time sources that may be used for clock synchronization +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClockSource { + /// Initial state, never synchronized + Init, + /// Lost synchronization + None, + /// PTP Hardware Clock + Phc, + /// Remote NTP server + Server(Ipv4Addr), + /// Time and clock frequency from Linux hypervisor + VMClock, +} + +impl From for u32 { + fn from(source: ClockSource) -> u32 { + match source { + ClockSource::Init => u32::from_be_bytes(*b"INIT"), + ClockSource::None => 0, + ClockSource::Phc => u32::from_be_bytes(*b"XPHC"), + ClockSource::Server(addr) => u32::from(addr), + ClockSource::VMClock => u32::from_be_bytes(*b"XVMC"), + } + } +} + +impl Display for ClockSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClockSource::Init => write!(f, "INIT"), + ClockSource::None => write!(f, "None"), + ClockSource::Phc => write!(f, "PHC"), + ClockSource::Server(addr) => write!(f, "{addr}"), + ClockSource::VMClock => write!(f, "VMClock"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[test] + fn default_creates_init_state() { + let clock = SelectedClockSource::default(); + let (source, stratum) = clock.get(); + + assert_eq!(source, ClockSource::Init); + assert_eq!(stratum, Stratum::Unspecified); + } + + #[rstest] + #[case(ClockSource::Init, Stratum::Unspecified)] + #[case(ClockSource::Phc, Stratum::Unspecified)] + #[case(ClockSource::VMClock, Stratum::Unspecified)] + #[case(ClockSource::None, Stratum::Unsynchronized)] + #[case(ClockSource::Server("192.168.1.1".parse().unwrap()), Stratum::TWO)] + fn set_and_get_roundtrip(#[case] source: ClockSource, #[case] stratum: Stratum) { + let clock = SelectedClockSource::default(); + clock.set(source.clone(), stratum); + + let (read_source, read_stratum) = clock.get(); + assert_eq!(read_source, source); + assert_eq!(read_stratum, stratum); + } + + #[test] + fn convenience_methods() { + let clock = SelectedClockSource::default(); + + // Test PHC + clock.phc(); + let (source, stratum) = clock.get(); + assert_eq!(source, ClockSource::Phc); + assert_eq!(stratum, Stratum::Unspecified); + + // Test VMClock + clock.vmclock(); + let (source, stratum) = clock.get(); + assert_eq!(source, ClockSource::VMClock); + assert_eq!(stratum, Stratum::Unspecified); + + // Test Server + let ip = "169.254.169.123".parse().unwrap(); + clock.server(ip, Stratum::ONE); + let (source, stratum) = clock.get(); + assert_eq!(source, ClockSource::Server(ip)); + assert_eq!(stratum, Stratum::ONE); + + // Test Unsynchronized + clock.none(); + let (source, stratum) = clock.get(); + assert_eq!(source, ClockSource::None); + assert_eq!(stratum, Stratum::Unsynchronized); + } + + #[rstest] + #[case(ClockSource::Init, "INIT")] + #[case(ClockSource::None, "None")] + #[case(ClockSource::Phc, "PHC")] + #[case(ClockSource::VMClock, "VMClock")] + #[case(ClockSource::Server("192.168.1.1".parse().unwrap()), "192.168.1.1")] + fn clock_source_display(#[case] source: ClockSource, #[case] expected: &str) { + assert_eq!(source.to_string(), expected); + } + + #[rstest] + #[case(ClockSource::Init, 0x494E_4954)] // "INIT" + #[case(ClockSource::None, 0)] + #[case(ClockSource::Phc, 0x5850_4843)] // "XPHC" + #[case(ClockSource::VMClock, 0x5856_4D43)] // "XVMC" + #[case(ClockSource::Server("192.168.1.1".parse().unwrap()), 0xC0A8_0101)] + fn clock_source_to_u32(#[case] source: ClockSource, #[case] expected: u32) { + assert_eq!(u32::from(source), expected); + } + + #[test] + fn selected_clock_source_display() { + let clock = SelectedClockSource::default(); + assert_eq!(clock.to_string(), "INIT (stratum 0)"); + + clock.phc(); + assert_eq!(clock.to_string(), "PHC (stratum 0)"); + + clock.server("169.254.169.123".parse().unwrap(), Stratum::ONE); + assert_eq!(clock.to_string(), "169.254.169.123 (stratum 1)"); + + clock.none(); + assert_eq!(clock.to_string(), "None (stratum 16)"); + } +} From d92721f5715c5a5b09893ab35ba546785aec63bb Mon Sep 17 00:00:00 2001 From: mk <55758543+mekabir@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:19:15 -0500 Subject: [PATCH 067/177] Generate daemon metadata and pass it to IO land (#85) This commit generates daemon metadata - major and minor version as well as ephemeral id generated on every startup - and pass it to the IO task for use in 0xFEC2. Co-authored-by: MOHAMMED KABIR --- Cargo.lock | 2 ++ clock-bound/src/daemon.rs | 14 ++++++++++++-- clock-bound/src/daemon/io.rs | 15 +++++++++++++-- clock-bound/src/daemon/io/ntp.rs | 2 +- clock-bound/src/daemon/io/ntp/packet.rs | 2 +- clock-bound/src/daemon/io/ntp_source.rs | 5 ++++- test/link-local/Cargo.toml | 1 + test/link-local/src/main.rs | 11 +++++++++-- test/ntp-source/Cargo.toml | 1 + test/ntp-source/src/main.rs | 10 +++++++++- 10 files changed, 53 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39027b3..2d8b2b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -709,6 +709,7 @@ name = "link-local" version = "2.0.3" dependencies = [ "clock-bound", + "rand 0.9.2", "tokio", "tracing-subscriber", ] @@ -873,6 +874,7 @@ name = "ntp-source" version = "2.0.3" dependencies = [ "clock-bound", + "rand 0.9.2", "tokio", "tracing-subscriber", ] diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 4abd76c..17004b2 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -18,7 +18,11 @@ pub mod receiver_stream; pub mod selected_clock; -use crate::daemon::{clock_sync_algorithm::ClockSyncAlgorithm, time::tsc::Skew}; +use crate::daemon::{ + clock_sync_algorithm::ClockSyncAlgorithm, io::ntp::DaemonInfo, time::tsc::Skew, +}; + +use rand::{RngCore, rng}; /// The maximum dispersion growth every second /// @@ -45,7 +49,13 @@ impl Daemon { pub async fn construct() -> Self { let (tx, rx) = async_ring_buffer::create(2); - let mut io_front_end = io::SourceIO::construct(); + let daemon_info = DaemonInfo { + major_version: 2, + minor_version: 100, + startup_id: rng().next_u64(), + }; + + let mut io_front_end = io::SourceIO::construct(daemon_info); let clock_sync_algorithm = ClockSyncAlgorithm::builder() .link_local(clock_sync_algorithm::source::LinkLocal::new( diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 4d538de..e15c661 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -13,6 +13,7 @@ use tokio::task::spawn; use tracing::{debug, info, warn}; pub mod ntp; +use crate::daemon::io::ntp::DaemonInfo; use crate::daemon::{async_ring_buffer, event}; mod link_local; @@ -39,11 +40,13 @@ pub struct SourceIO { vmclock: Option>, /// Contains the channel used to communicate clock disruption events. clock_disruption_channels: ClockDisruptionChannels, + /// Daemon metadata + daemon_info: DaemonInfo, } impl SourceIO { /// Constructs a new `SourceIO` object and constructs the necessary resources. - pub fn construct() -> Self { + pub fn construct(daemon_info: DaemonInfo) -> Self { let (sender, receiver) = watch::channel::(ClockDisruptionEvent::default()); SourceIO { @@ -51,6 +54,7 @@ impl SourceIO { ntp_sources: HashMap::new(), vmclock: None, clock_disruption_channels: ClockDisruptionChannels { sender, receiver }, + daemon_info, } } @@ -117,6 +121,7 @@ impl SourceIO { event_sender, ctrl_receiver, clock_disruption_receiver, + self.daemon_info.clone(), ); let source = Source { @@ -311,7 +316,13 @@ mod tests { async fn source_io_verify_link_local_creation() { let (event_sender, _) = async_ring_buffer::create::(1); - let mut source_io = SourceIO::construct(); + let info = DaemonInfo { + major_version: 2, + minor_version: 100, + startup_id: 0xABCD_BCDE_CDEF_DEFA, + }; + + let mut source_io = SourceIO::construct(info); source_io.create_link_local(event_sender).await; assert!(source_io.link_local.is_some()) diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index 92250d5..acd24d3 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -4,7 +4,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use tokio::time::Duration; pub mod packet; -pub use packet::Packet; +pub use packet::{Fec2V1Value as DaemonInfo, Packet}; pub const UNSPECIFIED_SOCKET_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); pub const LINK_LOCAL_ADDRESS: SocketAddrV4 = diff --git a/clock-bound/src/daemon/io/ntp/packet.rs b/clock-bound/src/daemon/io/ntp/packet.rs index b9ccdff..d0c875e 100644 --- a/clock-bound/src/daemon/io/ntp/packet.rs +++ b/clock-bound/src/daemon/io/ntp/packet.rs @@ -22,7 +22,7 @@ mod header; mod short; mod timestamp; -pub use extension::ExtensionField; +pub use extension::{ExtensionField, Fec2V1Value}; pub use header::{LeapIndicator, Mode, Version}; pub use short::Short; pub use timestamp::Timestamp; diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index cff1df8..8ae0a98 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -15,7 +15,7 @@ use super::tsc::read_timestamp_counter; use crate::daemon::{ async_ring_buffer, event::{self, NtpData}, - io::{ClockDisruptionEvent, ControlRequest}, + io::{ClockDisruptionEvent, ControlRequest, DaemonInfo}, time::tsc::TscCount, }; @@ -48,6 +48,7 @@ pub struct NTPSource { clock_disruption_receiver: watch::Receiver, ntp_buffer: [u8; Packet::SIZE], interval: Interval, + daemon_info: DaemonInfo, } impl NTPSource { @@ -58,6 +59,7 @@ impl NTPSource { event_sender: async_ring_buffer::Sender, ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, + daemon_info: DaemonInfo, ) -> Self { let mut ntp_source_interval = interval(NTP_SOURCE_INTERVAL_DURATION); ntp_source_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); @@ -69,6 +71,7 @@ impl NTPSource { clock_disruption_receiver, ntp_buffer: [0u8; Packet::SIZE], interval: ntp_source_interval, + daemon_info, } } diff --git a/test/link-local/Cargo.toml b/test/link-local/Cargo.toml index 7221571..336518b 100644 --- a/test/link-local/Cargo.toml +++ b/test/link-local/Cargo.toml @@ -20,5 +20,6 @@ path = "src/main.rs" clock-bound = { version = "2.0", path = "../../clock-bound", features = [ "daemon", ] } +rand = "0.9.2" tokio = { version = "1.47.1", features = ["macros", "rt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index c9c1d0b..e1998ba 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -3,10 +3,11 @@ //! This executable tests that the link local runner is able to send and receive packets from the //! link local address and that the polling rate is roughly once a second. -use clock_bound::daemon::async_ring_buffer; use clock_bound::daemon::io::SourceIO; +use clock_bound::daemon::{async_ring_buffer, io::ntp::DaemonInfo}; use std::time; +use rand::{RngCore, rng}; use tokio::time::{Duration, timeout}; use tracing_subscriber::EnvFilter; @@ -21,7 +22,13 @@ async fn main() { let mut start = time::Instant::now(); - let mut sourceio = SourceIO::construct(); + let daemon_info = DaemonInfo { + major_version: 2, + minor_version: 100, + startup_id: rng().next_u64(), + }; + + let mut sourceio = SourceIO::construct(daemon_info); sourceio.create_link_local(link_local_sender).await; sourceio.spawn_all(); diff --git a/test/ntp-source/Cargo.toml b/test/ntp-source/Cargo.toml index e4c4122..bad1bcf 100644 --- a/test/ntp-source/Cargo.toml +++ b/test/ntp-source/Cargo.toml @@ -20,5 +20,6 @@ path = "src/main.rs" clock-bound = { version = "2.0", path = "../../clock-bound", features = [ "daemon", ] } +rand = "0.9.2" tokio = { version = "1.47.1", features = ["macros", "rt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/ntp-source/src/main.rs b/test/ntp-source/src/main.rs index 92df6bb..3ef8a1e 100644 --- a/test/ntp-source/src/main.rs +++ b/test/ntp-source/src/main.rs @@ -6,11 +6,13 @@ use std::time::Duration; use clock_bound::daemon::async_ring_buffer; +use clock_bound::daemon::io::ntp::DaemonInfo; use clock_bound::daemon::io::{ SourceIO, ntp::{FIRST_PUBLIC_TIME_ADDRESS, SECOND_PUBLIC_TIME_ADDRESS}, }; +use rand::{RngCore, rng}; use tracing_subscriber::EnvFilter; #[tokio::main(flavor = "current_thread")] @@ -23,7 +25,13 @@ async fn main() { let (first_ntp_source_sender, first_ntp_source_receiver) = async_ring_buffer::create(1); let (second_ntp_source_sender, second_ntp_source_receiver) = async_ring_buffer::create(1); - let mut sourceio = SourceIO::construct(); + let daemon_info = DaemonInfo { + major_version: 2, + minor_version: 100, + startup_id: rng().next_u64(), + }; + + let mut sourceio = SourceIO::construct(daemon_info); // Create IO time sources sourceio From 37b2a6e4c36a272442e547e0e4c968e2579d94ce Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 3 Nov 2025 11:26:23 -0500 Subject: [PATCH 068/177] bugfix: small negative values had wrong sign in fmt::Debug (#78) --- clock-bound/src/daemon/time/inner.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 5e446a6..cccfcf2 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -638,12 +638,14 @@ fn debug_femtos( prefix: &'static str, femtos: i128, ) -> std::fmt::Result { + let sign = if femtos < 0 { "-" } else { "" }; + let femtos = femtos.abs(); let secs = femtos / FEMTOS_PER_SEC; let nanos = (femtos / FEMTOS_PER_NANO - secs * 1_000_000_000).abs(); if nanos == 0 { return f .debug_tuple(prefix) - .field(&format_args!("{secs}.0")) + .field(&format_args!("{sign}{secs}.0")) .finish(); } let millis = nanos / 1_000_000; @@ -652,16 +654,16 @@ fn debug_femtos( if nanos == 0 && micros == 0 { return f .debug_tuple(prefix) - .field(&format_args!("{secs}.{millis:0>3}")) + .field(&format_args!("{sign}{secs}.{millis:0>3}")) .finish(); } if nanos == 0 { return f .debug_tuple(prefix) - .field(&format_args!("{secs}.{millis:0>3}_{micros:0>3}")) + .field(&format_args!("{sign}{secs}.{millis:0>3}_{micros:0>3}")) .finish(); } - let formatted = format_args!("{secs}.{millis:0>3}_{micros:0>3}_{nanos:0>3}"); + let formatted = format_args!("{sign}{secs}.{millis:0>3}_{micros:0>3}_{nanos:0>3}"); f.debug_tuple(prefix).field(&formatted).finish() } @@ -987,6 +989,7 @@ mod test { #[case(Duration::from_seconds_f64(-1.123456), "Duration(-1.123_456)")] #[case(Duration::from_secs(0), "Duration(0.0)")] #[case(Duration::from_picos(6500), "Duration(0.000_000_006)")] + #[case(Duration::from_micros(-1500), "Duration(-0.001_500)")] #[case(Duration::from_picos(50), "Duration(0.0)")] fn duration_debug(#[case] duration: Duration, #[case] expected: &str) { assert_eq!(format!("{duration:?}"), expected); @@ -1000,6 +1003,7 @@ mod test { #[case(Instant::from_micros(-1123456), "Instant(-1.123_456)")] #[case(Instant::from_secs(0), "Instant(0.0)")] #[case(Instant::from_picos(6500), "Instant(0.000_000_006)")] + #[case(Instant::from_nanos(-1500), "Instant(-0.000_001_500)")] #[case(Instant::from_picos(50), "Instant(0.0)")] fn instant_debug(#[case] instant: Instant, #[case] expected: &str) { assert_eq!(format!("{instant:?}"), expected); From 141043e0ef438e51733b56858aba868c04bd537b Mon Sep 17 00:00:00 2001 From: Myles N <95256483+nelomsmn@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:11:41 -0500 Subject: [PATCH 069/177] Adding "ff::source::NTPSource"s to ClockSyncAlgorithm struct (#71) **Adds a vector of ntp sources into the clocksync algorithm struct.** Changes include: **Adding "ff::source::NTPSource" struct which wraps the algorithm for a single ntp source** - Adding a vector of "ff::source::NTPSource" objects to the "ClockSyncAlgorithm" struct - Updated `ClockSyncAlgorithm` to handle disruption for `NTPSource`s - Created method to initialize temp aws public time sources `clock_sync_algorithm::source::NTPSource::create_time_aws_sources()` - Updated daemon construction to initialize ClockSyncAlgorithm with default public time sources. - Updated daemon to receive `RoutableEvent`s from a `ReceiverStream`. - updating `Daemon` struct to hold a prop of type `ReceiverStream` instead of reference to individual IO receivers. - Adding `feed_ntp_source()` method to `ClockSyncAlgorithm` struct to allow for routing received events to the appropriate NTPSource Algorithm buffer --- **Reworking ntp source async ring buffer initialization in daemon.rs** - Added get_ntp_source_buffers() function - Added `NtpSourceSender` and `NtpSourceReceiver` types to hold socket address and ring buffer components during initialization - Updated SourceIO `create_ntp_source()` to accept `NTPSourceSender` - Updated Receiver stream builder to accept `NTPSourceReceiver` - Added unit tests for adding and removing ntp-sources from a ReceiverStream. - Add "Send" trait to `EventStream` type to allow for ReceiverStream.recv() to be used within Daemon.run() future. **updated ntp constants to replace "PUBLIC_TIME_ADDRESS" constants with list named "AWS_TEMP_PUBLIC_TIME_ADDRESSES".** - updated references to first and second "PUBLIC_TIME_ADDRESS" constant - Updated `ntp-source-test` to use new Public Time constants --- clock-bound/src/daemon.rs | 83 ++++++-- .../src/daemon/clock_sync_algorithm.rs | 29 ++- .../src/daemon/clock_sync_algorithm/source.rs | 2 + .../clock_sync_algorithm/source/ntp_source.rs | 74 ++++++++ clock-bound/src/daemon/io.rs | 7 +- clock-bound/src/daemon/io/ntp.rs | 16 +- clock-bound/src/daemon/receiver_stream.rs | 179 ++++++++++++------ test/ntp-source/README.md | 4 +- test/ntp-source/src/main.rs | 62 +++--- 9 files changed, 336 insertions(+), 120 deletions(-) create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 17004b2..cf75928 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -19,7 +19,10 @@ pub mod receiver_stream; pub mod selected_clock; use crate::daemon::{ - clock_sync_algorithm::ClockSyncAlgorithm, io::ntp::DaemonInfo, time::tsc::Skew, + clock_sync_algorithm::{ClockSyncAlgorithm, source::NTPSource}, + io::ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, + receiver_stream::{ReceiverStream, RoutableEvent}, + time::tsc::Skew, }; use rand::{RngCore, rng}; @@ -40,29 +43,42 @@ const MAX_DISPERSION_GROWTH: Skew = Skew::from_ppb(MAX_DISPERSION_GROWTH_PBB as pub struct Daemon { _io_front_end: io::SourceIO, clock_sync_algorithm: ClockSyncAlgorithm, - link_local_receiver: async_ring_buffer::Receiver, + receiver_stream: ReceiverStream, } impl Daemon { /// Construct and initialize a new daemon /// FIXME: Make this function not async. (Currently required for the io.run methods) pub async fn construct() -> Self { - let (tx, rx) = async_ring_buffer::create(2); - let daemon_info = DaemonInfo { major_version: 2, minor_version: 100, startup_id: rng().next_u64(), }; - let mut io_front_end = io::SourceIO::construct(daemon_info); - let clock_sync_algorithm = ClockSyncAlgorithm::builder() .link_local(clock_sync_algorithm::source::LinkLocal::new( MAX_DISPERSION_GROWTH, )) + .ntp_sources( + clock_sync_algorithm::source::NTPSource::create_time_aws_sources( + MAX_DISPERSION_GROWTH, + ), + ) .build(); + // Initializing async ring buffers for IO event delivery + let (link_local_tx, link_local_rx) = async_ring_buffer::create(2); + let (ntp_source_event_senders, ntp_source_event_receivers) = + Self::init_ntp_source_buffers(2, &clock_sync_algorithm.ntp_sources); + + // Initializing receiver stream with IO ring buffer receivers + let receiver_stream: ReceiverStream = ReceiverStream::builder() + .link_local_receiver(link_local_rx) + .ntp_source_receiver_vec(ntp_source_event_receivers) + .build(); + + let mut io_front_end = io::SourceIO::construct(daemon_info); // FIXME, we are basically starting the application in the constructor // We should be able to construct the link local and spawn it when `run` is called // @@ -74,14 +90,18 @@ impl Daemon { // let link_local = io::LinkLocal::construct(rx, ..Args); // let io_front_end = SourceIo::builder().link_local(tx, link_local).build(); // ``` - io_front_end.create_link_local(tx).await; - // TODO: Refactor so that the construction and spawning occurs in separate functions. + io_front_end.create_link_local(link_local_tx).await; + for source in ntp_source_event_senders { + io_front_end.create_ntp_source(source).await; + } + + // Start IO polling io_front_end.spawn_all(); Self { _io_front_end: io_front_end, clock_sync_algorithm, - link_local_receiver: rx, + receiver_stream, } } @@ -90,14 +110,51 @@ impl Daemon { loop { // TODO: add live migration watch and statements tokio::select! { - Ok(event) = self.link_local_receiver.recv() => { - self.handle_event(event); + Some(routable_event) = self.receiver_stream.recv() => { + self.handle_event(routable_event); } } } } - fn handle_event(&mut self, event: event::Ntp) { - self.clock_sync_algorithm.feed_link_local(event); + fn handle_event(&mut self, routable_event: RoutableEvent) { + match routable_event { + RoutableEvent::LinkLocalEvent(event) => { + self.clock_sync_algorithm.feed_link_local(event); + } + RoutableEvent::NTPSourceEvent(sender_address, event) => { + self.clock_sync_algorithm + .feed_ntp_source(sender_address, &event); + } + RoutableEvent::PhcEvent => { + todo!("Implement PHC IO event delivery") + } + } + } + + /// Takes in a vector of `source::NTPSource` structs and returns the `async_ring_buffer` senders and receivers + /// for each `ntp_source`'s IO event delivery. + /// # Parameters + /// - `ntp_source_buffer_size`: The size of ring buffer to create for each source + /// - `ntp_source_vec`: a pointer to a vector of `source::NTPSource` + /// + /// # Returns + /// - A tuple containing two lists: + /// > 1. `sender_vec`: Vector of `NTPSourceSender` + /// > 2. `receiver_vec`: Vector of `NTPSourceReceiver` + fn init_ntp_source_buffers( + ntp_source_buffer_size: usize, + ntp_source_vec: &Vec, + ) -> (Vec, Vec) { + let mut sender_vec: Vec = Vec::new(); + let mut receiver_vec: Vec = Vec::new(); + + for source in ntp_source_vec { + let (tx, rx) = async_ring_buffer::create(ntp_source_buffer_size); + sender_vec.push((source.socket_address(), tx)); + receiver_vec.push((source.socket_address(), rx)); + } + + (sender_vec, receiver_vec) } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 716e792..bf78a64 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -7,6 +7,9 @@ pub mod ff; mod ring_buffer; + +use std::net::SocketAddr; + pub use ring_buffer::RingBuffer; use crate::daemon::{clock_parameters::ClockParameters, event}; @@ -29,6 +32,8 @@ pub mod source; pub struct ClockSyncAlgorithm { /// The link-local reference clock's ff algorithm link_local: source::LinkLocal, + // A Vector of ff algorithms for ntp source reference clocks + pub ntp_sources: Vec, } impl ClockSyncAlgorithm { @@ -38,6 +43,22 @@ impl ClockSyncAlgorithm { self.link_local.feed(event) } + /// Feed event into ntp source + /// TODO: make this function private and call into it from `fn feed` when we have a routable event + pub fn feed_ntp_source( + &mut self, + sender_address: SocketAddr, + event: &event::Ntp, + ) -> Option<&ClockParameters> { + let mut clock_parameters: Option<&ClockParameters> = None; + for source in &mut self.ntp_sources { + if source.socket_address() == sender_address { + clock_parameters = source.feed(event.clone()); + } + } + clock_parameters + } + /// Handle a clock disruption event /// /// Call this function after the system detects a VMClock disruption event. @@ -47,9 +68,15 @@ impl ClockSyncAlgorithm { // Use the destructure pattern to get a mutable reference to each item. // // This makes it a compilation error if we add a new field this Self without handling it here - let Self { link_local } = self; + let Self { + link_local, + ntp_sources, + } = self; link_local.handle_disruption(); + for source in ntp_sources { + source.handle_disruption(); + } tracing::info!("Handled clock disruption event"); } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source.rs b/clock-bound/src/daemon/clock_sync_algorithm/source.rs index a91961d..1385e93 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source.rs @@ -8,5 +8,7 @@ //! concerns between different source types. mod link_local; +mod ntp_source; pub use link_local::LinkLocal; +pub use ntp_source::NTPSource; diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs new file mode 100644 index 0000000..e49b4a3 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs @@ -0,0 +1,74 @@ +//! NTP Source source + +use std::net::SocketAddr; +use std::num::NonZeroUsize; + +use crate::daemon::clock_parameters::ClockParameters; +use crate::daemon::clock_sync_algorithm::ff::{self, event_buffer}; +use crate::daemon::event; +use crate::daemon::io::ntp::AWS_TEMP_PUBLIC_TIME_ADDRESSES; +use crate::daemon::time::Duration; +use crate::daemon::time::tsc::Skew; + +/// A NTP Server reference clock source +/// +/// Wraps around an NTP feed-forward clock-sync algorithm +#[derive(Debug, Clone, PartialEq)] +pub struct NTPSource { + source_address: SocketAddr, + inner: ff::Ntp, +} + +impl NTPSource { + const POLL_INTERVAL: Duration = Duration::from_secs(16); + + // Poll every 16 seconds. Capacity is 1024 / 16 = 64 + const CAPACITY: NonZeroUsize = { + let capacity = + event_buffer::Local::<()>::SKM_WINDOW.as_seconds() / Self::POLL_INTERVAL.as_seconds(); + // Check on this >>> + assert!(capacity > 0); + #[expect(clippy::cast_sign_loss)] + NonZeroUsize::new(capacity as usize).unwrap() + }; + + /// Create a new NTP Source reference clock source + pub fn new(source_address: SocketAddr, max_dispersion: Skew) -> Self { + Self { + source_address, + inner: ff::Ntp::new(Self::CAPACITY, max_dispersion), + } + } + + /// Create a vector of NTP time sources referencing our public time IPs for alpha + pub fn create_time_aws_sources(max_dispersion: Skew) -> Vec { + let mut sources: Vec = Vec::new(); + for address in AWS_TEMP_PUBLIC_TIME_ADDRESSES { + sources.push(Self { + source_address: address, + inner: ff::Ntp::new(Self::CAPACITY, max_dispersion), + }); + } + sources + } + + pub fn socket_address(&self) -> SocketAddr { + self.source_address + } + + /// Feed an event into the NTP Source clock-sync algorithm + #[tracing::instrument(level = "info", skip_all)] + pub fn feed(&mut self, event: event::Ntp) -> Option<&ClockParameters> { + self.inner.feed(event) + } + + /// Handle a disruption event. + /// + /// This clears the internal `ff` and any other state related to the local system's hardware + pub fn handle_disruption(&mut self) { + // Destructure pattern makes handling new fields mandatory + let Self { inner, .. } = self; + + inner.handle_disruption(); + } +} diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index e15c661..7dada4c 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -97,11 +97,8 @@ impl SourceIO { /// # Panics /// - If not called within the `tokio` runtime. /// - If socket binding fails. - pub async fn create_ntp_source( - &mut self, - server_address: SocketAddr, - event_sender: async_ring_buffer::Sender, - ) { + pub async fn create_ntp_source(&mut self, source: ntp::NTPSourceSender) { + let (server_address, event_sender) = source; info!( "Creating IO source from ntp server at {:#?}.", server_address.ip().to_string() diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index acd24d3..f16c556 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -6,15 +6,21 @@ use tokio::time::Duration; pub mod packet; pub use packet::{Fec2V1Value as DaemonInfo, Packet}; +use crate::daemon::{async_ring_buffer, event}; + pub const UNSPECIFIED_SOCKET_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); pub const LINK_LOCAL_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(169, 254, 169, 123), 123); pub const LINK_LOCAL_INTERVAL_DURATION: Duration = Duration::from_secs(1); pub const LINK_LOCAL_TIMEOUT: Duration = Duration::from_millis(100); - -pub const FIRST_PUBLIC_TIME_ADDRESS: std::net::SocketAddr = - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(166, 117, 111, 42)), 123); -pub const SECOND_PUBLIC_TIME_ADDRESS: std::net::SocketAddr = - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(3, 33, 186, 244)), 123); +pub const AWS_TEMP_PUBLIC_TIME_ADDRESSES: [SocketAddr; 2] = [ + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(166, 117, 111, 42)), 123), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(3, 33, 186, 244)), 123), +]; pub const NTP_SOURCE_INTERVAL_DURATION: Duration = Duration::from_secs(16); pub const NTP_SOURCE_TIMEOUT: Duration = Duration::from_millis(100); + +/// Tuple to hold both the `SocketAddr` and ring buffer `Sender` for an IO `NTPSource` +pub type NTPSourceSender = (SocketAddr, async_ring_buffer::Sender); +/// Tuple to hold both the `SocketAddr` and ring buffer `Receiver` for an IO `NTPSource` +pub type NTPSourceReceiver = (SocketAddr, async_ring_buffer::Receiver); diff --git a/clock-bound/src/daemon/receiver_stream.rs b/clock-bound/src/daemon/receiver_stream.rs index 810b7d7..e1dc92e 100644 --- a/clock-bound/src/daemon/receiver_stream.rs +++ b/clock-bound/src/daemon/receiver_stream.rs @@ -18,6 +18,8 @@ use tracing::info; use crate::daemon::async_ring_buffer::{BufferClosedError, Receiver}; use crate::daemon::event::{Event, Ntp}; +use super::io::ntp::NTPSourceReceiver; + #[derive(Debug, Error)] pub enum ReceiverStreamError { #[error("Failed to initialize ReceiverStream.")] @@ -26,7 +28,7 @@ pub enum ReceiverStreamError { /// Type to hold the stream produced from each `SourceIO` components receiver type EventStream<'a> = - Pin)> + 'a>>; + Pin)> + 'a + Send>>; pub struct ReceiverStream { ntp_sources: HashMap>, @@ -39,14 +41,14 @@ impl ReceiverStream { #[builder] pub fn new( link_local_receiver: Receiver, - ntp_source_vec: Vec<(SocketAddr, Receiver)>, + ntp_source_receiver_vec: Vec, ) -> Self { let mut rs = ReceiverStream { ntp_sources: HashMap::new(), link_local: link_local_receiver, }; - for (source_ip, source_receiver) in ntp_source_vec { - rs.add_ntp_source(source_ip, source_receiver); + for source in ntp_source_receiver_vec { + rs.add_ntp_source(source); } rs @@ -57,10 +59,19 @@ impl ReceiverStream { /// to ring buffers associated to separate `SourceIO` time sources. impl ReceiverStream { /// Adds a new ntp source to the `ntp_sources` - fn add_ntp_source(&mut self, id: SocketAddr, receiver: Receiver) { + pub fn add_ntp_source(&mut self, source: NTPSourceReceiver) { + let (id, receiver) = source; let _ = &self.ntp_sources.insert(SourceId::NTPSource(id), receiver); } + /// Removes an ntp source from `ntp_sources` + pub fn remove_ntp_source(&mut self, id: SocketAddr) { + let source_id = &SourceId::NTPSource(id); + if self.ntp_sources.contains_key(source_id) { + self.ntp_sources.remove(source_id); + } + } + /// Creates an aggregated stream of results from all `SourceIO` component `Receivers`. fn get_aggregate_stream(&mut self) -> SelectAll> { let mut streams: Vec> = Vec::new(); @@ -139,66 +150,110 @@ pub enum RoutableEvent { PhcEvent, } -#[tokio::test] -async fn receiver_stream_test() { - use std::net::{IpAddr, Ipv4Addr}; +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use super::*; use crate::daemon::async_ring_buffer::create; - use crate::daemon::event::{NtpData, Stratum}; + use crate::daemon::event::{Ntp, NtpData, Stratum}; use crate::daemon::time::{Duration, Instant, TscCount}; - let (link_local_tx, link_local_rx) = create(1); - - let (ntp_source_tx, ntp_source_rx) = create(1); - let dummy_ntp_source_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 123); - - let mut rx_stream = ReceiverStream::builder() - .link_local_receiver(link_local_rx) - .ntp_source_vec(vec![(dummy_ntp_source_ip, ntp_source_rx)]) - .build(); - - let dummy_ntp_data = Ntp::builder() - .tsc_pre(TscCount::new(1)) - .tsc_post(TscCount::new(2)) - .ntp_data(NtpData { - server_recv_time: Instant::new(1), - server_send_time: Instant::new(2), - root_delay: Duration::new(3), - root_dispersion: Duration::new(4), - stratum: Stratum::ONE, - }) - .build() - .unwrap(); - - let _ = link_local_tx.send(dummy_ntp_data.clone()); - let _ = ntp_source_tx.send(dummy_ntp_data.clone()); - let num_events = 2; - let mut counter = 0; - - for _ in 0..num_events { - match rx_stream.recv().await.unwrap() { - RoutableEvent::LinkLocalEvent(data) => { - counter += 1; - assert_eq!( - RoutableEvent::LinkLocalEvent(dummy_ntp_data.clone()), - RoutableEvent::LinkLocalEvent(data) - ); - } - RoutableEvent::NTPSourceEvent(ip, data) => { - counter += 1; - assert_eq!( - RoutableEvent::NTPSourceEvent(dummy_ntp_source_ip, dummy_ntp_data.clone()), - RoutableEvent::NTPSourceEvent(ip, data) - ); - } - RoutableEvent::PhcEvent => { - assert!(false, "Phc event delivery has yet to be implemented") - } - }; + #[tokio::test] + async fn receiver_stream() { + let (link_local_tx, link_local_rx) = create(1); + + let (ntp_source_tx, ntp_source_rx) = create(1); + let dummy_ntp_source_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 123); + + let mut rx_stream = ReceiverStream::builder() + .link_local_receiver(link_local_rx) + .ntp_source_receiver_vec(vec![(dummy_ntp_source_ip, ntp_source_rx)]) + .build(); + + let dummy_ntp_data = Ntp::builder() + .tsc_pre(TscCount::new(1)) + .tsc_post(TscCount::new(2)) + .ntp_data(NtpData { + server_recv_time: Instant::new(1), + server_send_time: Instant::new(2), + root_delay: Duration::new(3), + root_dispersion: Duration::new(4), + stratum: Stratum::ONE, + }) + .build() + .unwrap(); + + let _ = link_local_tx.send(dummy_ntp_data.clone()); + let _ = ntp_source_tx.send(dummy_ntp_data.clone()); + let num_events = 2; + let mut counter = 0; + + for _ in 0..num_events { + match rx_stream.recv().await.unwrap() { + RoutableEvent::LinkLocalEvent(data) => { + counter += 1; + assert_eq!( + RoutableEvent::LinkLocalEvent(dummy_ntp_data.clone()), + RoutableEvent::LinkLocalEvent(data) + ); + } + RoutableEvent::NTPSourceEvent(ip, data) => { + counter += 1; + assert_eq!( + RoutableEvent::NTPSourceEvent(dummy_ntp_source_ip, dummy_ntp_data.clone()), + RoutableEvent::NTPSourceEvent(ip, data) + ); + } + RoutableEvent::PhcEvent => { + assert!(false, "Phc event delivery has yet to be implemented") + } + }; + } + assert!( + counter.eq(&num_events), + "{}", + format!("{:#?} :: {:#?}", counter, num_events) + ); + } + + #[test] + fn add_ntp_source() { + let (_, link_local_rx) = create(1); + + let (_, ntp_source_rx) = create(1); + let dummy_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 123); + let dummy_ntp_source_receiver: NTPSourceReceiver = (dummy_address, ntp_source_rx); + + let mut rx_stream = ReceiverStream::builder() + .link_local_receiver(link_local_rx) + .ntp_source_receiver_vec(vec![]) + .build(); + + assert!(rx_stream.ntp_sources.is_empty()); + + rx_stream.add_ntp_source(dummy_ntp_source_receiver); + + assert!(rx_stream.ntp_sources.len() == 1); + } + + #[test] + fn remove_ntp_source() { + let (_, link_local_rx) = create(1); + let (_, ntp_source_rx) = create(1); + + let dummy_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 123); + let dummy_ntp_source_receiver: NTPSourceReceiver = (dummy_address, ntp_source_rx); + + let mut rx_stream = ReceiverStream::builder() + .link_local_receiver(link_local_rx) + .ntp_source_receiver_vec(vec![dummy_ntp_source_receiver]) + .build(); + + assert!(rx_stream.ntp_sources.len() == 1); + + rx_stream.remove_ntp_source(dummy_address); + + assert!(rx_stream.ntp_sources.is_empty()); } - assert!( - counter.eq(&num_events), - "{}", - format!("{:#?} :: {:#?}", counter, num_events) - ); } diff --git a/test/ntp-source/README.md b/test/ntp-source/README.md index 405b1dc..2399455 100644 --- a/test/ntp-source/README.md +++ b/test/ntp-source/README.md @@ -34,7 +34,7 @@ The output should look something like the following: $ ./ntp-source-test NTP Source creation complete! Lets get NTP packets! -Packet received from first NTP host -Packet received from second NTP host +Packet received from host (1 / 2) +Packet received from host (2 / 2) TEST COMPLETE ``` diff --git a/test/ntp-source/src/main.rs b/test/ntp-source/src/main.rs index 3ef8a1e..bf76a5f 100644 --- a/test/ntp-source/src/main.rs +++ b/test/ntp-source/src/main.rs @@ -3,14 +3,14 @@ //! This executable tests that the NTP Server runner is able to send and receive packets from the //! specified NTP Server address and that the polling rate is roughly once a second. -use std::time::Duration; - -use clock_bound::daemon::async_ring_buffer; -use clock_bound::daemon::io::ntp::DaemonInfo; +use clock_bound::daemon::async_ring_buffer::{self, Receiver}; +use clock_bound::daemon::event::Ntp; +use clock_bound::daemon::io::ntp::NTPSourceSender; use clock_bound::daemon::io::{ SourceIO, - ntp::{FIRST_PUBLIC_TIME_ADDRESS, SECOND_PUBLIC_TIME_ADDRESS}, + ntp::{AWS_TEMP_PUBLIC_TIME_ADDRESSES, DaemonInfo}, }; +use std::time::Duration; use rand::{RngCore, rng}; use tracing_subscriber::EnvFilter; @@ -21,10 +21,6 @@ async fn main() { .with_env_filter(EnvFilter::from_default_env()) .init(); - // Create ring buffer channels for ntp io sources - let (first_ntp_source_sender, first_ntp_source_receiver) = async_ring_buffer::create(1); - let (second_ntp_source_sender, second_ntp_source_receiver) = async_ring_buffer::create(1); - let daemon_info = DaemonInfo { major_version: 2, minor_version: 100, @@ -32,36 +28,38 @@ async fn main() { }; let mut sourceio = SourceIO::construct(daemon_info); + let mut receiver_vec: Vec> = Vec::new(); - // Create IO time sources - sourceio - .create_ntp_source(FIRST_PUBLIC_TIME_ADDRESS, first_ntp_source_sender) - .await; - sourceio - .create_ntp_source(SECOND_PUBLIC_TIME_ADDRESS, second_ntp_source_sender) - .await; + for address in AWS_TEMP_PUBLIC_TIME_ADDRESSES { + // Create ring buffer channels for ntp io sources + let (tx, rx) = async_ring_buffer::create(1); + receiver_vec.push(rx); + + // Create IO time sources + let sender_with_address: NTPSourceSender = (address, tx); + sourceio.create_ntp_source(sender_with_address).await; + } println!("NTP Source creation complete!"); // Get NTP packet from both specified servers println!("Lets get NTP packets!"); sourceio.spawn_all(); - let timeout_err_msg = &format!( - "Timeout was reached before a packet was received from {FIRST_PUBLIC_TIME_ADDRESS:#?}" - ); - let event_result = - tokio::time::timeout(Duration::from_secs(48), first_ntp_source_receiver.recv()) - .await - .expect(timeout_err_msg); - event_result.unwrap(); - println!("Packet received from first NTP host"); + for i in 0..AWS_TEMP_PUBLIC_TIME_ADDRESSES.len() { + let timeout_err_msg = &format!( + "Timeout was reached before a packet was received from {:#?}", + AWS_TEMP_PUBLIC_TIME_ADDRESSES[i] + ); - let timeout_err_msg = &format!( - "Timeout was reached before a packet was received from {SECOND_PUBLIC_TIME_ADDRESS:#?}" - ); - let event_result = - tokio::time::timeout(Duration::from_secs(48), second_ntp_source_receiver.recv()) + let event_result = tokio::time::timeout(Duration::from_secs(48), receiver_vec[i].recv()) .await .expect(timeout_err_msg); - event_result.unwrap(); - println!("Packet received from second NTP host\nTEST COMPLETE"); + + event_result.unwrap(); + println!( + "Packet received from host ({} / {})", + i + 1, + AWS_TEMP_PUBLIC_TIME_ADDRESSES.len() + ); + } + println!("TEST COMPLETE"); } From 0bdec7aef80916f6a4f8ff07a4874f037ae1375b Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 3 Nov 2025 12:50:33 -0500 Subject: [PATCH 070/177] [ff][ff-tester] Enable reproducibility testing of the ClockSyncAlgorithm (#70) * [ff][ff-tester] Enable reproducibility testing of the ClockSyncAlgorithm. This change includes: - A modification to the tracing::subscriber to route all clock_bound::primer target events to a separate logfile - emits every input/output pair to ClockSyncAlgorithm::feed_link_local - Adds code in ff-tester to convert these logfiles into ff-tester scenarios * rustfmt fix * Revision: removed development artifact --- Cargo.lock | 117 +++++++++- clock-bound-ff-tester/Cargo.toml | 1 + clock-bound-ff-tester/src/lib.rs | 2 + clock-bound-ff-tester/src/repro.rs | 203 ++++++++++++++++++ .../src/repro/test_10_29_2025.log | 5 + clock-bound/Cargo.toml | 13 +- clock-bound/src/bin/clockbound.rs | 14 +- clock-bound/src/daemon.rs | 1 + clock-bound/src/daemon/clock_parameters.rs | 2 +- .../src/daemon/clock_sync_algorithm.rs | 59 ++++- clock-bound/src/daemon/event/ntp.rs | 9 +- clock-bound/src/daemon/subscriber.rs | 43 ++++ 12 files changed, 454 insertions(+), 15 deletions(-) create mode 100644 clock-bound-ff-tester/src/repro.rs create mode 100644 clock-bound-ff-tester/src/repro/test_10_29_2025.log create mode 100644 clock-bound/src/daemon/subscriber.rs diff --git a/Cargo.lock b/Cargo.lock index 2d8b2b9..8dfdca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,9 +213,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.48" +version = "4.5.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" dependencies = [ "clap_builder", "clap_derive", @@ -223,9 +223,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.5.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" dependencies = [ "anstream", "anstyle", @@ -235,9 +235,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -260,6 +260,7 @@ dependencies = [ "byteorder", "bytes", "chrono", + "clap", "errno", "futures", "hex-literal", @@ -271,11 +272,14 @@ dependencies = [ "rand 0.9.2", "rstest 0.26.1", "serde", + "serde_json", "tempfile", "thiserror 2.0.17", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", + "tracing-test", ] [[package]] @@ -319,6 +323,7 @@ dependencies = [ "statrs", "tempfile", "thiserror 2.0.17", + "tracing", "varpro", ] @@ -366,6 +371,21 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "darling" version = "0.21.3" @@ -401,6 +421,15 @@ dependencies = [ "syn", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "distrs" version = "0.2.2" @@ -907,6 +936,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -976,6 +1011,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1468,6 +1509,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.47.1" @@ -1537,6 +1609,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror 1.0.69", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.30" @@ -1600,6 +1684,27 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-test" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/clock-bound-ff-tester/Cargo.toml b/clock-bound-ff-tester/Cargo.toml index 69370a3..422ae83 100644 --- a/clock-bound-ff-tester/Cargo.toml +++ b/clock-bound-ff-tester/Cargo.toml @@ -28,6 +28,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.145" statrs = "0.18.0" thiserror = { version = "2.0" } +tracing = "0.1.41" [dev-dependencies] approx = "0.5" diff --git a/clock-bound-ff-tester/src/lib.rs b/clock-bound-ff-tester/src/lib.rs index a87d7a8..a02813c 100644 --- a/clock-bound-ff-tester/src/lib.rs +++ b/clock-bound-ff-tester/src/lib.rs @@ -1,5 +1,7 @@ //! Feed Forward Time sync algorithm tester +pub mod repro; + pub mod time; pub mod events; diff --git a/clock-bound-ff-tester/src/repro.rs b/clock-bound-ff-tester/src/repro.rs new file mode 100644 index 0000000..4da5db2 --- /dev/null +++ b/clock-bound-ff-tester/src/repro.rs @@ -0,0 +1,203 @@ +//! Convert logs into `ff-tester` types in a Scenario + +use std::{ + collections::HashMap, + fs::File, + io::{BufRead, Read}, + path::Path, +}; + +use clock_bound::daemon::{clock_parameters::ClockParameters, event}; + +use crate::events::{Scenario, v1}; + +/// Read a logfile and return all inputs and outputs from the `ClockSyncAlgorithm` +/// +/// # Errors +/// - Returns an error if unable to open the file. +/// - Returns and error if unable to read values from the logfile +pub fn scenario_from_log_file( + file_path: impl AsRef, +) -> anyhow::Result<(Scenario, Vec>)> { + let file = File::open(file_path)?; + scenario_from_reader(file) +} + +/// Convert a log file at `file_path` into a `ff-tester` scenario +/// +/// Returns a Scenario, and the output from every call into `ClockSyncAlgorithm::feed_link_local` +/// +/// FIXME: Need to fix source id when integrating `routing` into the `ClockSyncAlgorithm` +/// +/// # Errors +/// Returns errors if the logfile is corrupted +pub fn scenario_from_reader( + input: impl Read, +) -> anyhow::Result<(Scenario, Vec>)> { + let reader = std::io::BufReader::new(input); + + let mut events = Vec::new(); + let mut clock_parameters = Vec::new(); + + for line in reader.lines() { + let Ok(line) = line else { + tracing::warn!("Could not read line from log file"); + continue; + }; + let line: Line = match serde_json::from_str(&line) { + Ok(line) => line, + Err(e) => { + tracing::warn!(line, "Could not parse line from log file: {e}"); + continue; + } + }; + + let event = match line.fields.parse_event() { + Ok(event) => event, + Err(e) => { + tracing::warn!(line = ?line, "Could not parse event: {e}"); + continue; + } + }; + + let output = match line.fields.parse_output() { + Ok(output) => output, + Err(e) => { + tracing::warn!(line = ?line, "Could not parse output: {e}"); + continue; + } + }; + + events.push(event); + clock_parameters.push(output); + } + + if events.is_empty() { + anyhow::bail!("No events found in log file"); + } + + if clock_parameters.is_empty() { + anyhow::bail!("No clock parameters found in log file"); + } + + if events.len() != clock_parameters.len() { + anyhow::bail!("Number of events and clock parameters do not match"); + } + + let events: Vec<_> = events + .into_iter() + .map(|event| tester_event_from_clock_bound(&event, "link_local".to_string())) + .collect(); + + let scenario = Scenario::V1(v1::Scenario { + events, + oscillator: None, + metadata: HashMap::new(), + }); + + Ok((scenario, clock_parameters)) +} + +pub fn tester_event_from_clock_bound(event: &event::Ntp, source_id: String) -> v1::Event { + use crate::time::CbBridge; + v1::Event { + variants: v1::EventKind::Ntp(v1::Ntp { + server_system_recv_time: event.data().server_recv_time.into_estimate(), + server_system_send_time: event.data().server_send_time.into_estimate(), + root_delay: event.data().root_delay.into_estimate(), + root_dispersion: event.data().root_dispersion.into_estimate(), + client_system_times: None, + source_id, + }), + client_tsc_pre_time: event.tsc_pre(), + client_tsc_post_time: event.tsc_post(), + } +} + +/// Convenience struct to parse each line of the log +/// +/// It's not exhaustive, but has the minimum number of fields +/// to get the [`NTP event`](clock_bound::daemon::event::Ntp) and [`ClockParameters`] +#[derive(Debug, Clone, serde::Deserialize)] +struct Line { + fields: Fields, +} + +/// Workaround for tracing escapes +/// +/// Holds strings to make it easy to grab the inner keys. But since these values cannot be parsed directly from json, +/// implement methods to parse each event. +/// +/// tracing currently escapes double quote characters in json when logging. So we need to convert all `\"` into '"' +#[derive(Debug, Clone, serde::Deserialize)] +struct Fields { + event: String, // FIXME switch to routable event + output: String, +} + +impl Fields { + fn parse_output(&self) -> anyhow::Result> { + let unescaped = self.output.replace("\\\"", "\""); + let retval: Option = serde_json::from_str(&unescaped)?; + Ok(retval) + } + + fn parse_event(&self) -> anyhow::Result { + let unescaped = self.event.replace("\\\"", "\""); + let retval: event::Ntp = serde_json::from_str(&unescaped)?; + Ok(retval) + } +} + +#[cfg(test)] +mod tests { + use clock_bound::daemon::{event::Stratum, time::TscCount}; + + use crate::time::{EstimateDuration, EstimateInstant}; + + use super::*; + + #[test] + fn convert_clock_bound_event_to_tester() { + let event = event::Ntp::builder() + .ntp_data(clock_bound::daemon::event::NtpData { + server_recv_time: 1.into(), + server_send_time: 2.into(), + root_delay: 3.into(), + root_dispersion: 4.into(), + stratum: Stratum::TWO, + }) + .tsc_pre(TscCount::new(500)) + .tsc_post(TscCount::new(600)) + .build() + .unwrap(); + + let source_id = "source_id".to_string(); + let tester_event = tester_event_from_clock_bound(&event, source_id.clone()); + assert_eq!(tester_event.client_tsc_pre_time, TscCount::new(500)); + assert_eq!(tester_event.client_tsc_post_time, TscCount::new(600)); + assert_eq!( + tester_event.variants, + v1::EventKind::Ntp(v1::Ntp { + server_system_recv_time: EstimateInstant::new(1), + server_system_send_time: EstimateInstant::new(2), + root_delay: EstimateDuration::new(3), + root_dispersion: EstimateDuration::new(4), + client_system_times: None, + source_id, + }) + ); + } + + #[test] + fn scenario_from_logs() { + let example_log = include_str!("repro/test_10_29_2025.log"); + + let (scenario, clock_parameters) = scenario_from_reader(example_log.as_bytes()).unwrap(); + + assert_eq!(clock_parameters.len(), 5); + let Scenario::V1(scenario) = scenario; + assert_eq!(scenario.events.len(), 5); + assert!(scenario.oscillator.is_none()); + } +} diff --git a/clock-bound-ff-tester/src/repro/test_10_29_2025.log b/clock-bound-ff-tester/src/repro/test_10_29_2025.log new file mode 100644 index 0000000..04aed65 --- /dev/null +++ b/clock-bound-ff-tester/src/repro/test_10_29_2025.log @@ -0,0 +1,5 @@ +{"timestamp":"2025-10-28T20:32:45.436522Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684683194245128,\"tsc_post\":684683194794794,\"data\":{\"server_recv_time\":1761683565436333156000000,\"server_send_time\":1761683565436348058000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} +{"timestamp":"2025-10-28T20:32:46.436878Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684685795242462,\"tsc_post\":684685795712854,\"data\":{\"server_recv_time\":1761683566436724697000000,\"server_send_time\":1761683566436738169000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} +{"timestamp":"2025-10-28T20:32:47.436275Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684688393604176,\"tsc_post\":684688394117962,\"data\":{\"server_recv_time\":1761683567436112791000000,\"server_send_time\":1761683567436127810000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} +{"timestamp":"2025-10-28T20:32:48.436644Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684690994547794,\"tsc_post\":684690995078662,\"data\":{\"server_recv_time\":1761683568436488072000000,\"server_send_time\":1761683568436503955000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} +{"timestamp":"2025-10-28T20:32:49.437019Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684693595469390,\"tsc_post\":684693596006290,\"data\":{\"server_recv_time\":1761683569436853672000000,\"server_send_time\":1761683569436868285000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} \ No newline at end of file diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index e47e4b3..3ae0055 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -17,6 +17,7 @@ bon = { version = "3.8.0", optional = true } byteorder = "1" bytes = { version = "1", optional = true } chrono = { version = "0.4", optional = true } +clap = { version = "4.5.50", features = ["derive"], optional = true } errno = { version = "0.3.0", default-features = false } libc = { version = "0.2", default-features = false, features = [ "extra_traits", @@ -24,6 +25,7 @@ libc = { version = "0.2", default-features = false, features = [ nix = { version = "0.26", features = ["feature", "time"] } nom = { version = "8", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = "1.0.145" thiserror = { version = "2.0", optional = true } tokio = { version = "1.47.1", features = [ "fs", @@ -35,7 +37,13 @@ tokio = { version = "1.47.1", features = [ "time", ], optional = true } tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } +tracing-appender = { version = "0.2", optional = true } +tracing-subscriber = { version = "0.3", features = [ + "std", + "fmt", + "json", + "registry", +] } futures = "0.3" rand = "0.9.2" @@ -46,17 +54,20 @@ mockall = "0.13.1" mockall_double = "0.3.1" rstest = "0.26" tempfile = "3.13" +tracing-test = "0.2.5" [features] client = [] daemon = [ "dep:bon", + "dep:clap", "dep:serde", "dep:tokio", "dep:chrono", "dep:bytes", "dep:nom", "dep:thiserror", + "dep:tracing-appender", "tracing-subscriber/env-filter", ] time-string-parse = ["dep:nom"] diff --git a/clock-bound/src/bin/clockbound.rs b/clock-bound/src/bin/clockbound.rs index 0f69f33..51fdd0a 100644 --- a/clock-bound/src/bin/clockbound.rs +++ b/clock-bound/src/bin/clockbound.rs @@ -1,9 +1,19 @@ //! ClockBound daemon -use clock_bound::daemon::Daemon; +use std::path::PathBuf; + +use clap::Parser; +use clock_bound::daemon::{Daemon, subscriber}; + +#[derive(Debug, Parser)] +struct Args { + #[clap(long, default_value = "clockbound")] + log_dir: PathBuf, +} #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { - tracing_subscriber::fmt::init(); + let args = Args::parse(); + subscriber::init(&args.log_dir); let mut d = Daemon::construct().await; tokio::spawn(async move { d.run().await }).await.unwrap(); } diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index cf75928..e16926d 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -15,6 +15,7 @@ pub mod time; pub mod event; pub mod receiver_stream; +pub mod subscriber; pub mod selected_clock; diff --git a/clock-bound/src/daemon/clock_parameters.rs b/clock-bound/src/daemon/clock_parameters.rs index 095099c..7ab0128 100644 --- a/clock-bound/src/daemon/clock_parameters.rs +++ b/clock-bound/src/daemon/clock_parameters.rs @@ -11,7 +11,7 @@ use crate::daemon::time::{ /// /// These values are calculated by the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) /// and used by the [`ClockState`](super::clock_state) -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct ClockParameters { /// The tsc values that these account for pub tsc_count: TscCount, diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index bf78a64..bee1cc6 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -12,7 +12,7 @@ use std::net::SocketAddr; pub use ring_buffer::RingBuffer; -use crate::daemon::{clock_parameters::ClockParameters, event}; +use crate::daemon::{clock_parameters::ClockParameters, event, subscriber::PRIMER_TARGET}; pub mod source; @@ -39,8 +39,17 @@ pub struct ClockSyncAlgorithm { impl ClockSyncAlgorithm { /// Feed event into the link local /// TODO: make this function private and call into it from `fn feed` when we have a routable event + #[expect(clippy::missing_panics_doc, reason = "serialization will not fail")] pub fn feed_link_local(&mut self, event: event::Ntp) -> Option<&ClockParameters> { - self.link_local.feed(event) + let serialized = serde_json::to_string(&event).unwrap(); + let output = self.link_local.feed(event); + tracing::info!( + target: PRIMER_TARGET, + event = serialized, + output = serde_json::to_string(&output).unwrap(), + "feed link local" + ); + output } /// Feed event into ntp source @@ -80,3 +89,49 @@ impl ClockSyncAlgorithm { tracing::info!("Handled clock disruption event"); } } + +#[cfg(test)] +mod tests { + use crate::daemon::{ + event::Stratum, + time::{Duration, Instant, TscCount, tsc::Skew}, + }; + + use super::*; + + #[test] + #[tracing_test::traced_test] + fn feed_link_local_event() { + // Most logs are permeable to change. Make sure that we log a json event. + + let event = event::Ntp::builder() + .tsc_pre(TscCount::new(500)) + .tsc_post(TscCount::new(1000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(2), + root_delay: Duration::from_micros(50), + root_dispersion: Duration::from_millis(17), + stratum: Stratum::TWO, + }) + .build() + .unwrap(); + + let mut csa = ClockSyncAlgorithm::builder() + .link_local(source::LinkLocal::new(Skew::from_ppm(15.0))) + .build(); + + let clock_parameters = csa.feed_link_local(event.clone()); + assert!(clock_parameters.is_none()); + + let serialized_event = serde_json::to_string(&event).unwrap(); + let serialized_output = serde_json::to_string(&clock_parameters).unwrap(); + + // tracing escapes quotes + let serialized_event = serialized_event.replace("\"", r#"\""#); + let serialized_output = serialized_output.replace("\"", r#"\""#); + + assert!(logs_contain(&serialized_event)); + assert!(logs_contain(&serialized_output)); + } +} diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index 9a4ba6c..eef8c1f 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -13,7 +13,7 @@ use crate::daemon::{ /// Contains the NTP and time stamp counter samples to be used by synchronization algorithm. /// /// `tsc_post` must be greater than `tsc_pre` -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Ntp { /// TSC value before sending event tsc_pre: TscCount, @@ -130,7 +130,7 @@ impl TscRtt for Ntp { } /// NTP-specific data -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct NtpData { /// NTP Server recv time pub server_recv_time: Instant, @@ -147,7 +147,10 @@ pub struct NtpData { } /// An NTP stratum -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[serde(try_from = "u8", into = "u8")] pub enum Stratum { /// Unspecified or invalid. /// diff --git a/clock-bound/src/daemon/subscriber.rs b/clock-bound/src/daemon/subscriber.rs new file mode 100644 index 0000000..35c90ae --- /dev/null +++ b/clock-bound/src/daemon/subscriber.rs @@ -0,0 +1,43 @@ +//! Tracing subscribers used within the clockbound daemon. +//! +//! Logging in Clockbound use the tracing ecosystem for logging. While +//! most of the logs are straightforward, it makes special consideration for logging +//! all clock synchronization events in a format that allows for deterministic and +//! reproducible testing of the FF Clock Sync Algorithm + +use std::path::{Path, PathBuf}; + +use tracing_subscriber::{ + EnvFilter, Layer, filter::filter_fn, layer::SubscriberExt, util::SubscriberInitExt, +}; + +pub const PRIMER_TARGET: &str = "clock_bound::primer"; + +/// Initialize the tracing subscriber +/// +/// This currently uses the [`tracing_subscriber::registry()`] to have 2 different paths. Clock synchronization +/// events for reproducing test cases use the target of [`PRIMER_TARGET`] to route into a separate file, while +/// the rest of the logs go through the default logger (presently writes to stdout) +pub fn init(log_directory: impl AsRef) { + let primer_writer = tracing_appender::rolling::never(&log_directory, "primer.log"); + let primer_layer = tracing_subscriber::fmt::layer() + .json() + .with_writer(primer_writer) + .with_filter(filter_fn(|md| md.target().starts_with(PRIMER_TARGET))); + + let log_layer = tracing_subscriber::fmt::layer() + .with_writer(std::io::stdout) // this is the default, just making it explicit + .with_filter(EnvFilter::from_default_env()) + .with_filter(filter_fn(|md| !md.target().starts_with(PRIMER_TARGET))); + + tracing_subscriber::registry() + // this is the default logging layer + .with(log_layer) + // and this is the primer reproducibility layer + .with(primer_layer) + .init(); + + tracing::info!("Initialized tracing subscriber"); + let primer_log_file = PathBuf::from(log_directory.as_ref()).join("primer.log"); + tracing::info!(primer_log_file = %primer_log_file.display(), "Primer log file"); +} From d7a6d565d82f1c5391afd1824ce2b92d78e74f1d Mon Sep 17 00:00:00 2001 From: tphan25 Date: Mon, 3 Nov 2025 13:29:42 -0500 Subject: [PATCH 071/177] Handle disruptions in `ClockState` (#77) `ClockState` handler needs to deal with disruptions. It delegates to its subcomponents `ClockAdjust` + `ClockStateWriter`. ClockAdjust - Reset any ongoing slews. - We may want to step the clock if we think it will speed up recovery.. depends how much our clocks are disrupted. For now, not doing that. ClockStateWriter - Set the internal state to disrupted, and write that to SHM, along with the disruption marker value. - We'll continue to be disrupted til we get new `ClockParameters` to tell us otherwise. --- clock-bound/src/daemon/clock_state.rs | 42 ++++++++++ .../src/daemon/clock_state/clock_adjust.rs | 76 ++++++++++++++++++- .../daemon/clock_state/clock_state_writer.rs | 69 ++++++++++++++++- clock-bound/src/daemon/time/timex.rs | 2 +- 4 files changed, 186 insertions(+), 3 deletions(-) diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index db30ac2..ed5b59d 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -65,10 +65,31 @@ impl ClockState { .handle_clock_parameters(clock_parameters, clock_realtime_offset_and_rtt), } } + + /// Handle a clock disruption event + /// + /// Call this function after the system detects a VMClock disruption event. + /// + /// It will go through and clear the state (like startup). + pub fn handle_disruption(&mut self, new_disruption_marker: u64) { + // Use the destructure pattern to get a mutable reference to each item. + // + // This makes it a compilation error if we add a new field this Self without handling it here + let Self { + clock_adjuster, + clock_state_writer, + } = self; + + clock_adjuster.handle_disruption(new_disruption_marker); + clock_state_writer.handle_disruption(new_disruption_marker); + tracing::info!("Handled clock disruption event"); + } } #[cfg(test)] mod tests { + use mockall::predicate::eq; + use crate::{ daemon::{ clock_parameters::ClockParameters, @@ -188,4 +209,25 @@ mod tests { let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); clock_state.handle_clock_parameters(&clock_parameters); } + + #[test] + fn handle_disruption() { + let disruption_marker = 123; + let mut mock_clock_adjuster: MockClockAdjuster = + MockClockAdjuster::new(); + mock_clock_adjuster + .expect_handle_disruption() + .once() + .with(eq(disruption_marker)) + .return_const(()); + let mut mock_clock_state_writer: MockClockStateWriter = + MockClockStateWriter::new(); + mock_clock_state_writer + .expect_handle_disruption() + .once() + .with(eq(disruption_marker)) + .return_const(()); + let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); + clock_state.handle_disruption(disruption_marker); + } } diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index a4611f4..f9d7bd8 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -2,7 +2,7 @@ use errno::Errno; use libc::{TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT, ntp_adjtime}; use thiserror::Error; -use tracing::debug; +use tracing::{debug, error, info}; use crate::daemon::{ clock_parameters::ClockParameters, @@ -64,6 +64,7 @@ mockall::mock! { // This is needed to tell ClockAdjust what phase offset to use. clock_realtime_offset_and_rtt: ClockOffsetAndRtt, ) -> Result; + pub fn handle_disruption(&mut self, new_disruption_marker: u64); } } @@ -151,10 +152,46 @@ impl ClockAdjuster { unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), } } + + /// Handle a clock disruption event + /// + /// Call this function after the system detects a VMClock disruption event. + /// + /// It will go through and clear the state (like startup). + /// + /// # Panics + /// If `adjust_clock` fails, e.g. an invalid value was supplied to `ntp_adjtime` (unlikely), or + /// insufficient permissions to adjust the clock. + pub fn handle_disruption(&mut self, _new_disruption_marker: u64) { + // Use the destructure pattern to get a mutable reference to each item. + // + // This makes it a compilation error if we add a new field to Self without handling it here + let Self { + ntp_adjtime: _, + should_step: _, + } = self; + // At least stop any ongoing phase correction slew or frequency correction, if the clock is disrupted. + // Notably, phase correction slew is recalculated at the top of a second, so we still might end up having some moderate slew + // of the clock happening til that time. + info!("Resetting ntp_adjtime parameters to zero any phase or frequency corrections"); + match self.adjust_clock(Duration::from_secs(0), Skew::from_ppm(0.0)) { + failed_adjtime @ Err(NtpAdjTimeError::Failure(_)) => { + failed_adjtime.unwrap(); + } + Err(unexpected_adjtime_status) => { + error!("Unexpected adjtime result: {unexpected_adjtime_status}"); + } + _ => {} + } + // TODO: We may want to reset `should_step` if we think it is acceptable to step the clock on next adjustment + // for faster recovery.. + tracing::info!("Handled clock disruption event"); + } } #[cfg(test)] mod test { + use mockall::predicate::eq; use rstest::rstest; use super::*; @@ -276,4 +313,41 @@ mod test { clock_adjuster.step_clock(input_phase_correction).unwrap(); assert!(!clock_adjuster.should_step); } + + #[test] + fn handle_disruption() { + let disruption_marker = 123; + let expected_tx = Timex::clock_adjustment() + .phase_correction(Duration::from_secs(0)) + .skew(Skew::from_ppm(0.0)) + .call(); + let mut mock_ntp_adj_time = MockNtpAdjTime::new(); + mock_ntp_adj_time + .expect_ntp_adjtime() + .once() + .with(eq(expected_tx)) + .return_once(|_| 0); + + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + clock_adjuster.handle_disruption(disruption_marker); + } + + #[test] + #[should_panic] + fn handle_disruption_panics_if_hard_failure_to_adjust_clock() { + let disruption_marker = 123; + let expected_tx = Timex::clock_adjustment() + .phase_correction(Duration::from_secs(0)) + .skew(Skew::from_ppm(0.0)) + .call(); + let mut mock_ntp_adj_time = MockNtpAdjTime::new(); + mock_ntp_adj_time + .expect_ntp_adjtime() + .once() + .with(eq(expected_tx)) + .return_once(|_| -1); + + let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); + clock_adjuster.handle_disruption(disruption_marker); + } } diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index 7ab7dbd..09165ad 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -1,5 +1,6 @@ //! Write clockbound shared memory and manage clock state use nix::sys::time::TimeSpec; +use tracing::info; use crate::{ daemon::{ @@ -36,6 +37,7 @@ mockall::mock! { // This is needed to tell ClockAdjust what phase offset to use. clock_realtime_offset_and_rtt: ClockOffsetAndRtt, ); + pub fn handle_disruption(&mut self, new_disruption_marker: u64); } } @@ -68,6 +70,9 @@ impl ClockStateWriter { // This is needed to tell ClockAdjust what phase offset to use. clock_realtime_offset_and_rtt: ClockOffsetAndRtt, ) { + // If we've received `ClockParameters`, it means we were able to construct a valid `ClockBound` clock. + // Thus, mark ourselves as synchronized. + self.clock_status = ClockStatus::Synchronized; let bound = get_bound(clock_parameters, clock_realtime_offset_and_rtt); // Unwrap safety: sane error bound should be less than `i64::MAX` let bound_nsec = i64::try_from(bound.as_nanos()).unwrap(); @@ -103,6 +108,30 @@ impl ClockStateWriter { ); self.shm_writer.write(&ceb); } + + /// Handle a clock disruption event + /// + /// Call this function after the system detects a VMClock disruption event. + /// + /// It will go through and clear the state (like startup). + pub fn handle_disruption(&mut self, new_disruption_marker: u64) { + // Use the destructure pattern to get a mutable reference to each item. + // + // This makes it a compilation error if we add a new field to Self without handling it here + let Self { + clock_status, + clock_disruption_support_enabled: _, + shm_writer: _, + max_drift_ppb: _, + disruption_marker, + } = self; + *clock_status = ClockStatus::Disrupted; + *disruption_marker = new_disruption_marker; + let as_of = MonotonicCoarse.get_time(); + info!("Writing `ClockStatus::Disrupted` to SHM with 0 `bound_nsec`"); + self.write_shm(as_of, 0); // We're writing that we're disrupted anyways, so the `bound_nsec` value should be useless here, 0 is ok + tracing::info!("Handled clock disruption event"); + } } /// Calculate the `ClockErrorBound` `bound_nsec` value. This is used to calculate @@ -226,7 +255,7 @@ mod tests { && ceb.bound_nsec() == 2250 && ceb.disruption_marker() == disruption_marker && ceb.max_drift_ppb() == max_drift_ppb - && ceb.clock_status() == ClockStatus::Unknown // default on `ClockStateWriter` constructor + && ceb.clock_status() == ClockStatus::Synchronized // If we're getting clock parameters, we're "Synchronized" && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled }) .times(1) @@ -301,4 +330,42 @@ mod tests { let bound_nsec = get_bound(&clock_parameters, clock_realtime_offset_and_rtt); assert_eq!(bound_nsec, expected); } + + #[test] + fn handle_disruption() { + let clock_disruption_support_enabled = false; + let max_drift_ppb = 0; + let initial_disruption_marker = 0; + let final_disruption_marker = 1; + let mut shm_writer = MockShmWriter::new(); + shm_writer + .expect_write() + .withf(move |ceb: &ClockErrorBound| { + ceb.void_after() + == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) + && ceb.bound_nsec() == 0 + && ceb.disruption_marker() == final_disruption_marker + && ceb.max_drift_ppb() == max_drift_ppb + && ceb.clock_status() == ClockStatus::Disrupted + && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled + }) + .times(1) + .return_const(()); + let mut clock_state_writer = ClockStateWriter::builder() + .clock_disruption_support_enabled(clock_disruption_support_enabled) + .shm_writer(shm_writer) + .max_drift_ppb(max_drift_ppb) + .disruption_marker(initial_disruption_marker) + .build(); + assert_eq!(clock_state_writer.clock_status, ClockStatus::Unknown); + assert_eq!( + clock_state_writer.disruption_marker, + initial_disruption_marker + ); + clock_state_writer.handle_disruption(final_disruption_marker); + assert_eq!( + clock_state_writer.disruption_marker, + final_disruption_marker + ); + } } diff --git a/clock-bound/src/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs index cbf5e1f..fd51aa4 100644 --- a/clock-bound/src/daemon/time/timex.rs +++ b/clock-bound/src/daemon/time/timex.rs @@ -16,7 +16,7 @@ const MAX_SKEW: Skew = Skew::from_ppm(512.0); /// Newtype wrapping `libc::timex` to provide valid /// constructors for each type of `adjtimex`/`ntp_adjtime` operation. -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Timex(timex); #[bon] From bf01b5ea0adfdbd8d44fa1abff70d0753359ab5f Mon Sep 17 00:00:00 2001 From: tphan25 Date: Mon, 3 Nov 2025 15:43:39 -0500 Subject: [PATCH 072/177] Fix CI by adding `ntp_sources` missing field (#87) bon::Builder expects ntp_sources to be set on ClockSyncAlgorithm but it was missing in a test, adding it here --- clock-bound/src/daemon/clock_sync_algorithm.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index bee1cc6..c11c4bb 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -119,6 +119,7 @@ mod tests { let mut csa = ClockSyncAlgorithm::builder() .link_local(source::LinkLocal::new(Skew::from_ppm(15.0))) + .ntp_sources(vec![]) .build(); let clock_parameters = csa.feed_link_local(event.clone()); From 6d720aac09684f4945fb9e2eeb6027ba75bd37c3 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 4 Nov 2025 12:20:53 -0500 Subject: [PATCH 073/177] [feature] side by side test (#79) Enable the ability to run link local NTP while comparing the algorithm output to the system clock --- Cargo.lock | 8 ++--- clock-bound/Cargo.toml | 2 ++ .../src/daemon/clock_sync_algorithm.rs | 35 ++++++++++++++++++- clock-bound/src/daemon/event.rs | 32 +++++++++++++++++ clock-bound/src/daemon/event/ntp.rs | 20 ++++++++++- clock-bound/src/daemon/io/link_local.rs | 28 +++++++++++---- 6 files changed, 112 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8dfdca7..c3a25a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,9 +213,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.50" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" dependencies = [ "clap_builder", "clap_derive", @@ -223,9 +223,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.50" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" dependencies = [ "anstream", "anstyle", diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 3ae0055..b93d36d 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -70,6 +70,8 @@ daemon = [ "dep:tracing-appender", "tracing-subscriber/env-filter", ] +test-side-by-side = [ +] # run without changing system clock. And compare against system clock time-string-parse = ["dep:nom"] default = ["client", "daemon"] diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index c11c4bb..c45e039 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -42,7 +42,40 @@ impl ClockSyncAlgorithm { #[expect(clippy::missing_panics_doc, reason = "serialization will not fail")] pub fn feed_link_local(&mut self, event: event::Ntp) -> Option<&ClockParameters> { let serialized = serde_json::to_string(&event).unwrap(); - let output = self.link_local.feed(event); + + let output = { + #[cfg(all(feature = "test-side-by-side", not(test)))] + { + let Some(system_clock) = event.system_clock() else { + return self.link_local.feed(event); + }; + let system = system_clock.system_time; + let system_tsc = system_clock.tsc; + let tsc_rtt = event.tsc_post() - event.tsc_pre(); + let retval = self.link_local.feed(event); + if let Some(new_params) = &retval { + let system_clock_tsc_age = system_tsc - new_params.tsc_count; + let system_clock_age = system_clock_tsc_age * new_params.period; + + let comparable_time = new_params.time + system_clock_age; + let offset_from_system_clock = comparable_time - system; + let ntp_rtt = tsc_rtt * new_params.period; + + tracing::info!( + ?system_clock_tsc_age, + ?offset_from_system_clock, + ?ntp_rtt, + ?tsc_rtt + ); + } + retval + } + #[cfg(any(not(feature = "test-side-by-side"), test))] + { + self.link_local.feed(event) + } + }; + tracing::info!( target: PRIMER_TARGET, event = serialized, diff --git a/clock-bound/src/daemon/event.rs b/clock-bound/src/daemon/event.rs index 5a9b6da..d724d8a 100644 --- a/clock-bound/src/daemon/event.rs +++ b/clock-bound/src/daemon/event.rs @@ -31,3 +31,35 @@ pub trait TscRtt { self.tsc_pre().midpoint(self.tsc_post()) } } + +/// Struct containing a system clock read and a TSC read +#[cfg(feature = "test-side-by-side")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SystemClockMeasurement { + /// The system clock read + pub system_time: crate::daemon::time::Instant, + /// The TSC read + pub tsc: TscCount, +} + +#[cfg(feature = "test-side-by-side")] +impl SystemClockMeasurement { + /// Create a new [`SystemClockMeasurement`] + #[expect(clippy::missing_panics_doc, reason = "unwrap won't panic")] + #[expect(clippy::cast_possible_wrap)] + pub fn now() -> Self { + use crate::daemon::io::tsc::read_timestamp_counter; + use crate::daemon::time::Instant; + let pre = read_timestamp_counter(); + let now = std::time::SystemTime::now(); + let post = read_timestamp_counter(); + + let now = now.duration_since(std::time::UNIX_EPOCH).unwrap(); + let tsc = pre.midpoint(post); + let system_time = Instant::from_nanos(now.as_nanos() as i128); + Self { + system_time, + tsc: TscCount::new(tsc.into()), + } + } +} diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index eef8c1f..8a32388 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -21,6 +21,9 @@ pub struct Ntp { tsc_post: TscCount, /// NTP Packet data data: NtpData, + #[cfg(all(not(test), feature = "test-side-by-side"))] + #[serde(skip)] + system_clock: Option, } #[bon::bon] @@ -29,12 +32,21 @@ impl Ntp { /// /// Returns `None` if `tsc_post <= tsc_pre` #[builder] - pub fn new(tsc_pre: TscCount, tsc_post: TscCount, ntp_data: NtpData) -> Option { + pub fn new( + tsc_pre: TscCount, + tsc_post: TscCount, + ntp_data: NtpData, + #[cfg(all(not(test), feature = "test-side-by-side"))] system_clock: Option< + super::SystemClockMeasurement, + >, + ) -> Option { if tsc_post > tsc_pre { Some(Self { tsc_pre, tsc_post, data: ntp_data, + #[cfg(all(not(test), feature = "test-side-by-side"))] + system_clock, }) } else { None @@ -58,6 +70,12 @@ impl Ntp { &self.data } + /// system time getter + #[cfg(all(not(test), feature = "test-side-by-side"))] + pub fn system_clock(&self) -> Option<&super::SystemClockMeasurement> { + self.system_clock.as_ref() + } + /// Calculate a period by using 2 NTP events using return path /// /// NTP traffic is characterized in ClockBound with each exchange having a diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index d98b2d7..10c408f 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -91,21 +91,35 @@ impl LinkLocal { let received_timestamp = read_timestamp_counter(); + #[cfg(all(not(test), feature = "test-side-by-side"))] + let system_clock_reading = crate::daemon::event::SystemClockMeasurement::now(); + let (_, ntp_packet) = Packet::parse_from_bytes(&self.ntp_buffer) .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; let ntp_data = NtpData::try_from(ntp_packet) .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; - let ntp_event = event::Ntp::builder() + let builder = event::Ntp::builder() .tsc_pre(TscCount::new(sent_timestamp.into())) .tsc_post(TscCount::new(received_timestamp.into())) - .ntp_data(ntp_data) - .build() - .ok_or(LinkLocalError::TscOrder { - pre: sent_timestamp, - post: received_timestamp, - })?; + .ntp_data(ntp_data); + + let ntp_event = { + #[cfg(all(not(test), feature = "test-side-by-side"))] + { + builder.system_clock(system_clock_reading).build() + } + #[cfg(any(test, not(feature = "test-side-by-side")))] + { + builder.build() + } + }; + + let ntp_event = ntp_event.ok_or(LinkLocalError::TscOrder { + pre: sent_timestamp, + post: received_timestamp, + })?; debug!(?recv_packet_result, "Received packet."); Ok(ntp_event) From 01ee4cd0818678b11f942d7838228ba7f7bd28a5 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Tue, 4 Nov 2025 13:21:32 -0500 Subject: [PATCH 074/177] Trigger `ClockState` on link_local feed event success (#88) This should be the first build which actually mutates the system clock and writes to SHM. It entails a few changes as prerequisites.. - Removes `mock!` impls of ClockStateWriter and ClockAdjuster, instead preferring to mimic their public interfaces with a trait to be mocked. Unfortunately, mocking structs has worse effects in the callsites, where mockall_double doesn't work well for figuring out which constructor is in cfg(test) and which one is part of the struct.. - Add a `ClockState::construct` implementation with concrete trait implementors, to be used as a sane, working default. The header looks messy, since we've polluted the place with many a trait. - Include `clock_state` in the `Daemon`, and handle `feed_link_local` by calling into `clock_state.handle_parameters` when params.is_some() - Add a SafeShmWriter, which simply impls Send + Sync on ShmWriter since we only intend to use it in one point of the application so the underlying raw pointer should not risk data races. This results in an end-to-end daemon which at least performs some adjustments via phase correction to supply to the kernel. Skew is not yet implemented. --- clock-bound/src/daemon.rs | 21 ++- clock-bound/src/daemon/clock_state.rs | 94 ++++++------- .../src/daemon/clock_state/clock_adjust.rs | 123 ++++++++--------- .../daemon/clock_state/clock_state_writer.rs | 127 ++++++++++-------- 4 files changed, 200 insertions(+), 165 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index e16926d..057dc13 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -20,12 +20,16 @@ pub mod subscriber; pub mod selected_clock; use crate::daemon::{ + clock_state::{ + ClockState, + clock_adjust::{ClockAdjuster, KAPIClockAdjuster}, + clock_state_writer::{ClockStateWriter, SafeShmWriter}, + }, clock_sync_algorithm::{ClockSyncAlgorithm, source::NTPSource}, io::ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, receiver_stream::{ReceiverStream, RoutableEvent}, time::tsc::Skew, }; - use rand::{RngCore, rng}; /// The maximum dispersion growth every second @@ -45,6 +49,7 @@ pub struct Daemon { _io_front_end: io::SourceIO, clock_sync_algorithm: ClockSyncAlgorithm, receiver_stream: ReceiverStream, + clock_state: ClockState, ClockStateWriter>, } impl Daemon { @@ -56,6 +61,7 @@ impl Daemon { minor_version: 100, startup_id: rng().next_u64(), }; + let clock_state = ClockState::construct(); let clock_sync_algorithm = ClockSyncAlgorithm::builder() .link_local(clock_sync_algorithm::source::LinkLocal::new( @@ -103,6 +109,7 @@ impl Daemon { _io_front_end: io_front_end, clock_sync_algorithm, receiver_stream, + clock_state, } } @@ -121,11 +128,17 @@ impl Daemon { fn handle_event(&mut self, routable_event: RoutableEvent) { match routable_event { RoutableEvent::LinkLocalEvent(event) => { - self.clock_sync_algorithm.feed_link_local(event); + if let Some(params) = self.clock_sync_algorithm.feed_link_local(event) { + self.clock_state.handle_clock_parameters(params); + } } RoutableEvent::NTPSourceEvent(sender_address, event) => { - self.clock_sync_algorithm - .feed_ntp_source(sender_address, &event); + if let Some(params) = self + .clock_sync_algorithm + .feed_ntp_source(sender_address, &event) + { + self.clock_state.handle_clock_parameters(params); + } } RoutableEvent::PhcEvent => { todo!("Implement PHC IO event delivery") diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index ed5b59d..1690f07 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -4,34 +4,33 @@ pub mod clock_state_writer; use tracing::error; -use crate::daemon::clock_state::clock_adjust::NtpAdjTimeError; -#[cfg_attr(test, mockall_double::double)] +use crate::daemon::clock_state::clock_adjust::{ClockAdjust, KAPIClockAdjuster, NtpAdjTimeError}; +use crate::daemon::clock_state::clock_state_writer::{ClockStateWrite, SafeShmWriter}; use crate::daemon::clock_state::{ clock_adjust::ClockAdjuster, clock_state_writer::ClockStateWriter, }; use crate::daemon::io::tsc::ReadTscImpl; use crate::daemon::time::clocks::RealTime; -use crate::{ - daemon::{ - clock_parameters::ClockParameters, - clock_state::clock_adjust::NtpAdjTime, - time::{ClockExt, clocks::ClockBound}, - }, - shm::ShmWrite, +use crate::daemon::{ + clock_parameters::ClockParameters, + time::{ClockExt, clocks::ClockBound}, }; +use crate::shm::ShmWriter; + +const CLOCKBOUND_SHM_DEFAULT_PATH: &str = "/var/run/clockbound/shm0"; /// The whole `ClockState` component struct. /// This encompasses both `ClockAdjust` component which interfaces /// with the `CLOCK_REALTIME` kernel clock to synchronize it with `ClockBound` estimate /// of UTC (`ClockBound` clock), and `ClockStateWriter` which manages writing /// the `ClockErrorBound` to SHM segment for the client to read. -pub struct ClockState { - clock_state_writer: ClockStateWriter, - clock_adjuster: ClockAdjuster, +pub(crate) struct ClockState { + clock_state_writer: S, + clock_adjuster: A, } -impl ClockState { - pub fn new(clock_state_writer: ClockStateWriter, clock_adjust: ClockAdjuster) -> Self { +impl ClockState { + pub fn new(clock_state_writer: S, clock_adjust: A) -> Self { Self { clock_state_writer, clock_adjuster: clock_adjust, @@ -71,6 +70,7 @@ impl ClockState { /// Call this function after the system detects a VMClock disruption event. /// /// It will go through and clear the state (like startup). + #[cfg_attr(not(test), expect(unused))] pub fn handle_disruption(&mut self, new_disruption_marker: u64) { // Use the destructure pattern to get a mutable reference to each item. // @@ -86,29 +86,39 @@ impl ClockState { } } +impl ClockState, ClockStateWriter> { + pub fn construct() -> Self { + let shm_writer = ShmWriter::new(std::path::Path::new(CLOCKBOUND_SHM_DEFAULT_PATH)).unwrap(); + let safe_shm_writer = SafeShmWriter::new(shm_writer); + let clock_state_writer: ClockStateWriter = ClockStateWriter::builder() + .clock_disruption_support_enabled(true) + .shm_writer(safe_shm_writer) + .max_drift_ppb(15_000) + .disruption_marker(0) + .build(); + let clock_adjuster: ClockAdjuster = + ClockAdjuster::new(KAPIClockAdjuster); + Self::new(clock_state_writer, clock_adjuster) + } +} + #[cfg(test)] mod tests { use mockall::predicate::eq; - use crate::{ - daemon::{ - clock_parameters::ClockParameters, - clock_state::{ - ClockState, - clock_adjust::{MockClockAdjuster, NoopClockAdjuster, NtpAdjTimeError}, - clock_state_writer::MockClockStateWriter, - }, - time::{ - Duration, Instant, TscCount, - inner::ClockOffsetAndRtt, - instant::Utc, - timex::Timex, - tsc::{Period, Skew}, - }, + use crate::daemon::{ + clock_state::{clock_adjust::MockClockAdjust, clock_state_writer::MockClockStateWrite}, + time::{ + Duration, Instant, TscCount, + inner::ClockOffsetAndRtt, + instant::Utc, + timex::Timex, + tsc::{Period, Skew}, }, - shm::ShmWriter, }; + use super::*; + fn get_sample_clock_parameters() -> ClockParameters { ClockParameters { tsc_count: TscCount::new(0), @@ -123,8 +133,7 @@ mod tests { fn handle_clock_parameters() { let clock_parameters = get_sample_clock_parameters(); let clock_parameters_clone = clock_parameters.clone(); - let mut mock_clock_adjuster: MockClockAdjuster = - MockClockAdjuster::new(); + let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() .times(1) @@ -141,8 +150,7 @@ mod tests { }); let clock_parameters_clone = clock_parameters.clone(); - let mut mock_clock_state_writer: MockClockStateWriter = - MockClockStateWriter::new(); + let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer .expect_handle_clock_parameters() .once() @@ -162,8 +170,7 @@ mod tests { fn handle_clock_parameters_clock_adjust_hard_failure() { let clock_parameters = get_sample_clock_parameters(); let clock_parameters_clone = clock_parameters.clone(); - let mut mock_clock_adjuster: MockClockAdjuster = - MockClockAdjuster::new(); + let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() .times(1) @@ -174,8 +181,7 @@ mod tests { ) .return_once(|_, _| Err(NtpAdjTimeError::Failure(errno::errno()))); - let mut mock_clock_state_writer: MockClockStateWriter = - MockClockStateWriter::new(); + let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer .expect_handle_clock_parameters() .never(); @@ -188,8 +194,7 @@ mod tests { fn handle_clock_parameters_clock_adjust_soft_failure() { let clock_parameters = get_sample_clock_parameters(); let clock_parameters_clone = clock_parameters.clone(); - let mut mock_clock_adjuster: MockClockAdjuster = - MockClockAdjuster::new(); + let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() .times(1) @@ -200,8 +205,7 @@ mod tests { ) .return_once(|_, _| Err(NtpAdjTimeError::BadState(0))); - let mut mock_clock_state_writer: MockClockStateWriter = - MockClockStateWriter::new(); + let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer .expect_handle_clock_parameters() .never(); @@ -213,15 +217,13 @@ mod tests { #[test] fn handle_disruption() { let disruption_marker = 123; - let mut mock_clock_adjuster: MockClockAdjuster = - MockClockAdjuster::new(); + let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_disruption() .once() .with(eq(disruption_marker)) .return_const(()); - let mut mock_clock_state_writer: MockClockStateWriter = - MockClockStateWriter::new(); + let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer .expect_handle_disruption() .once() diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index f9d7bd8..dfc354e 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -54,35 +54,22 @@ pub struct ClockAdjuster { should_step: bool, } -#[cfg(test)] -mockall::mock! { - pub ClockAdjuster { - pub fn handle_clock_parameters( - &mut self, - // This is needed to tell ClockAdjust what frequency to use. - _clock_parameters: &ClockParameters, - // This is needed to tell ClockAdjust what phase offset to use. - clock_realtime_offset_and_rtt: ClockOffsetAndRtt, - ) -> Result; - pub fn handle_disruption(&mut self, new_disruption_marker: u64); - } +#[cfg_attr(test, mockall::automock)] +pub(crate) trait ClockAdjust { + fn handle_clock_parameters( + &mut self, + clock_parameters: &ClockParameters, + clock_realtime_offset_and_rtt: ClockOffsetAndRtt, + ) -> Result; + fn handle_disruption(&mut self, new_disruption_marker: u64); } -impl ClockAdjuster { - pub fn new(ntp_adjtime: T) -> Self { - // Should step on first adjustment. - let should_step = true; - Self { - ntp_adjtime, - should_step, - } - } - +impl ClockAdjust for ClockAdjuster { /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. /// /// # Errors /// This method returns [`NtpAdjTimeError`] if the call has failed or has an unexpected return code. - pub fn handle_clock_parameters( + fn handle_clock_parameters( &mut self, // This is needed to tell ClockAdjust what frequency to use. _clock_parameters: &ClockParameters, @@ -96,6 +83,52 @@ impl ClockAdjuster { } } + /// Handle a clock disruption event + /// + /// Call this function after the system detects a VMClock disruption event. + /// + /// It will go through and clear the state (like startup). + /// + /// # Panics + /// If `adjust_clock` fails, e.g. an invalid value was supplied to `ntp_adjtime` (unlikely), or + /// insufficient permissions to adjust the clock. + fn handle_disruption(&mut self, _new_disruption_marker: u64) { + // Use the destructure pattern to get a mutable reference to each item. + // + // This makes it a compilation error if we add a new field to Self without handling it here + let Self { + ntp_adjtime: _, + should_step: _, + } = self; + // At least stop any ongoing phase correction slew or frequency correction, if the clock is disrupted. + // Notably, phase correction slew is recalculated at the top of a second, so we still might end up having some moderate slew + // of the clock happening til that time. + info!("Resetting ntp_adjtime parameters to zero any phase or frequency corrections"); + match self.adjust_clock(Duration::from_secs(0), Skew::from_ppm(0.0)) { + failed_adjtime @ Err(NtpAdjTimeError::Failure(_)) => { + failed_adjtime.unwrap(); + } + Err(unexpected_adjtime_status) => { + error!("Unexpected adjtime result: {unexpected_adjtime_status}"); + } + _ => {} + } + // TODO: We may want to reset `should_step` if we think it is acceptable to step the clock on next adjustment + // for faster recovery.. + tracing::info!("Handled clock disruption event"); + } +} + +impl ClockAdjuster { + pub fn new(ntp_adjtime: T) -> Self { + // Should step on first adjustment. + let should_step = true; + Self { + ntp_adjtime, + should_step, + } + } + /// Performs an adjustment of the clock, to apply the given phase correction /// and skew values, in a single system call. /// @@ -113,7 +146,9 @@ impl ClockAdjuster { .skew(skew) .call(); - debug!("calling ntp_adjtime with {:?}", tx); + debug!( + "calling ntp_adjtime to adjust clock with phase_correction {phase_correction:?} and skew {skew:?}" + ); match self.ntp_adjtime.ntp_adjtime(&mut tx) { TIME_OK => Ok(tx), cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { @@ -135,7 +170,10 @@ impl ClockAdjuster { .phase_correction(phase_correction) .call(); - debug!("calling ntp_adjtime with {:?}", tx); + debug!( + "calling ntp_adjtime to step clock with phase_correction {phase_correction:?} {:?}", + tx + ); // NOTE: we actually expect TIME_ERROR if the clock adjustment succeeds, since // that indicates the clock is now "unsynchronized" (expected after we step the clock // discontinuously) @@ -152,41 +190,6 @@ impl ClockAdjuster { unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), } } - - /// Handle a clock disruption event - /// - /// Call this function after the system detects a VMClock disruption event. - /// - /// It will go through and clear the state (like startup). - /// - /// # Panics - /// If `adjust_clock` fails, e.g. an invalid value was supplied to `ntp_adjtime` (unlikely), or - /// insufficient permissions to adjust the clock. - pub fn handle_disruption(&mut self, _new_disruption_marker: u64) { - // Use the destructure pattern to get a mutable reference to each item. - // - // This makes it a compilation error if we add a new field to Self without handling it here - let Self { - ntp_adjtime: _, - should_step: _, - } = self; - // At least stop any ongoing phase correction slew or frequency correction, if the clock is disrupted. - // Notably, phase correction slew is recalculated at the top of a second, so we still might end up having some moderate slew - // of the clock happening til that time. - info!("Resetting ntp_adjtime parameters to zero any phase or frequency corrections"); - match self.adjust_clock(Duration::from_secs(0), Skew::from_ppm(0.0)) { - failed_adjtime @ Err(NtpAdjTimeError::Failure(_)) => { - failed_adjtime.unwrap(); - } - Err(unexpected_adjtime_status) => { - error!("Unexpected adjtime result: {unexpected_adjtime_status}"); - } - _ => {} - } - // TODO: We may want to reset `should_step` if we think it is acceptable to step the clock on next adjustment - // for faster recovery.. - tracing::info!("Handled clock disruption event"); - } } #[cfg(test)] diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index 09165ad..f420825 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -10,7 +10,7 @@ use crate::{ instant::Utc, }, }, - shm::{ClockErrorBound, ClockStatus, ShmWrite}, + shm::{ClockErrorBound, ClockStatus, ShmWrite, ShmWriter}, }; /// The drift rate/maximal frequency error in parts-per-billion @@ -19,6 +19,25 @@ use crate::{ #[expect(unused)] const FREQUENCY_ERROR_PPB: u32 = 15_000; +/// Newtype wrapper around `ShmWriter` so we can implement `Send` + `Sync`, +/// and thus construct this as part of an async task. +/// `ShmWriter` itself does not implement these because of its usage of a raw +/// pointer, but semantics in our daemon are that only one such `ShmWriter` should exist +/// at all, so `SafeShmWriter` does that for us. +pub struct SafeShmWriter(ShmWriter); +impl SafeShmWriter { + pub fn new(shm_writer: ShmWriter) -> Self { + Self(shm_writer) + } +} +impl ShmWrite for SafeShmWriter { + fn write(&mut self, ceb: &ClockErrorBound) { + self.0.write(ceb); + } +} +unsafe impl Send for SafeShmWriter {} +unsafe impl Sync for SafeShmWriter {} + pub struct ClockStateWriter { clock_status: ClockStatus, clock_disruption_support_enabled: bool, @@ -27,43 +46,22 @@ pub struct ClockStateWriter { disruption_marker: u64, } -#[cfg(test)] -mockall::mock! { - pub ClockStateWriter { - pub fn handle_clock_parameters( - &mut self, - // This is needed to tell ClockAdjust what frequency to use. - _clock_parameters: &ClockParameters, - // This is needed to tell ClockAdjust what phase offset to use. - clock_realtime_offset_and_rtt: ClockOffsetAndRtt, - ); - pub fn handle_disruption(&mut self, new_disruption_marker: u64); - } +#[cfg_attr(test, mockall::automock)] +pub(crate) trait ClockStateWrite { + fn handle_clock_parameters( + &mut self, + clock_parameters: &ClockParameters, + clock_realtime_offset_and_rtt: ClockOffsetAndRtt, + ); + fn handle_disruption(&mut self, new_disruption_marker: u64); } -#[bon::bon] -impl ClockStateWriter { - #[builder] - pub fn new( - clock_disruption_support_enabled: bool, - shm_writer: T, - max_drift_ppb: u32, - disruption_marker: u64, - ) -> Self { - Self { - clock_status: ClockStatus::Unknown, - clock_disruption_support_enabled, - shm_writer, - max_drift_ppb, - disruption_marker, - } - } - +impl ClockStateWrite for ClockStateWriter { /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. /// /// # Panics /// Panics if error bound calculated exceeds `i64::MAX` - pub fn handle_clock_parameters( + fn handle_clock_parameters( &mut self, // This is needed to tell ClockAdjust what frequency to use. clock_parameters: &ClockParameters, @@ -87,34 +85,12 @@ impl ClockStateWriter { self.write_shm(as_of, bound_nsec); } - fn write_shm(&mut self, as_of: Instant, bound_nsec: i64) { - let void_after = as_of + Duration::from_secs(1000); - // TODO: It may be worthwhile to add to this max drift ppb base the following components: - // - any slew rate for phase correction, since kernel clocks are used on client side - // - error inherent to our frequency calculation e.g. `period_max_error` - let max_drift_ppb = self.max_drift_ppb; - let ceb: ClockErrorBound = ClockErrorBound::new( - // Unwrap safety: unlikely to fail for any value for the distant future, - // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds - TimeSpec::try_from(as_of).unwrap(), - // Unwrap safety: unlikely to fail for any value for the distant future, - // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds - TimeSpec::try_from(void_after).unwrap(), - bound_nsec, - self.disruption_marker, - max_drift_ppb, - self.clock_status, - self.clock_disruption_support_enabled, - ); - self.shm_writer.write(&ceb); - } - /// Handle a clock disruption event /// /// Call this function after the system detects a VMClock disruption event. /// /// It will go through and clear the state (like startup). - pub fn handle_disruption(&mut self, new_disruption_marker: u64) { + fn handle_disruption(&mut self, new_disruption_marker: u64) { // Use the destructure pattern to get a mutable reference to each item. // // This makes it a compilation error if we add a new field to Self without handling it here @@ -134,6 +110,47 @@ impl ClockStateWriter { } } +#[bon::bon] +impl ClockStateWriter { + #[builder] + pub fn new( + clock_disruption_support_enabled: bool, + shm_writer: T, + max_drift_ppb: u32, + disruption_marker: u64, + ) -> Self { + Self { + clock_status: ClockStatus::Unknown, + clock_disruption_support_enabled, + shm_writer, + max_drift_ppb, + disruption_marker, + } + } + + fn write_shm(&mut self, as_of: Instant, bound_nsec: i64) { + let void_after = as_of + Duration::from_secs(1000); + // TODO: It may be worthwhile to add to this max drift ppb base the following components: + // - any slew rate for phase correction, since kernel clocks are used on client side + // - error inherent to our frequency calculation e.g. `period_max_error` + let max_drift_ppb = self.max_drift_ppb; + let ceb: ClockErrorBound = ClockErrorBound::new( + // Unwrap safety: unlikely to fail for any value for the distant future, + // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds + TimeSpec::try_from(as_of).unwrap(), + // Unwrap safety: unlikely to fail for any value for the distant future, + // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds + TimeSpec::try_from(void_after).unwrap(), + bound_nsec, + self.disruption_marker, + max_drift_ppb, + self.clock_status, + self.clock_disruption_support_enabled, + ); + self.shm_writer.write(&ceb); + } +} + /// Calculate the `ClockErrorBound` `bound_nsec` value. This is used to calculate /// the `earliest` and `latest` readings from a `now` call. /// From cad679216084b20f2847e502357d82cd451e9948 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Tue, 4 Nov 2025 11:46:02 -0800 Subject: [PATCH 075/177] Refactor the ClockBound rust client (#84) * Refactor the ClockBound rust client This patch is a first step towards exposing a slightly more abstracted interface for client applications. This serves the purpose of preparing future changes, mainly for a smoother customer experience. Some of the changes in this patch: - Remove some code redundancy when creating the ClockBound client. - Move default path to clockbound shm to shm module. - Add structs that encapsulate access to VMClock and ClockBound shared memory segment. - Bring the logic of asserting the clock disruption marker out of the original VMClock struct, and into the ClockBoundClient. Unit tests added are a slightly simpler versions of the ones that exist in the vmclock module. Last, note the vmclock module and the VMClock struct are kept for now, but will likely be removed once the C FFI are updated to use the ClockBoundClient instead. --- clock-bound/src/client.rs | 399 ++++++++++++++++++++++++++++--------- clock-bound/src/shm.rs | 2 + clock-bound/src/vmclock.rs | 3 + 3 files changed, 314 insertions(+), 90 deletions(-) diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index 8148572..5ffe02c 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -1,110 +1,233 @@ //! A client library to communicate with ClockBound daemon. This client library is written in pure Rust. //! +pub use crate::shm::CLOCKBOUND_SHM_DEFAULT_PATH; pub use crate::shm::ClockStatus; -use crate::shm::ShmError; -use crate::vmclock::VMClock; +use crate::shm::ShmReader; +use crate::shm::{ClockErrorBound, ShmError}; pub use crate::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; +use crate::vmclock::shm_reader::VMClockShmReader; use errno::Errno; use nix::sys::time::TimeSpec; +use std::ffi::CString; use std::path::Path; -pub const CLOCKBOUND_SHM_DEFAULT_PATH: &str = "/var/run/clockbound/shm0"; - +/// The `ClockBoundClient` +/// +/// Use it to return current time, the clock error bound and clock status associated with it. pub struct ClockBoundClient { - vmclock: VMClock, + clockbound_shm: ClockBoundSHM, + vmclock_shm: VMClockSHM, } impl ClockBoundClient { /// Creates and returns a new `ClockBoundClient`. /// - /// The creation process also initializes a shared memory reader - /// with the shared memory default path that is used by - /// the ClockBound daemon. - #[expect(clippy::missing_errors_doc, reason = "todo")] + /// The client accesses two shared memory segments. One written to by the ClockBound daemon. + /// The second one by the VMClock device (if available). + /// + /// Use default paths to the two shared memory segments. + /// + /// # Errors + /// Returns [`ClockBoundError`] if the shared memory segments cannot be open or accessed. pub fn new() -> Result { - // Validate that the default ClockBound shared memory path exists. - if !Path::new(CLOCKBOUND_SHM_DEFAULT_PATH).exists() { - let mut error = ClockBoundError::from(ShmError::SegmentNotInitialized); - error.detail = String::from( - "Default path for the ClockBound shared memory segment does not exist: ", - ); - error.detail.push_str(CLOCKBOUND_SHM_DEFAULT_PATH); - return Err(error); - } - - // Create a ClockBoundClient that makes use of the ClockBound daemon and VMClock. - // - // Clock disruption is expected to be handled by ClockBound daemon - // in coordination with this VMClock. - let vmclock = VMClock::new(CLOCKBOUND_SHM_DEFAULT_PATH, VMCLOCK_SHM_DEFAULT_PATH)?; - - Ok(ClockBoundClient { vmclock }) + Self::new_with_path(CLOCKBOUND_SHM_DEFAULT_PATH) } - /// Creates and returns a new `ClockBoundClient`, specifying a shared - /// memory path that is being used by the ClockBound daemon. - /// The VMClock will be accessed by reading the default VMClock - /// shared memory path. - #[expect(clippy::missing_errors_doc, reason = "todo")] + /// Creates and returns a new `ClockBoundClient`. + /// + /// The client accesses two shared memory segments. One written to by the ClockBound daemon. + /// The second one by the VMClock device (if available). + /// + /// Specify the path to the shared memory segment written to by the VMClock device. + /// Use the default paths to the ClockBound daemon shared memory segment. + /// + /// # Errors + /// Returns [`ClockBoundError`] if the shared memory segments cannot be open or accessed. pub fn new_with_path(clockbound_shm_path: &str) -> Result { - // Validate that the provided ClockBound shared memory path exists. - if !Path::new(clockbound_shm_path).exists() { - let mut error = ClockBoundError::from(ShmError::SegmentNotInitialized); - error.detail = String::from("Path in argument `clockbound_shm_path` does not exist: "); - error.detail.push_str(clockbound_shm_path); - return Err(error); - } - - // Create a ClockBoundClient that makes use of the ClockBound daemon and VMClock. - // - // Clock disruption is expected to be handled by ClockBound daemon - // in coordination with this VMClock. - let vmclock = VMClock::new(clockbound_shm_path, VMCLOCK_SHM_DEFAULT_PATH)?; - - Ok(ClockBoundClient { vmclock }) + Self::new_with_paths(clockbound_shm_path, VMCLOCK_SHM_DEFAULT_PATH) } - /// Creates and returns a new `ClockBoundClient`, specifying a shared - /// memory paths that are being used by the ClockBound daemon and by the VMClock, - /// respectively. - #[expect(clippy::missing_errors_doc, reason = "todo")] + /// Creates and returns a new `ClockBoundClient`. + /// + /// The client accesses two shared memory segments. One written to by the ClockBound daemon. + /// The second one by the VMClock device (if available). + /// + /// Explicitly specifies the paths to the two shared memory segments. + /// + /// # Errors + /// Returns [`ClockBoundError`] if the shared memory segments cannot be open or accessed. pub fn new_with_paths( clockbound_shm_path: &str, vmclock_shm_path: &str, ) -> Result { - // Validate that the provided shared memory paths exists. + // Create the clockbound shared memory accessor + let mut clockbound_shm = ClockBoundSHM::new(clockbound_shm_path)?; + + // Read the segment to determine whether the daemon has been instructed to provide support + // for clock disruption. If true, the VMClock will be accessed. + let cb_snapshot = clockbound_shm.snapshot()?; + + // Create the VMClock shared memory accessor + let vmclock_shm = VMClockSHM::new( + vmclock_shm_path, + cb_snapshot.clock_disruption_support_enabled, + )?; + + Ok(ClockBoundClient { + clockbound_shm, + vmclock_shm, + }) + } + + /// Read the current time, but with a bound on accuracy and a status. + /// + /// Returns a pair of (earliest, latest) timespec between which current time exists. The + /// interval width is twice the clock error bound (ceb) such that: + /// (earliest, latest) = ((now - ceb), (now + ceb)) + /// + /// The function also returns a clock status to assert that the clock is being synchronized, or + /// free-running, or ... + /// + /// # Errors + /// Returns [`ClockBoundError`] if the shared memory segments cannot be open or accessed. + pub fn now(&mut self) -> Result { + // The very first thing to do is to read from the ClockBound shared memory segment, take a + // snapshot to obtain the clock parameters and bound on error, and create a timestamp. + let cb_snap = self.clockbound_shm.snapshot()?; + let (earliest, latest, clock_status) = cb_snap.now()?; + + // Now that the timestamp is created, check whether the clockbound daemon has been + // restarted and the option to enable the clock disruption support has been turned on. If + // so, need to create a reader for the VMClock device shared memory. + if self.vmclock_shm.vmclock_shm_reader.is_none() && cb_snap.clock_disruption_support_enabled + { + self.vmclock_shm.vmclock_shm_reader = Some(VMClockShmReader::new( + self.vmclock_shm.vmclock_shm_path.as_str(), + )?); + } + + // Check whether the clock is disrupted. If the support to capture the clock disruption + // signal has been explicitly disabled, there is nothing to do. Otherwise, and if the + // VMClock shared memory is successfully read, this compares the value of the disruption + // marker between the clockbound daemon and the VMClock. If these disagree, a disruption + // has occured, and the clockbound daemon has not recovered from it yet. + let is_not_disrupted = + self.vmclock_shm.disruption_marker()? == Some(cb_snap.disruption_marker); + + // If the clock is disrupted, overwrite the status + let result_status = if is_not_disrupted { + clock_status + } else { + ClockStatus::Disrupted + }; + + Ok(ClockBoundNowResult { + earliest, + latest, + clock_status: result_status, + }) + } +} + +/// `ClockBoundSHM` handles access to the shared memory segment populated by the ClockBound daemon. +struct ClockBoundSHM { + #[allow(dead_code)] + clockbound_shm_path: String, + clockbound_shm_reader: ShmReader, +} + +impl ClockBoundSHM { + /// Create a new [`ClockBoundSHM`] and open the shared memory segment for reading. + /// + /// # Errors + /// Returns a [`ClockErrorBound`] with an appropriate `Errno`. If the content of the segment is + /// uninitialized, unparseable, or otherwise malformed. + fn new(clockbound_shm_path: &str) -> Result { + // Fail early if the provided shared memory path does not exist. if !Path::new(clockbound_shm_path).exists() { let mut error = ClockBoundError::from(ShmError::SegmentNotInitialized); - error.detail = String::from("Path in argument `clockbound_shm_path` does not exist: "); - error.detail.push_str(clockbound_shm_path); + error.detail = format!( + "Path to clockbound daemon shared memory segment does not exist: {clockbound_shm_path}" + ); return Err(error); } - let vmclock = VMClock::new(clockbound_shm_path, vmclock_shm_path)?; + let shm_path = CString::new(clockbound_shm_path).expect("CString::new failed"); + let shm_reader = ShmReader::new(shm_path.as_c_str())?; - Ok(ClockBoundClient { vmclock }) + Ok(ClockBoundSHM { + clockbound_shm_path: String::from(clockbound_shm_path), + clockbound_shm_reader: shm_reader, + }) } - /// Obtains the clock error bound and clock status at the current moment. - #[expect(clippy::missing_errors_doc, reason = "todo")] - pub fn now(&mut self) -> Result { - let (earliest, latest, clock_status) = self.vmclock.now()?; + /// Returns a snapshot of the shared memory segment last populated by the ClockBound daemon. + fn snapshot(&mut self) -> Result<&ClockErrorBound, ShmError> { + self.clockbound_shm_reader.snapshot() + } +} - Ok(ClockBoundNowResult { - earliest, - latest, - clock_status, +/// `VMClockSHM` handles access to the shared memory segment populated by the VMClock device. +struct VMClockSHM { + vmclock_shm_path: String, + vmclock_shm_reader: Option, +} + +impl VMClockSHM { + /// Create a new [`VMClockSHM`] and open the shared memory segment for reading, if needed. + /// + /// # Errors + /// Returns a [`ClockErrorBound`] with an appropriate `Errno`. If the content of the segment is + /// uninitialized, unparseable, or otherwise malformed. + fn new( + vmclock_shm_path: &str, + clock_disruption_support_enabled: bool, + ) -> Result { + // Note that the support for clock disruption signal may be disabled on the ClockBound + // daemon, in which case, no reader is created. + let mut vmclock_shm_reader: Option = None; + if clock_disruption_support_enabled { + // Fail early if the provided shared memory path does not exist. + if !Path::new(vmclock_shm_path).exists() { + let mut error = ClockBoundError::from(ShmError::SegmentNotInitialized); + error.detail = format!( + "Path to VMClock device shared memory segment does not exist: {vmclock_shm_path}" + ); + return Err(error); + } + vmclock_shm_reader = Some(VMClockShmReader::new(vmclock_shm_path)?); + } + + Ok(VMClockSHM { + vmclock_shm_path: String::from(vmclock_shm_path), + vmclock_shm_reader, }) } + + /// Take a snapshot of the VMClock shared memory segment and extract the disruption marker. + /// + /// Note that None is returned if no SHM reader is present. + /// + /// # Errors + /// Returns a [`ShmError`] if the content of the segment is uninitialized, unparseable, or + /// otherwise malformed. + fn disruption_marker(&mut self) -> Result, ShmError> { + if let Some(ref mut vmclock_shm_reader) = self.vmclock_shm_reader { + let snap = vmclock_shm_reader.snapshot()?; + return Ok(Some(snap.disruption_marker)); + } + + // The clock disruption support is not enabled + Ok(None) + } } -#[derive(Hash, PartialEq, Eq, Clone, Debug)] -pub enum ClockBoundErrorKind { - Syscall, - SegmentNotInitialized, - SegmentMalformed, - CausalityBreach, - SegmentVersionNotSupported, +/// Result of the `ClockBoundClient::now()` function. +#[derive(PartialEq, Clone, Debug)] +pub struct ClockBoundNowResult { + pub earliest: TimeSpec, + pub latest: TimeSpec, + pub clock_status: ClockStatus, } #[derive(Debug)] @@ -145,19 +268,21 @@ impl From for ClockBoundError { } } -/// Result of the `ClockBoundClient::now()` function. -#[derive(PartialEq, Clone, Debug)] -pub struct ClockBoundNowResult { - pub earliest: TimeSpec, - pub latest: TimeSpec, - pub clock_status: ClockStatus, +#[derive(Hash, PartialEq, Eq, Clone, Debug)] +pub enum ClockBoundErrorKind { + Syscall, + SegmentNotInitialized, + SegmentMalformed, + CausalityBreach, + SegmentVersionNotSupported, } #[cfg(test)] mod lib_tests { use super::*; use crate::shm::{ClockErrorBound, ShmWrite, ShmWriter}; - use crate::vmclock::shm::VMClockClockStatus; + use crate::vmclock::shm::{VMClockClockStatus, VMClockShmBody}; + use crate::vmclock::shm_writer::{VMClockShmWrite, VMClockShmWriter}; use byteorder::{NativeEndian, WriteBytesExt}; use std::ffi::CStr; use std::fs::{File, OpenOptions}; @@ -220,6 +345,29 @@ mod lib_tests { }; } + macro_rules! vmclockshmbody { + () => { + VMClockShmBody { + disruption_marker: 10, + flags: 0_u64, + _padding: [0x00, 0x00], + clock_status: VMClockClockStatus::Unknown, + leap_second_smearing_hint: 0, + tai_offset_sec: 37_i16, + leap_indicator: 0, + counter_period_shift: 0, + counter_value: 0, + counter_period_frac_sec: 0, + counter_period_esterror_rate_frac_sec: 0, + counter_period_maxerror_rate_frac_sec: 0, + time_sec: 0, + time_frac_sec: 0, + time_esterror_nanosec: 0, + time_maxerror_nanosec: 0, + } + }; + } + /// Test struct used to hold the expected fields in the VMClock shared memory segment. #[repr(C)] #[derive(Debug, Copy, Clone, PartialEq)] @@ -262,13 +410,84 @@ mod lib_tests { file.sync_all().expect("Sync to disk failed"); } - fn remove_path_if_exists(path_shm: &str) { - let path = Path::new(path_shm); - if path.exists() { - if path.is_dir() { - std::fs::remove_dir_all(path_shm).expect("failed to remove file"); + /// Helper function to remove files created during unit tests. + fn remove_file_or_directory(path: &str) { + // Busy looping on deleting the previous file, good enough for unit test + let p = Path::new(&path); + while p.exists() { + if p.is_dir() { + std::fs::remove_dir_all(&path).expect("failed to remove file"); } else { - std::fs::remove_file(path_shm).expect("failed to remove file"); + std::fs::remove_file(&path).expect("failed to remove file"); + } + } + } + + /// Assert that VMClock can be created successfully and the disruption marker is retrieved when + /// clock_disruption_support_enabled is true and a valid file exists at the vmclock_shm_path. + #[test] + fn test_vmclock_now_with_clock_disruption_support_enabled_success() { + let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); + let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); + let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); + remove_file_or_directory(&vmclock_shm_path); + + // Create and write the VMClock memory segment. + let vmclock_shm_body = vmclockshmbody!(); + let mut vmclock_shm_writer = VMClockShmWriter::new(Path::new(&vmclock_shm_path)) + .expect("Failed to create a VMClockShmWriter"); + vmclock_shm_writer.write(&vmclock_shm_body); + + // Create the VMClock, and assert that the creation was successful. + let vmclock_new_result = VMClockSHM::new(&vmclock_shm_path, true); + match vmclock_new_result { + Ok(mut vmclock) => { + // Assert that now() does not return an error. + let marker_result = vmclock.disruption_marker(); + assert!(marker_result.is_ok()); + assert!(marker_result.unwrap() == Some(10_u64)); + } + Err(_) => { + assert!(false); + } + } + } + + /// Assert that VMClock will fail to be created when clock_disruption_support_enabled is true + /// and no file exists at the vmclock_shm_path. + #[test] + fn test_vmclock_now_with_clock_disruption_support_enabled_failure() { + let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); + let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); + let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); + remove_file_or_directory(&vmclock_shm_path); + + // Create the VMClock, and assert that the creation was successful. + let vmclock_new_result = VMClockSHM::new(&vmclock_shm_path, true); + assert!(vmclock_new_result.is_err()); + } + + /// Assert that VMClock can be created successfully when clock_disruption_support_enabled is + /// false and no file exists at the vmclock_shm_path. + #[test] + fn test_vmclock_now_with_clock_disruption_support_not_enabled() { + let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); + let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); + let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); + remove_file_or_directory(&vmclock_shm_path); + + // Create the VMClock, and assert that the creation was successful. + // There should be no error even though there is no file located at vmclock_shm_path. + let vmclock_new_result = VMClockSHM::new(&vmclock_shm_path, false); + match vmclock_new_result { + Ok(mut vmclock) => { + // Assert that now() does not return an error. + let marker_result = vmclock.disruption_marker(); + assert!(marker_result.is_ok()); + assert!(marker_result.unwrap() == None) + } + Err(_) => { + assert!(false); } } } @@ -278,7 +497,7 @@ mod lib_tests { let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); let clockbound_shm_path = clockbound_shm_temppath.to_str().unwrap(); - remove_path_if_exists(clockbound_shm_path); + remove_file_or_directory(clockbound_shm_path); let result = ClockBoundClient::new_with_path(clockbound_shm_path); assert!(result.is_err()); } @@ -345,7 +564,7 @@ mod lib_tests { } }; - assert_eq!(now_result.clock_status, ClockStatus::Unknown); + assert_eq!(now_result.clock_status, ClockStatus::Disrupted); } #[test] @@ -354,11 +573,11 @@ mod lib_tests { let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); let clockbound_shm_path = clockbound_shm_temppath.to_str().unwrap(); - remove_path_if_exists(clockbound_shm_path); + remove_file_or_directory(clockbound_shm_path); let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); - remove_path_if_exists(vmclock_shm_path); + remove_file_or_directory(vmclock_shm_path); let result = ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path); assert!(result.is_err()); @@ -374,16 +593,16 @@ mod lib_tests { let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); - remove_path_if_exists(vmclock_shm_path); + remove_file_or_directory(vmclock_shm_path); let result = ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path); assert!(result.is_err()); - remove_path_if_exists(clockbound_shm_path); + remove_file_or_directory(clockbound_shm_path); // Test clockbound file does not exist but vmclock file exists. let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); let clockbound_shm_path = clockbound_shm_temppath.to_str().unwrap(); - remove_path_if_exists(clockbound_shm_path); + remove_file_or_directory(clockbound_shm_path); let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 69fd229..4788b32 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -25,6 +25,8 @@ use std::fmt; use common::{CLOCK_MONOTONIC, CLOCK_REALTIME, clock_gettime_safe}; +pub const CLOCKBOUND_SHM_DEFAULT_PATH: &str = "/var/run/clockbound/shm0"; + const CLOCKBOUND_RESTART_GRACE_PERIOD: TimeSpec = TimeSpec::new(5, 0); /// Convenience macro to build a `ShmError::SyscallError` with extra info from errno and custom diff --git a/clock-bound/src/vmclock.rs b/clock-bound/src/vmclock.rs index 8085968..03864e0 100644 --- a/clock-bound/src/vmclock.rs +++ b/clock-bound/src/vmclock.rs @@ -9,6 +9,9 @@ pub mod shm; pub mod shm_reader; pub mod shm_writer; +/// TODO: remove this module once the ffi code relies on the `ClockBoundClient` rather than the +/// VMClock struct here. +/// /// VMClock provides the following capabilities: /// /// - Error-bounded timestamps obtained from ClockBound daemon. From 38ea58593c16f101e33d1156228fe0557c35ec5c Mon Sep 17 00:00:00 2001 From: mk <55758543+mekabir@users.noreply.github.com> Date: Tue, 4 Nov 2025 16:09:07 -0500 Subject: [PATCH 076/177] Add shared ref to current selected clock source (#92) This commit adds a shared reference to the current selected clock source for the clock sync algorithm to maintain and the IO component to consume. Co-authored-by: MOHAMMED KABIR --- clock-bound/src/daemon.rs | 7 ++++++- clock-bound/src/daemon/clock_sync_algorithm.rs | 14 +++++++++++--- clock-bound/src/daemon/io.rs | 9 +++++++-- test/link-local/src/main.rs | 4 +++- test/ntp-source/src/main.rs | 4 +++- 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 057dc13..38acba3 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -19,6 +19,8 @@ pub mod subscriber; pub mod selected_clock; +use std::sync::Arc; + use crate::daemon::{ clock_state::{ ClockState, @@ -28,6 +30,7 @@ use crate::daemon::{ clock_sync_algorithm::{ClockSyncAlgorithm, source::NTPSource}, io::ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, receiver_stream::{ReceiverStream, RoutableEvent}, + selected_clock::SelectedClockSource, time::tsc::Skew, }; use rand::{RngCore, rng}; @@ -63,6 +66,7 @@ impl Daemon { }; let clock_state = ClockState::construct(); + let selected_clock = Arc::new(SelectedClockSource::default()); let clock_sync_algorithm = ClockSyncAlgorithm::builder() .link_local(clock_sync_algorithm::source::LinkLocal::new( MAX_DISPERSION_GROWTH, @@ -72,6 +76,7 @@ impl Daemon { MAX_DISPERSION_GROWTH, ), ) + .selected_clock(selected_clock.clone()) .build(); // Initializing async ring buffers for IO event delivery @@ -85,7 +90,7 @@ impl Daemon { .ntp_source_receiver_vec(ntp_source_event_receivers) .build(); - let mut io_front_end = io::SourceIO::construct(daemon_info); + let mut io_front_end = io::SourceIO::construct(selected_clock.clone(), daemon_info); // FIXME, we are basically starting the application in the constructor // We should be able to construct the link local and spawn it when `run` is called // diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index c45e039..662b6d0 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -8,11 +8,14 @@ pub mod ff; mod ring_buffer; -use std::net::SocketAddr; +use std::{net::SocketAddr, sync::Arc}; pub use ring_buffer::RingBuffer; -use crate::daemon::{clock_parameters::ClockParameters, event, subscriber::PRIMER_TARGET}; +use crate::daemon::{ + clock_parameters::ClockParameters, event, selected_clock::SelectedClockSource, + subscriber::PRIMER_TARGET, +}; pub mod source; @@ -28,12 +31,14 @@ pub mod source; /// /// # Usage /// TODO -#[derive(Debug, Clone, PartialEq, bon::Builder)] +#[derive(Debug, Clone, bon::Builder)] pub struct ClockSyncAlgorithm { /// The link-local reference clock's ff algorithm link_local: source::LinkLocal, // A Vector of ff algorithms for ntp source reference clocks pub ntp_sources: Vec, + /// Shared reference to the current selected clock source + selected_clock: Arc, } impl ClockSyncAlgorithm { @@ -113,8 +118,10 @@ impl ClockSyncAlgorithm { let Self { link_local, ntp_sources, + selected_clock, } = self; + selected_clock.none(); link_local.handle_disruption(); for source in ntp_sources { source.handle_disruption(); @@ -153,6 +160,7 @@ mod tests { let mut csa = ClockSyncAlgorithm::builder() .link_local(source::LinkLocal::new(Skew::from_ppm(15.0))) .ntp_sources(vec![]) + .selected_clock(Arc::new(SelectedClockSource::default())) .build(); let clock_parameters = csa.feed_link_local(event.clone()); diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 7dada4c..b962d0e 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use std::net::SocketAddr; +use std::sync::Arc; use tokio::net::UdpSocket; use tokio::sync::{mpsc, watch}; use tokio::task::spawn; @@ -14,6 +15,7 @@ use tracing::{debug, info, warn}; pub mod ntp; use crate::daemon::io::ntp::DaemonInfo; +use crate::daemon::selected_clock::SelectedClockSource; use crate::daemon::{async_ring_buffer, event}; mod link_local; @@ -40,13 +42,15 @@ pub struct SourceIO { vmclock: Option>, /// Contains the channel used to communicate clock disruption events. clock_disruption_channels: ClockDisruptionChannels, + /// Shared reference to the current selected clock source + selected_clock: Arc, /// Daemon metadata daemon_info: DaemonInfo, } impl SourceIO { /// Constructs a new `SourceIO` object and constructs the necessary resources. - pub fn construct(daemon_info: DaemonInfo) -> Self { + pub fn construct(selected_clock: Arc, daemon_info: DaemonInfo) -> Self { let (sender, receiver) = watch::channel::(ClockDisruptionEvent::default()); SourceIO { @@ -54,6 +58,7 @@ impl SourceIO { ntp_sources: HashMap::new(), vmclock: None, clock_disruption_channels: ClockDisruptionChannels { sender, receiver }, + selected_clock, daemon_info, } } @@ -319,7 +324,7 @@ mod tests { startup_id: 0xABCD_BCDE_CDEF_DEFA, }; - let mut source_io = SourceIO::construct(info); + let mut source_io = SourceIO::construct(Arc::new(SelectedClockSource::default()), info); source_io.create_link_local(event_sender).await; assert!(source_io.link_local.is_some()) diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index e1998ba..5c2e287 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -4,7 +4,9 @@ //! link local address and that the polling rate is roughly once a second. use clock_bound::daemon::io::SourceIO; +use clock_bound::daemon::selected_clock::SelectedClockSource; use clock_bound::daemon::{async_ring_buffer, io::ntp::DaemonInfo}; +use std::sync::Arc; use std::time; use rand::{RngCore, rng}; @@ -28,7 +30,7 @@ async fn main() { startup_id: rng().next_u64(), }; - let mut sourceio = SourceIO::construct(daemon_info); + let mut sourceio = SourceIO::construct(Arc::new(SelectedClockSource::default()), daemon_info); sourceio.create_link_local(link_local_sender).await; sourceio.spawn_all(); diff --git a/test/ntp-source/src/main.rs b/test/ntp-source/src/main.rs index bf76a5f..443e5ac 100644 --- a/test/ntp-source/src/main.rs +++ b/test/ntp-source/src/main.rs @@ -10,6 +10,8 @@ use clock_bound::daemon::io::{ SourceIO, ntp::{AWS_TEMP_PUBLIC_TIME_ADDRESSES, DaemonInfo}, }; +use clock_bound::daemon::selected_clock::SelectedClockSource; +use std::sync::Arc; use std::time::Duration; use rand::{RngCore, rng}; @@ -27,7 +29,7 @@ async fn main() { startup_id: rng().next_u64(), }; - let mut sourceio = SourceIO::construct(daemon_info); + let mut sourceio = SourceIO::construct(Arc::new(SelectedClockSource::default()), daemon_info); let mut receiver_vec: Vec> = Vec::new(); for address in AWS_TEMP_PUBLIC_TIME_ADDRESSES { From df46c06c67ada4c13461d58c7cb792ea5c4949a3 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 5 Nov 2025 11:00:53 -0500 Subject: [PATCH 077/177] fixup: side-by-side test runs without modifying system resources (#93) --- clock-bound/src/daemon.rs | 37 ++++---- clock-bound/src/daemon/clock_state.rs | 4 +- .../src/daemon/clock_state/clock_adjust.rs | 3 +- .../daemon/clock_state/clock_state_writer.rs | 2 +- .../src/daemon/clock_sync_algorithm.rs | 86 +++++++++++-------- clock-bound/src/daemon/receiver_stream.rs | 44 +++++++++- 6 files changed, 114 insertions(+), 62 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 38acba3..9cd1ff0 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -22,17 +22,19 @@ pub mod selected_clock; use std::sync::Arc; use crate::daemon::{ - clock_state::{ - ClockState, - clock_adjust::{ClockAdjuster, KAPIClockAdjuster}, - clock_state_writer::{ClockStateWriter, SafeShmWriter}, - }, clock_sync_algorithm::{ClockSyncAlgorithm, source::NTPSource}, io::ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, receiver_stream::{ReceiverStream, RoutableEvent}, selected_clock::SelectedClockSource, time::tsc::Skew, }; + +#[cfg(not(feature = "test-side-by-side"))] +use crate::daemon::clock_state::{ + ClockState, + clock_adjust::{ClockAdjuster, KAPIClockAdjuster}, + clock_state_writer::{ClockStateWriter, SafeShmWriter}, +}; use rand::{RngCore, rng}; /// The maximum dispersion growth every second @@ -52,6 +54,7 @@ pub struct Daemon { _io_front_end: io::SourceIO, clock_sync_algorithm: ClockSyncAlgorithm, receiver_stream: ReceiverStream, + #[cfg(not(feature = "test-side-by-side"))] clock_state: ClockState, ClockStateWriter>, } @@ -64,6 +67,7 @@ impl Daemon { minor_version: 100, startup_id: rng().next_u64(), }; + #[cfg(not(feature = "test-side-by-side"))] let clock_state = ClockState::construct(); let selected_clock = Arc::new(SelectedClockSource::default()); @@ -114,6 +118,7 @@ impl Daemon { _io_front_end: io_front_end, clock_sync_algorithm, receiver_stream, + #[cfg(not(feature = "test-side-by-side"))] clock_state, } } @@ -131,24 +136,12 @@ impl Daemon { } fn handle_event(&mut self, routable_event: RoutableEvent) { - match routable_event { - RoutableEvent::LinkLocalEvent(event) => { - if let Some(params) = self.clock_sync_algorithm.feed_link_local(event) { - self.clock_state.handle_clock_parameters(params); - } - } - RoutableEvent::NTPSourceEvent(sender_address, event) => { - if let Some(params) = self - .clock_sync_algorithm - .feed_ntp_source(sender_address, &event) - { - self.clock_state.handle_clock_parameters(params); - } - } - RoutableEvent::PhcEvent => { - todo!("Implement PHC IO event delivery") - } + #[cfg(not(feature = "test-side-by-side"))] + if let Some(params) = self.clock_sync_algorithm.feed(routable_event) { + self.clock_state.handle_clock_parameters(params); } + #[cfg(feature = "test-side-by-side")] + let _ = self.clock_sync_algorithm.feed(routable_event); } /// Takes in a vector of `source::NTPSource` structs and returns the `async_ring_buffer` senders and receivers diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 1690f07..1ca77b2 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -24,7 +24,7 @@ const CLOCKBOUND_SHM_DEFAULT_PATH: &str = "/var/run/clockbound/shm0"; /// with the `CLOCK_REALTIME` kernel clock to synchronize it with `ClockBound` estimate /// of UTC (`ClockBound` clock), and `ClockStateWriter` which manages writing /// the `ClockErrorBound` to SHM segment for the client to read. -pub(crate) struct ClockState { +pub struct ClockState { clock_state_writer: S, clock_adjuster: A, } @@ -70,7 +70,6 @@ impl ClockState { /// Call this function after the system detects a VMClock disruption event. /// /// It will go through and clear the state (like startup). - #[cfg_attr(not(test), expect(unused))] pub fn handle_disruption(&mut self, new_disruption_marker: u64) { // Use the destructure pattern to get a mutable reference to each item. // @@ -87,6 +86,7 @@ impl ClockState { } impl ClockState, ClockStateWriter> { + #[expect(clippy::missing_panics_doc, reason = "unwrap")] pub fn construct() -> Self { let shm_writer = ShmWriter::new(std::path::Path::new(CLOCKBOUND_SHM_DEFAULT_PATH)).unwrap(); let safe_shm_writer = SafeShmWriter::new(shm_writer); diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index dfc354e..6548519 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -55,7 +55,8 @@ pub struct ClockAdjuster { } #[cfg_attr(test, mockall::automock)] -pub(crate) trait ClockAdjust { +#[expect(clippy::missing_errors_doc, reason = "tphan to update")] +pub trait ClockAdjust { fn handle_clock_parameters( &mut self, clock_parameters: &ClockParameters, diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index f420825..6da4041 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -47,7 +47,7 @@ pub struct ClockStateWriter { } #[cfg_attr(test, mockall::automock)] -pub(crate) trait ClockStateWrite { +pub trait ClockStateWrite { fn handle_clock_parameters( &mut self, clock_parameters: &ClockParameters, diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 662b6d0..e39d841 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -13,8 +13,8 @@ use std::{net::SocketAddr, sync::Arc}; pub use ring_buffer::RingBuffer; use crate::daemon::{ - clock_parameters::ClockParameters, event, selected_clock::SelectedClockSource, - subscriber::PRIMER_TARGET, + clock_parameters::ClockParameters, event, receiver_stream::RoutableEvent, + selected_clock::SelectedClockSource, subscriber::PRIMER_TARGET, }; pub mod source; @@ -42,44 +42,61 @@ pub struct ClockSyncAlgorithm { } impl ClockSyncAlgorithm { + /// Feed the clock sync algorithm with a time synchronization event + pub fn feed(&mut self, routable_event: RoutableEvent) -> Option<&ClockParameters> { + #[cfg(all(feature = "test-side-by-side", not(test)))] + { + use crate::daemon::event::TscRtt; + let Some(system_clock) = routable_event.system_clock() else { + return self.feed_inner(routable_event); + }; + let system = system_clock.system_time; + let system_tsc = system_clock.tsc; + let tsc_rtt = routable_event.rtt(); + let retval = self.feed_inner(routable_event); + if let Some(new_params) = &retval { + let system_clock_tsc_age = system_tsc - new_params.tsc_count; + let system_clock_age = system_clock_tsc_age * new_params.period; + + let comparable_time = new_params.time + system_clock_age; + let offset_from_system_clock = comparable_time - system; + let ntp_rtt = tsc_rtt * new_params.period; + + tracing::info!( + ?system_clock_tsc_age, + ?offset_from_system_clock, + ?ntp_rtt, + ?tsc_rtt + ); + } + retval + } + #[cfg(any(not(feature = "test-side-by-side"), test))] + { + self.feed_inner(routable_event) + } + } + + /// Convenience function to allow for easy instrumenting + fn feed_inner(&mut self, routable_event: RoutableEvent) -> Option<&ClockParameters> { + match routable_event { + RoutableEvent::LinkLocalEvent(event) => self.feed_link_local(event), + RoutableEvent::NTPSourceEvent(sender_address, event) => { + self.feed_ntp_source(sender_address, &event) + } + RoutableEvent::PhcEvent => { + todo!("Implement PHC IO event delivery") + } + } + } + /// Feed event into the link local /// TODO: make this function private and call into it from `fn feed` when we have a routable event #[expect(clippy::missing_panics_doc, reason = "serialization will not fail")] pub fn feed_link_local(&mut self, event: event::Ntp) -> Option<&ClockParameters> { let serialized = serde_json::to_string(&event).unwrap(); - let output = { - #[cfg(all(feature = "test-side-by-side", not(test)))] - { - let Some(system_clock) = event.system_clock() else { - return self.link_local.feed(event); - }; - let system = system_clock.system_time; - let system_tsc = system_clock.tsc; - let tsc_rtt = event.tsc_post() - event.tsc_pre(); - let retval = self.link_local.feed(event); - if let Some(new_params) = &retval { - let system_clock_tsc_age = system_tsc - new_params.tsc_count; - let system_clock_age = system_clock_tsc_age * new_params.period; - - let comparable_time = new_params.time + system_clock_age; - let offset_from_system_clock = comparable_time - system; - let ntp_rtt = tsc_rtt * new_params.period; - - tracing::info!( - ?system_clock_tsc_age, - ?offset_from_system_clock, - ?ntp_rtt, - ?tsc_rtt - ); - } - retval - } - #[cfg(any(not(feature = "test-side-by-side"), test))] - { - self.link_local.feed(event) - } - }; + let output = self.link_local.feed(event); tracing::info!( target: PRIMER_TARGET, @@ -101,6 +118,7 @@ impl ClockSyncAlgorithm { for source in &mut self.ntp_sources { if source.socket_address() == sender_address { clock_parameters = source.feed(event.clone()); + break; } } clock_parameters diff --git a/clock-bound/src/daemon/receiver_stream.rs b/clock-bound/src/daemon/receiver_stream.rs index e1dc92e..f6ec0a5 100644 --- a/clock-bound/src/daemon/receiver_stream.rs +++ b/clock-bound/src/daemon/receiver_stream.rs @@ -16,7 +16,8 @@ use thiserror::Error; use tracing::info; use crate::daemon::async_ring_buffer::{BufferClosedError, Receiver}; -use crate::daemon::event::{Event, Ntp}; +use crate::daemon::event::{Event, Ntp, TscRtt}; +use crate::daemon::time::TscCount; use super::io::ntp::NTPSourceReceiver; @@ -141,7 +142,7 @@ enum SourceId { NTPSource(SocketAddr), } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub enum RoutableEvent { LinkLocalEvent(Ntp), NTPSourceEvent(SocketAddr, Ntp), @@ -150,6 +151,45 @@ pub enum RoutableEvent { PhcEvent, } +impl RoutableEvent { + /// Get the system clock info + #[cfg(all(not(test), feature = "test-side-by-side"))] + pub fn system_clock(&self) -> Option<&crate::daemon::event::SystemClockMeasurement> { + match self { + RoutableEvent::LinkLocalEvent(data) | RoutableEvent::NTPSourceEvent(_, data) => { + data.system_clock() + } + RoutableEvent::PhcEvent => { + todo!("Implement PHC IO source and data struct"); + } + } + } +} + +impl TscRtt for RoutableEvent { + fn tsc_pre(&self) -> TscCount { + match self { + RoutableEvent::LinkLocalEvent(data) | RoutableEvent::NTPSourceEvent(_, data) => { + data.tsc_pre() + } + RoutableEvent::PhcEvent => { + todo!("Implement PHC IO source and data struct"); + } + } + } + + fn tsc_post(&self) -> TscCount { + match self { + RoutableEvent::LinkLocalEvent(data) | RoutableEvent::NTPSourceEvent(_, data) => { + data.tsc_post() + } + RoutableEvent::PhcEvent => { + todo!("Implement PHC IO source and data struct"); + } + } + } +} + #[cfg(test)] mod tests { use std::net::{IpAddr, Ipv4Addr, SocketAddr}; From a32719f890d841a3b6159547834b0779f51f427b Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Wed, 5 Nov 2025 11:47:07 -0500 Subject: [PATCH 078/177] Added check when converting a ntp packet to ntpdata (#89) This commit updates the try function that checks the content of the ntp packet to ensure that the packet can be used to disciple the clock. --- clock-bound/src/daemon/io/ntp/packet.rs | 117 ++++++++++++++++++++---- 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/clock-bound/src/daemon/io/ntp/packet.rs b/clock-bound/src/daemon/io/ntp/packet.rs index d0c875e..5c9cca3 100644 --- a/clock-bound/src/daemon/io/ntp/packet.rs +++ b/clock-bound/src/daemon/io/ntp/packet.rs @@ -16,6 +16,7 @@ use std::fmt::Display; use bon::Builder; use nom::Parser; use nom::number::{be_i8, be_u8}; +use thiserror::Error; mod extension; mod header; @@ -217,32 +218,85 @@ impl Packet { } impl TryFrom for NtpData { - type Error = TryFromU8Error; + type Error = TryFromPacketError; fn try_from(value: Packet) -> Result { - let root_delay = ClockBoundDuration::from(value.root_delay); - let root_dispersion = ClockBoundDuration::from(value.root_dispersion); - let server_recv_time = ClockBoundInstant::from(value.receive_timestamp); - let server_send_time = ClockBoundInstant::from(value.transmit_timestamp); - - let stratum = Stratum::try_from(value.stratum)?; - - Ok(NtpData { - server_recv_time, - server_send_time, - - root_delay, - root_dispersion, - - stratum, - }) + match value { + // kiss-o'-death packet + // + // [rfc5905 7.4](https://datatracker.ietf.org/doc/html/rfc5905#section-7.4) + // When a packets stratum is set to "0" it implies that the packet is either invalid or + // unspecified. In either case the Reference ID field will contain a four letter ascii + // code specified the nature of the error. + Packet { + stratum: 0, + reference_id, + .. + } => Err(Self::Error::KissoDeath(reference_id)), + // Unsynchronized host + // + // Generally NTP servers identify themselves as unsynchronized by their stratum level + // and leap indicator value. A stratum of "16" and a leap indicator of "0x3" (Unknown) + // are the most widely used indicators. + Packet { + stratum: 16, + leap_indicator: LeapIndicator::Unknown, + .. + } => Err(Self::Error::Unsynchronized), + // Bad time stamp + // + // If the packet was received after it was sent, something has gone wrong and the + // packet's time stamps should not be used when synchronizing the clock. + Packet { + transmit_timestamp, + receive_timestamp, + .. + } if receive_timestamp > transmit_timestamp => Err(Self::Error::BadTimestamps), + // Useable packet + Packet { + root_delay, + root_dispersion, + receive_timestamp, + transmit_timestamp, + stratum, + .. + } => { + let root_delay = ClockBoundDuration::from(root_delay); + let root_dispersion = ClockBoundDuration::from(root_dispersion); + let server_recv_time = ClockBoundInstant::from(receive_timestamp); + let server_send_time = ClockBoundInstant::from(transmit_timestamp); + let stratum = Stratum::try_from(stratum)?; + Ok(NtpData { + server_recv_time, + server_send_time, + + root_delay, + root_dispersion, + + stratum, + }) + } + } } } +#[derive(Debug, Error, PartialEq)] +pub enum TryFromPacketError { + #[error("Could not parse stratum value.")] + ParsingStratum(#[from] TryFromU8Error), + #[error("Unspecified or invalid packet.")] + KissoDeath([u8; 4]), + #[error("The packet's timestamp indicates that it was received after it was transmitted.")] + BadTimestamps, + #[error("Server indicates that it is unsynchronized.")] + Unsynchronized, +} + #[cfg(test)] mod test { use chrono::{DateTime, TimeDelta}; use hex_literal::hex; + use rstest::rstest; use crate::daemon::io::ntp::packet::extension::Fec2V1Value; @@ -362,4 +416,33 @@ mod test { assert_eq!(custom_packet.reference_id, [0xa9, 0xfe, 0xa9, 0x7a]); assert_eq!(custom_packet.extensions.len(), 1); } + + #[rstest] + #[case::kiss_o_death( + Packet::builder().build(), + Err(TryFromPacketError::KissoDeath([0, 0, 0,0])) + )] + #[case::healthy( + Packet::builder() + .stratum(16) + .leap_indicator(LeapIndicator::Unknown) + .build(), + Err(TryFromPacketError::Unsynchronized) + )] + #[case::healthy( + Packet::builder() + .stratum(1) + .receive_timestamp(Timestamp::new(10)) + .transmit_timestamp(Timestamp::new(9)) + .build(), + Err(TryFromPacketError::BadTimestamps) + )] + fn packet_is_unuseable( + #[case] packet: Packet, + #[case] ntp_data: Result, + ) { + // This unit tests only tests for failures. + // The happy path is tested in `conversion_packet_to_ntp_data`. + assert_eq!(packet.try_into(), ntp_data); + } } From f04d213f32adfb36379cc551ce05d6248e0a3d20 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 5 Nov 2025 13:05:08 -0500 Subject: [PATCH 079/177] bugfix: normalize on estimate buffer using the SKM window (#90) --- .../ff/event_buffer/estimate.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs index fa4a874..14d9fa6 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs @@ -5,7 +5,7 @@ use super::Local; use crate::daemon::{ clock_sync_algorithm::RingBuffer, event::TscRtt, - time::{Duration, TscCount, tsc::Period}, + time::{TscCount, tsc::Period}, }; /// An estimate ring buffer @@ -145,7 +145,7 @@ impl Estimate { let diff = now_event_tsc_post - last_tsc_post; let duration = diff * period_estimate; - if duration < Duration::from_secs(1000) { + if duration < super::Local::::SKM_WINDOW { // SKM window hasn't expired yet. Bail out None } else { @@ -302,8 +302,8 @@ mod tests { let events: Vec<_> = (1..=100) .map(|i| { - //local buffer gets events from 902 seconds to 1001. Last one triggers - TestEvent::pre_and_rtt((i + 901) * 1_000_000_000, 100) + //local buffer gets events from 926 seconds to 1025. Last one triggers + TestEvent::pre_and_rtt((i + 925) * 1_000_000_000, 100) }) .collect(); @@ -349,8 +349,8 @@ mod tests { let events: Vec<_> = (1..=100) .map(|i| { - //local buffer gets events from 902 seconds to 1001. Last one triggers - TestEvent::pre_and_rtt((i + 901) * 1_000_000_000, 100) + //local buffer gets events from 926 seconds to 1025. Last one triggers + TestEvent::pre_and_rtt((i + 925) * 1_000_000_000, 100) }) .collect(); @@ -363,7 +363,7 @@ mod tests { }; // Overwrite the last event to have the minimum RTT - events[100] = TestEvent::pre_and_rtt(1001 * 1_000_000_000, 25); + events[100] = TestEvent::pre_and_rtt(1025 * 1_000_000_000, 25); // Feed everything but the last event. Estimate feed should return `None` since SKM never expired for i in 0..100 { From cfbffca275b3e9a0952a94453c5fdab73284a3bd Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 5 Nov 2025 16:05:21 -0500 Subject: [PATCH 080/177] Add PHC event and added support to ReceiverStream (#96) Some extra things that made it in here: - RoutableEvent enums removed Event from the variant names. That removes duplication when matching - Refactored away the SourceId enum. This becomes nonsensical when adding Phc - Receiver stream just takes in a Hashmap of NTP sources. IDK why we abstract to a vector of tuples - NTP sources is now optional (might be controversial, but IDK we'll be fine) --- clock-bound/src/daemon.rs | 4 +- .../src/daemon/clock_sync_algorithm.rs | 6 +- clock-bound/src/daemon/event.rs | 6 +- clock-bound/src/daemon/event/phc.rs | 123 ++++++++++++ clock-bound/src/daemon/receiver_stream.rs | 179 ++++++++---------- 5 files changed, 215 insertions(+), 103 deletions(-) create mode 100644 clock-bound/src/daemon/event/phc.rs diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 9cd1ff0..8ad0535 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -90,8 +90,8 @@ impl Daemon { // Initializing receiver stream with IO ring buffer receivers let receiver_stream: ReceiverStream = ReceiverStream::builder() - .link_local_receiver(link_local_rx) - .ntp_source_receiver_vec(ntp_source_event_receivers) + .link_local(link_local_rx) + .ntp_sources(ntp_source_event_receivers.into_iter().collect()) .build(); let mut io_front_end = io::SourceIO::construct(selected_clock.clone(), daemon_info); diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index e39d841..b99480b 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -80,11 +80,11 @@ impl ClockSyncAlgorithm { /// Convenience function to allow for easy instrumenting fn feed_inner(&mut self, routable_event: RoutableEvent) -> Option<&ClockParameters> { match routable_event { - RoutableEvent::LinkLocalEvent(event) => self.feed_link_local(event), - RoutableEvent::NTPSourceEvent(sender_address, event) => { + RoutableEvent::LinkLocal(event) => self.feed_link_local(event), + RoutableEvent::NtpSource(sender_address, event) => { self.feed_ntp_source(sender_address, &event) } - RoutableEvent::PhcEvent => { + RoutableEvent::Phc(_data) => { todo!("Implement PHC IO event delivery") } } diff --git a/clock-bound/src/daemon/event.rs b/clock-bound/src/daemon/event.rs index d724d8a..f0d6f7a 100644 --- a/clock-bound/src/daemon/event.rs +++ b/clock-bound/src/daemon/event.rs @@ -4,13 +4,17 @@ mod ntp; pub use ntp::{Ntp, NtpData, Stratum, TryFromU8Error, ValidStratumLevel}; +mod phc; +pub use phc::{Phc, PhcData}; + use crate::daemon::time::{TscCount, TscDiff}; /// A time synchronization event handled by ClockBound pub enum Event { /// NTP Event Ntp(Ntp), - Phc, + /// PHC Event + Phc(Phc), } /// Simple abstraction around types that have a TSC read before and after reference clock reads diff --git a/clock-bound/src/daemon/event/phc.rs b/clock-bound/src/daemon/event/phc.rs new file mode 100644 index 0000000..ef6d712 --- /dev/null +++ b/clock-bound/src/daemon/event/phc.rs @@ -0,0 +1,123 @@ +//! PHC Time synchronization events + +use crate::daemon::time::{Duration, Instant, TscCount}; + +use super::TscRtt; + +/// Contains the PHC and time stamp counter samples to be used by the synchronization algorithm +/// +/// `tsc_post` must be greater than `tsc_pre`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Phc { + /// TSC value before reading reference clock + tsc_pre: TscCount, + /// TSC value after reading reference clock + tsc_post: TscCount, + /// PHC reference clock info + data: PhcData, + #[cfg(all(not(test), feature = "test-side-by-side"))] + #[serde(skip)] + system_clock: Option, +} + +#[bon::bon] +impl Phc { + /// Construct a [`Phc`] + /// + /// Returns `None` if `tsc_post <= tsc_pre` + #[builder] + pub fn new( + tsc_pre: TscCount, + tsc_post: TscCount, + data: PhcData, + #[cfg(all(not(test), feature = "test-side-by-side"))] system_clock: Option< + super::SystemClockMeasurement, + >, + ) -> Option { + if tsc_post <= tsc_pre { + return None; + } + + Some(Self { + tsc_pre, + tsc_post, + data, + #[cfg(all(not(test), feature = "test-side-by-side"))] + system_clock, + }) + } +} + +impl Phc { + /// `tsc_pre` getter + pub fn tsc_pre(&self) -> TscCount { + self.tsc_pre + } + + /// `tsc_post` getter + pub fn tsc_post(&self) -> TscCount { + self.tsc_post + } + + /// phc data getter + pub fn data(&self) -> &PhcData { + &self.data + } + + /// system time getter + #[cfg(all(not(test), feature = "test-side-by-side"))] + pub fn system_clock(&self) -> Option<&super::SystemClockMeasurement> { + self.system_clock.as_ref() + } +} + +impl TscRtt for Phc { + fn tsc_pre(&self) -> TscCount { + self.tsc_pre + } + + fn tsc_post(&self) -> TscCount { + self.tsc_post + } +} + +/// PHC specific data +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PhcData { + /// Reference clock time + pub time: Instant, + /// Clock error bound of this measurement + pub clock_error_bound: Duration, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phc_invalid() { + let result = Phc::builder() + .tsc_post(TscCount::new(5)) + .tsc_pre(TscCount::new(10)) // pre after post + .data(PhcData { + time: Instant::from_days(5), + clock_error_bound: Duration::from_micros(15), + }) + .build(); + + assert!(result.is_none()); + } + + #[test] + fn phc_valid() { + let _result = Phc::builder() + .tsc_post(TscCount::new(10)) + .tsc_pre(TscCount::new(5)) // pre before post + .data(PhcData { + time: Instant::from_days(5), + clock_error_bound: Duration::from_micros(15), + }) + .build() + .unwrap(); + } +} diff --git a/clock-bound/src/daemon/receiver_stream.rs b/clock-bound/src/daemon/receiver_stream.rs index f6ec0a5..11e6a2e 100644 --- a/clock-bound/src/daemon/receiver_stream.rs +++ b/clock-bound/src/daemon/receiver_stream.rs @@ -16,7 +16,7 @@ use thiserror::Error; use tracing::info; use crate::daemon::async_ring_buffer::{BufferClosedError, Receiver}; -use crate::daemon::event::{Event, Ntp, TscRtt}; +use crate::daemon::event::{self, TscRtt}; use crate::daemon::time::TscCount; use super::io::ntp::NTPSourceReceiver; @@ -29,31 +29,14 @@ pub enum ReceiverStreamError { /// Type to hold the stream produced from each `SourceIO` components receiver type EventStream<'a> = - Pin)> + 'a + Send>>; + Pin> + 'a + Send>>; +#[derive(Debug, bon::Builder)] pub struct ReceiverStream { - ntp_sources: HashMap>, - link_local: Receiver, -} - -#[bon::bon] -impl ReceiverStream { - /// Initializer for `ReceiverStream` struct - #[builder] - pub fn new( - link_local_receiver: Receiver, - ntp_source_receiver_vec: Vec, - ) -> Self { - let mut rs = ReceiverStream { - ntp_sources: HashMap::new(), - link_local: link_local_receiver, - }; - for source in ntp_source_receiver_vec { - rs.add_ntp_source(source); - } - - rs - } + #[builder(default)] + ntp_sources: HashMap>, + link_local: Receiver, + phc: Option>, } /// `ReceiverStream` provides methods for aggregating the events delivered @@ -61,15 +44,14 @@ impl ReceiverStream { impl ReceiverStream { /// Adds a new ntp source to the `ntp_sources` pub fn add_ntp_source(&mut self, source: NTPSourceReceiver) { - let (id, receiver) = source; - let _ = &self.ntp_sources.insert(SourceId::NTPSource(id), receiver); + let (socket_addr, receiver) = source; + let _ = &self.ntp_sources.insert(socket_addr, receiver); } /// Removes an ntp source from `ntp_sources` - pub fn remove_ntp_source(&mut self, id: SocketAddr) { - let source_id = &SourceId::NTPSource(id); - if self.ntp_sources.contains_key(source_id) { - self.ntp_sources.remove(source_id); + pub fn remove_ntp_source(&mut self, id: &SocketAddr) { + if self.ntp_sources.contains_key(id) { + self.ntp_sources.remove(id); } } @@ -80,17 +62,23 @@ impl ReceiverStream { // Add NTP source streams for (source_id, source_receiver) in &mut self.ntp_sources { let source_id = *source_id; - streams.push(Box::pin( - once(source_receiver.recv()).map(move |result| (source_id, result.map(Event::Ntp))), - )); + streams.push(Box::pin(once(source_receiver.recv()).map(move |result| { + result.map(|event| RoutableEvent::NtpSource(source_id, event)) + }))); } // Add the link_local receiver to the streams streams.push(Box::pin( - once(self.link_local.recv()) - .map(|result| (SourceId::LinkLocal, result.map(Event::Ntp))), + once(self.link_local.recv()).map(|result| result.map(RoutableEvent::LinkLocal)), )); + // Add PHC if it's available + if let Some(phc_receiver) = &mut self.phc { + streams.push(Box::pin( + once(phc_receiver.recv()).map(|result| result.map(RoutableEvent::Phc)), + )); + } + // Shuffles vector to avoid unfair treatment of any preloaded events. // Context: Without this shuffle, if events are loaded into the buffer before `recv` is called, // those events will be returned in the order that their relative stream is added to the `streams` vector. @@ -110,45 +98,25 @@ impl ReceiverStream { /// /// # Returns /// a `RoutableEvent` wrapping the first event returned by the aggregate stream. + #[expect(clippy::missing_panics_doc, reason = "not expected in alpha")] pub async fn recv(&mut self) -> Option { let mut result_stream = self.get_aggregate_stream(); // Handle first result from the stream - let Some((source_id, event_result)) = result_stream.next().await else { + let Some(event_result) = result_stream.next().await else { info!("Aggregate stream is empty, no futures to await"); return None; }; - match event_result { - Ok(Event::Ntp(ntp_event)) => match source_id { - SourceId::LinkLocal => Some(RoutableEvent::LinkLocalEvent(ntp_event)), - SourceId::NTPSource(id) => Some(RoutableEvent::NTPSourceEvent(id, ntp_event)), - }, - Ok(Event::Phc) => { - // FIXME: returned event should house data delivered from the PHC read - todo!("Implement PHC IO source and data struct"); - } - Err(_) => { - todo!( - "Implement logic for buffers closing. We do not expect this to happen as a part of the alpha release implementation" - ); - } - } + let routable_event = event_result.expect("todo: Implement logic for buffers closing. We do not expect this to happen as a part of the alpha release implementation"); + Some(routable_event) } } -#[derive(Debug, Hash, PartialEq, std::cmp::Eq, Clone, Copy)] -enum SourceId { - LinkLocal, - NTPSource(SocketAddr), -} - #[derive(Debug, PartialEq, Clone)] pub enum RoutableEvent { - LinkLocalEvent(Ntp), - NTPSourceEvent(SocketAddr, Ntp), - // FIXME: The PhcEvent should wrap around phc data. - // The phc data struct has yet to be implemented. Below enum is a placeholder - PhcEvent, + LinkLocal(event::Ntp), + NtpSource(SocketAddr, event::Ntp), + Phc(event::Phc), } impl RoutableEvent { @@ -156,12 +124,10 @@ impl RoutableEvent { #[cfg(all(not(test), feature = "test-side-by-side"))] pub fn system_clock(&self) -> Option<&crate::daemon::event::SystemClockMeasurement> { match self { - RoutableEvent::LinkLocalEvent(data) | RoutableEvent::NTPSourceEvent(_, data) => { + RoutableEvent::LinkLocal(data) | RoutableEvent::NtpSource(_, data) => { data.system_clock() } - RoutableEvent::PhcEvent => { - todo!("Implement PHC IO source and data struct"); - } + RoutableEvent::Phc(data) => data.system_clock(), } } } @@ -169,23 +135,15 @@ impl RoutableEvent { impl TscRtt for RoutableEvent { fn tsc_pre(&self) -> TscCount { match self { - RoutableEvent::LinkLocalEvent(data) | RoutableEvent::NTPSourceEvent(_, data) => { - data.tsc_pre() - } - RoutableEvent::PhcEvent => { - todo!("Implement PHC IO source and data struct"); - } + RoutableEvent::LinkLocal(data) | RoutableEvent::NtpSource(_, data) => data.tsc_pre(), + RoutableEvent::Phc(data) => data.tsc_pre(), } } fn tsc_post(&self) -> TscCount { match self { - RoutableEvent::LinkLocalEvent(data) | RoutableEvent::NTPSourceEvent(_, data) => { - data.tsc_post() - } - RoutableEvent::PhcEvent => { - todo!("Implement PHC IO source and data struct"); - } + RoutableEvent::LinkLocal(data) | RoutableEvent::NtpSource(_, data) => data.tsc_post(), + RoutableEvent::Phc(data) => data.tsc_post(), } } } @@ -196,7 +154,7 @@ mod tests { use super::*; use crate::daemon::async_ring_buffer::create; - use crate::daemon::event::{Ntp, NtpData, Stratum}; + use crate::daemon::event::{Ntp, NtpData, PhcData, Stratum}; use crate::daemon::time::{Duration, Instant, TscCount}; #[tokio::test] @@ -207,8 +165,8 @@ mod tests { let dummy_ntp_source_ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 123); let mut rx_stream = ReceiverStream::builder() - .link_local_receiver(link_local_rx) - .ntp_source_receiver_vec(vec![(dummy_ntp_source_ip, ntp_source_rx)]) + .link_local(link_local_rx) + .ntp_sources(HashMap::from([(dummy_ntp_source_ip, ntp_source_rx)])) .build(); let dummy_ntp_data = Ntp::builder() @@ -224,28 +182,28 @@ mod tests { .build() .unwrap(); - let _ = link_local_tx.send(dummy_ntp_data.clone()); - let _ = ntp_source_tx.send(dummy_ntp_data.clone()); + link_local_tx.send(dummy_ntp_data.clone()).unwrap(); + ntp_source_tx.send(dummy_ntp_data.clone()).unwrap(); let num_events = 2; let mut counter = 0; for _ in 0..num_events { match rx_stream.recv().await.unwrap() { - RoutableEvent::LinkLocalEvent(data) => { + RoutableEvent::LinkLocal(data) => { counter += 1; assert_eq!( - RoutableEvent::LinkLocalEvent(dummy_ntp_data.clone()), - RoutableEvent::LinkLocalEvent(data) + RoutableEvent::LinkLocal(dummy_ntp_data.clone()), + RoutableEvent::LinkLocal(data) ); } - RoutableEvent::NTPSourceEvent(ip, data) => { + RoutableEvent::NtpSource(ip, data) => { counter += 1; assert_eq!( - RoutableEvent::NTPSourceEvent(dummy_ntp_source_ip, dummy_ntp_data.clone()), - RoutableEvent::NTPSourceEvent(ip, data) + RoutableEvent::NtpSource(dummy_ntp_source_ip, dummy_ntp_data.clone()), + RoutableEvent::NtpSource(ip, data) ); } - RoutableEvent::PhcEvent => { + RoutableEvent::Phc(_data) => { assert!(false, "Phc event delivery has yet to be implemented") } }; @@ -257,6 +215,36 @@ mod tests { ); } + #[tokio::test] + async fn phc_stream() { + let (_link_local_tx, link_local_rx) = create(1); + let (phc_tx, phc_rx) = create(1); + + let mut rx_stream = ReceiverStream::builder() + .link_local(link_local_rx) + .phc(phc_rx) + .build(); + + let phc_data = event::Phc::builder() + .tsc_pre(TscCount::new(1)) + .tsc_post(TscCount::new(2)) + .data(PhcData { + clock_error_bound: Duration::from_micros(20), + time: Instant::from_days(3), + }) + .build() + .unwrap(); + + phc_tx.send(phc_data.clone()).unwrap(); + + let result = rx_stream.recv().await.unwrap(); + let RoutableEvent::Phc(data) = &result else { + panic!("Expected to receive a Phc event, got {result:?}") + }; + + assert_eq!(*data, phc_data); + } + #[test] fn add_ntp_source() { let (_, link_local_rx) = create(1); @@ -265,10 +253,7 @@ mod tests { let dummy_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 123); let dummy_ntp_source_receiver: NTPSourceReceiver = (dummy_address, ntp_source_rx); - let mut rx_stream = ReceiverStream::builder() - .link_local_receiver(link_local_rx) - .ntp_source_receiver_vec(vec![]) - .build(); + let mut rx_stream = ReceiverStream::builder().link_local(link_local_rx).build(); assert!(rx_stream.ntp_sources.is_empty()); @@ -286,13 +271,13 @@ mod tests { let dummy_ntp_source_receiver: NTPSourceReceiver = (dummy_address, ntp_source_rx); let mut rx_stream = ReceiverStream::builder() - .link_local_receiver(link_local_rx) - .ntp_source_receiver_vec(vec![dummy_ntp_source_receiver]) + .link_local(link_local_rx) + .ntp_sources(HashMap::from([dummy_ntp_source_receiver])) .build(); assert!(rx_stream.ntp_sources.len() == 1); - rx_stream.remove_ntp_source(dummy_address); + rx_stream.remove_ntp_source(&dummy_address); assert!(rx_stream.ntp_sources.is_empty()); } From f728abb2f8035f9009aa93760cdecdb6a44171f8 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Thu, 6 Nov 2025 10:03:21 -0500 Subject: [PATCH 081/177] Added clock disruption event support for link local sources. (#68) * Adding logic to handle clock disruption events to link local --- clock-bound/src/daemon.rs | 1 + clock-bound/src/daemon/io/link_local.rs | 140 +++++++++++++++++++++++- clock-bound/src/daemon/io/ntp.rs | 2 + 3 files changed, 138 insertions(+), 5 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 8ad0535..74c316b 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -15,6 +15,7 @@ pub mod time; pub mod event; pub mod receiver_stream; + pub mod subscriber; pub mod selected_clock; diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index 10c408f..f18ccb8 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -5,10 +5,14 @@ use tokio::{ io, net::UdpSocket, sync::{mpsc, watch}, - time::{self, Interval, MissedTickBehavior, interval, timeout}, + time::{self, Duration, Instant, Interval, MissedTickBehavior, interval, timeout}, }; use tracing::{debug, info}; +use super::ntp::{ + LINK_LOCAL_ADDRESS, LINK_LOCAL_BURST_DURATION, LINK_LOCAL_INTERVAL_DURATION, + LINK_LOCAL_TIMEOUT, packet, +}; use super::tsc::read_timestamp_counter; use super::{ClockDisruptionEvent, ControlRequest}; use crate::daemon::{ @@ -17,9 +21,11 @@ use crate::daemon::{ time::tsc::TscCount, }; -use super::ntp::{LINK_LOCAL_ADDRESS, LINK_LOCAL_INTERVAL_DURATION, LINK_LOCAL_TIMEOUT, packet}; use packet::Packet; +/// The amount of time between source polls when in burst mode. +const BURST_INTERVAL_DURATION: Duration = Duration::from_millis(50); + #[derive(Debug, Error)] pub enum LinkLocalError { #[error("IO failure.")] @@ -43,6 +49,7 @@ pub struct LinkLocal { clock_disruption_receiver: watch::Receiver, ntp_buffer: [u8; Packet::SIZE], interval: Interval, + mode: Mode, } impl LinkLocal { @@ -62,6 +69,7 @@ impl LinkLocal { clock_disruption_receiver, ntp_buffer: [0u8; Packet::SIZE], interval: link_local_interval, + mode: Mode::Normal, } } @@ -127,7 +135,18 @@ impl LinkLocal { /// NTP Link Local task runner. /// - /// Sampling NTP packets from the AWS EC2 internal Link Local address. + /// Samples NTP packets from the AWS EC2 internal Link Local address. + /// + /// The function runs in two modes a normal mode and a burst mode. + /// + /// While in burst mode the link local source is polled more frequently, + /// [`LINK_LOCAL_BURST_INTERVAL_DURATION`]. + /// Burst mode is triggered when: + /// - a clock disruption signal is received. + /// - ... + /// + /// Burst mode is active for a set amount of time, [`LINK_LOCAL_BURST_DURATION`], before + /// transitioning back to normal mode. /// /// # Panics /// Function will panic if not called within the `tokio` runtime. @@ -146,7 +165,11 @@ impl LinkLocal { self.event_sender.send(ntp_event.clone()).expect("Buffer Closing is not expected in alpha."); debug!(?ntp_event, "Successfully sent Link Local IO event."); } - + } + if let Mode::Burst(start_time) = self.mode + && start_time.elapsed() >= LINK_LOCAL_BURST_DURATION { + self.transition_to_normal_mode(); + info!("Transitioning from `Burst` mode to `Normal` mode."); } } _ = self.ctrl_receiver.recv() => { @@ -156,11 +179,118 @@ impl LinkLocal { } _ = self.clock_disruption_receiver.changed() => { // Clock Disruption logic here - todo!("Clock disruption logic has yet to be implemented."); + self.transition_to_burst_mode(); + info!("Received clock disruption signal. Entering Burst mode."); } } } info!("Link local runner exiting."); Ok(()) } + + /// Changes the source's mode to [`Mode::Burst`] and the polling frequency to + /// [`BURST_INTERVAL_DURATION`]. + fn transition_to_burst_mode(&mut self) { + let LinkLocal { + socket: _socket, + event_sender: _event_sender, + ctrl_receiver: _ctrl_receiver, + clock_disruption_receiver: _clock_disruption_receiver, + ntp_buffer: _ntp_buffer, + interval: ll_interval, + mode, + } = self; + + *mode = Mode::burst(); + *ll_interval = interval(BURST_INTERVAL_DURATION); + ll_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + ll_interval.reset_immediately(); + } + + /// Changes the source's mode to [`Mode::Normal`] and changes polling frequency to + /// [`LINK_LOCAL_INTERVAL_DURATION`]. + fn transition_to_normal_mode(&mut self) { + let LinkLocal { + socket: _socket, + event_sender: _event_sender, + ctrl_receiver: _ctrl_receiver, + clock_disruption_receiver: _clock_disruption_receiver, + ntp_buffer: _ntp_buffer, + interval: ll_interval, + mode, + } = self; + + *mode = Mode::Normal; + *ll_interval = interval(LINK_LOCAL_INTERVAL_DURATION); + ll_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + } +} + +// TODO: Move to a higher level. This enum should be shared by all io sources. +/// An enum indicating the interval state of the of the source io. +/// +/// # Variants: +/// - `Normal` mode indicates that the source is sampling at a constant frequency and remain so unless given an external signal. +/// +/// - `Burst` mode indicates that the source is in a temporary mode during which the underlying source is polled more frequently. +#[derive(Debug)] +enum Mode { + /// Indicates that the source is in its normal operating mode. + Normal, + /// Indicates that the source should be in burst mode and when it entered burst mode. + Burst(Instant), +} + +impl Mode { + /// Constructs the [`Mode::Burst`] enum variant coupling the start time generation. + fn burst() -> Mode { + Mode::Burst(Instant::now()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::io::ntp; + + async fn create_link_local() -> (LinkLocal, watch::Sender) { + let (event_sender, _) = async_ring_buffer::create::(1); + let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) + .await + .unwrap(); + let (_, ctrl_receiver) = mpsc::channel::(1); + let (clock_disruption_sender, clock_disruption_receiver) = + watch::channel::(ClockDisruptionEvent { + disruption_marker: None, + }); + + ( + LinkLocal::construct( + socket, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ), + clock_disruption_sender, + ) + } + + #[tokio::test] + async fn validate_to_burst_mode() { + let (mut link_local, _) = create_link_local().await; + link_local.transition_to_burst_mode(); + + assert!(matches!(link_local.mode, Mode::Burst(_))); + assert_eq!(link_local.interval.period(), BURST_INTERVAL_DURATION); + } + + #[tokio::test] + async fn validate_to_normal_mode() { + let (mut link_local, _) = create_link_local().await; + link_local.transition_to_burst_mode(); + link_local.transition_to_normal_mode(); + + assert!(matches!(link_local.mode, Mode::Normal)); + assert_eq!(link_local.interval.period(), LINK_LOCAL_INTERVAL_DURATION); + } } diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index f16c556..6bb9146 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -8,6 +8,8 @@ pub use packet::{Fec2V1Value as DaemonInfo, Packet}; use crate::daemon::{async_ring_buffer, event}; +pub const LINK_LOCAL_BURST_DURATION: Duration = Duration::from_secs(1); + pub const UNSPECIFIED_SOCKET_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); pub const LINK_LOCAL_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(169, 254, 169, 123), 123); From 602cb8ff6ea822302f3de2b4de9016fc08b5a3e2 Mon Sep 17 00:00:00 2001 From: mk <55758543+mekabir@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:06:21 -0500 Subject: [PATCH 082/177] SelectedClockSource follow up: Improve API (#94) - Make set methods obvious - Fail on unreachable case - Enable setting Server variant with ipv6 Co-authored-by: MOHAMMED KABIR --- Cargo.lock | 7 ++ clock-bound/Cargo.toml | 1 + .../src/daemon/clock_sync_algorithm.rs | 2 +- clock-bound/src/daemon/selected_clock.rs | 96 +++++++++++++------ 4 files changed, 78 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3a25a1..0acba2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,6 +265,7 @@ dependencies = [ "futures", "hex-literal", "libc", + "md5", "mockall", "mockall_double", "nix", @@ -774,6 +775,12 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.7.6" diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index b93d36d..cd021fc 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -22,6 +22,7 @@ errno = { version = "0.3.0", default-features = false } libc = { version = "0.2", default-features = false, features = [ "extra_traits", ] } +md5 = "0.8.0" nix = { version = "0.26", features = ["feature", "time"] } nom = { version = "8", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index b99480b..5b8d665 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -139,7 +139,7 @@ impl ClockSyncAlgorithm { selected_clock, } = self; - selected_clock.none(); + selected_clock.set_to_none(); link_local.handle_disruption(); for source in ntp_sources { source.handle_disruption(); diff --git a/clock-bound/src/daemon/selected_clock.rs b/clock-bound/src/daemon/selected_clock.rs index d7559fa..56e1a04 100644 --- a/clock-bound/src/daemon/selected_clock.rs +++ b/clock-bound/src/daemon/selected_clock.rs @@ -2,10 +2,12 @@ use std::{ fmt::Display, - net::Ipv4Addr, + net::IpAddr, sync::atomic::{AtomicU64, Ordering}, }; +use md5; + use crate::daemon::event::Stratum; /// Thread-safe storage for the currently selected clock source and its stratum @@ -33,22 +35,29 @@ impl SelectedClockSource { } /// Set the clock source to PHC - pub fn phc(&self) { + pub fn set_to_phc(&self) { self.set(ClockSource::Phc, Stratum::Unspecified); } /// Set the clock source to a remote NTP server - pub fn server(&self, ip: Ipv4Addr, stratum: Stratum) { - self.set(ClockSource::Server(ip), stratum); + pub fn set_to_server(&self, ip: IpAddr, stratum: Stratum) { + let refid = match ip { + IpAddr::V4(ipv4) => u32::from(ipv4), + IpAddr::V6(ipv6) => { + let hash = md5::compute(ipv6.octets()); + u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]]) + } + }; + self.set(ClockSource::Server(refid), stratum); } /// Set the clock source to unsynchronized state - pub fn none(&self) { + pub fn set_to_none(&self) { self.set(ClockSource::None, Stratum::Unsynchronized); } /// Set the clock source to VMClock - pub fn vmclock(&self) { + pub fn set_to_vmclock(&self) { self.set(ClockSource::VMClock, Stratum::Unspecified); } @@ -60,12 +69,17 @@ impl SelectedClockSource { v if v == u32::from_be_bytes(*b"INIT") => ClockSource::Init, v if v == u32::from_be_bytes(*b"XPHC") => ClockSource::Phc, v if v == u32::from_be_bytes(*b"XVMC") => ClockSource::VMClock, - _ => ClockSource::Init, // Defensively fallback to INIT; - // Shouldn't occur given the restricted API + _ => { + let bytes = refid.to_be_bytes(); + unreachable!( + "Unknown kiss code [{}, {}, {}, {}]; should not occur with restricted API", + bytes[0], bytes[1], bytes[2], bytes[3] + ) + } } } + Stratum::Level(_) => ClockSource::Server(refid), Stratum::Unsynchronized => ClockSource::None, - Stratum::Level(_) => ClockSource::Server(Ipv4Addr::from(refid)), } } @@ -103,8 +117,8 @@ pub enum ClockSource { None, /// PTP Hardware Clock Phc, - /// Remote NTP server - Server(Ipv4Addr), + /// NTP server (stores reference ID: IPv4 address or first 4 octets of IPv6 MD5 hash) + Server(u32), /// Time and clock frequency from Linux hypervisor VMClock, } @@ -115,7 +129,7 @@ impl From for u32 { ClockSource::Init => u32::from_be_bytes(*b"INIT"), ClockSource::None => 0, ClockSource::Phc => u32::from_be_bytes(*b"XPHC"), - ClockSource::Server(addr) => u32::from(addr), + ClockSource::Server(refid) => refid, ClockSource::VMClock => u32::from_be_bytes(*b"XVMC"), } } @@ -127,7 +141,14 @@ impl Display for ClockSource { ClockSource::Init => write!(f, "INIT"), ClockSource::None => write!(f, "None"), ClockSource::Phc => write!(f, "PHC"), - ClockSource::Server(addr) => write!(f, "{addr}"), + ClockSource::Server(refid) => { + let bytes = refid.to_be_bytes(); + write!( + f, + "Server([{}, {}, {}, {}])", + bytes[0], bytes[1], bytes[2], bytes[3] + ) + } ClockSource::VMClock => write!(f, "VMClock"), } } @@ -152,7 +173,7 @@ mod tests { #[case(ClockSource::Phc, Stratum::Unspecified)] #[case(ClockSource::VMClock, Stratum::Unspecified)] #[case(ClockSource::None, Stratum::Unsynchronized)] - #[case(ClockSource::Server("192.168.1.1".parse().unwrap()), Stratum::TWO)] + #[case(ClockSource::Server(0xC0A80101), Stratum::TWO)] // 192.168.1.1 fn set_and_get_roundtrip(#[case] source: ClockSource, #[case] stratum: Stratum) { let clock = SelectedClockSource::default(); clock.set(source.clone(), stratum); @@ -167,26 +188,35 @@ mod tests { let clock = SelectedClockSource::default(); // Test PHC - clock.phc(); + clock.set_to_phc(); let (source, stratum) = clock.get(); assert_eq!(source, ClockSource::Phc); assert_eq!(stratum, Stratum::Unspecified); // Test VMClock - clock.vmclock(); + clock.set_to_vmclock(); let (source, stratum) = clock.get(); assert_eq!(source, ClockSource::VMClock); assert_eq!(stratum, Stratum::Unspecified); - // Test Server - let ip = "169.254.169.123".parse().unwrap(); - clock.server(ip, Stratum::ONE); + // Test Server IPv4 + let ip: IpAddr = "169.254.169.123".parse().unwrap(); + clock.set_to_server(ip, Stratum::ONE); let (source, stratum) = clock.get(); - assert_eq!(source, ClockSource::Server(ip)); + assert_eq!(source, ClockSource::Server(0xA9FEA97B)); // 169.254.169.123 as u32 assert_eq!(stratum, Stratum::ONE); + // Test Server IPv6 + let ipv6: IpAddr = "2001:db8::1".parse().unwrap(); + clock.set_to_server(ipv6, Stratum::TWO); + let (source, stratum) = clock.get(); + // MD5 hash of 2001:db8::1 is 39ab9b3749629b8f2c7ccf39226f680c + // First 4 octets: 39ab9b37 + assert_eq!(source, ClockSource::Server(0x39ab9b37)); + assert_eq!(stratum, Stratum::TWO); + // Test Unsynchronized - clock.none(); + clock.set_to_none(); let (source, stratum) = clock.get(); assert_eq!(source, ClockSource::None); assert_eq!(stratum, Stratum::Unsynchronized); @@ -197,7 +227,7 @@ mod tests { #[case(ClockSource::None, "None")] #[case(ClockSource::Phc, "PHC")] #[case(ClockSource::VMClock, "VMClock")] - #[case(ClockSource::Server("192.168.1.1".parse().unwrap()), "192.168.1.1")] + #[case(ClockSource::Server(0xC0A80101), "Server([192, 168, 1, 1])")] fn clock_source_display(#[case] source: ClockSource, #[case] expected: &str) { assert_eq!(source.to_string(), expected); } @@ -207,7 +237,7 @@ mod tests { #[case(ClockSource::None, 0)] #[case(ClockSource::Phc, 0x5850_4843)] // "XPHC" #[case(ClockSource::VMClock, 0x5856_4D43)] // "XVMC" - #[case(ClockSource::Server("192.168.1.1".parse().unwrap()), 0xC0A8_0101)] + #[case(ClockSource::Server(0xC0A8_0101), 0xC0A8_0101)] fn clock_source_to_u32(#[case] source: ClockSource, #[case] expected: u32) { assert_eq!(u32::from(source), expected); } @@ -217,13 +247,25 @@ mod tests { let clock = SelectedClockSource::default(); assert_eq!(clock.to_string(), "INIT (stratum 0)"); - clock.phc(); + clock.set_to_phc(); assert_eq!(clock.to_string(), "PHC (stratum 0)"); - clock.server("169.254.169.123".parse().unwrap(), Stratum::ONE); - assert_eq!(clock.to_string(), "169.254.169.123 (stratum 1)"); + clock.set_to_vmclock(); + assert_eq!(clock.to_string(), "VMClock (stratum 0)"); + + clock.set_to_server("169.254.169.123".parse().unwrap(), Stratum::ONE); + assert_eq!( + clock.to_string(), + "Server([169, 254, 169, 123]) (stratum 1)" + ); + + clock.set_to_server("169.254.169.123".parse().unwrap(), Stratum::TWO); + assert_eq!( + clock.to_string(), + "Server([169, 254, 169, 123]) (stratum 2)" + ); - clock.none(); + clock.set_to_none(); assert_eq!(clock.to_string(), "None (stratum 16)"); } } From 87ca3c4eb5371d122342bae3f0b6b5c33ef82e2d Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 6 Nov 2025 15:43:37 -0500 Subject: [PATCH 083/177] [io] Update link local polling rate to 2 seconds (#102) --- clock-bound/src/daemon/io/ntp.rs | 2 +- test/link-local/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index 6bb9146..320b3de 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -13,7 +13,7 @@ pub const LINK_LOCAL_BURST_DURATION: Duration = Duration::from_secs(1); pub const UNSPECIFIED_SOCKET_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); pub const LINK_LOCAL_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(169, 254, 169, 123), 123); -pub const LINK_LOCAL_INTERVAL_DURATION: Duration = Duration::from_secs(1); +pub const LINK_LOCAL_INTERVAL_DURATION: Duration = Duration::from_secs(2); pub const LINK_LOCAL_TIMEOUT: Duration = Duration::from_millis(100); pub const AWS_TEMP_PUBLIC_TIME_ADDRESSES: [SocketAddr; 2] = [ SocketAddr::new(IpAddr::V4(Ipv4Addr::new(166, 117, 111, 42)), 123), diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index 5c2e287..ac6913f 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -57,5 +57,5 @@ async fn main() { } polling_rate /= 10; println!("Polling rate avg: {polling_rate:?}"); - assert!(polling_rate.abs_diff(time::Duration::from_secs(1)) < time::Duration::from_millis(100)); + assert!(polling_rate.abs_diff(time::Duration::from_secs(2)) < time::Duration::from_millis(100)); } From c3275f5861b5bbcec27497abf442f0504b727e18 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 7 Nov 2025 10:05:40 -0500 Subject: [PATCH 084/177] Run clockbound at INFO by default (#98) --- clock-bound/src/daemon/subscriber.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/clock-bound/src/daemon/subscriber.rs b/clock-bound/src/daemon/subscriber.rs index 35c90ae..79ea2ab 100644 --- a/clock-bound/src/daemon/subscriber.rs +++ b/clock-bound/src/daemon/subscriber.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; +use tracing::Level; use tracing_subscriber::{ EnvFilter, Layer, filter::filter_fn, layer::SubscriberExt, util::SubscriberInitExt, }; @@ -27,7 +28,11 @@ pub fn init(log_directory: impl AsRef) { let log_layer = tracing_subscriber::fmt::layer() .with_writer(std::io::stdout) // this is the default, just making it explicit - .with_filter(EnvFilter::from_default_env()) + .with_filter( + EnvFilter::builder() + .with_default_directive(Level::INFO.into()) + .from_env_lossy(), + ) .with_filter(filter_fn(|md| !md.target().starts_with(PRIMER_TARGET))); tracing_subscriber::registry() From b58065176524888e3235bf9663941aed451ebddc Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 7 Nov 2025 12:33:00 -0500 Subject: [PATCH 085/177] [async ring buffer] clearing during disruption (#103) * [async ring buffer] clearing during disruption This solves a problem of different race conditions that can occur if the buffer was only cleared from a single side during a disruption event. For example, if handled only at the receiver side, there is a chance that the receive side clears good data if the receiver is running slow. And if only handled at the sender side, then the receiver side can potentially take stale data from the buffer if the sender is running slow. Instead both sides *races* to handle, and the first one to complete leaves a marker. * Revision: Error on send if recv disrupted --- clock-bound/src/daemon/async_ring_buffer.rs | 154 +++++++++++++++++++- 1 file changed, 151 insertions(+), 3 deletions(-) diff --git a/clock-bound/src/daemon/async_ring_buffer.rs b/clock-bound/src/daemon/async_ring_buffer.rs index 58cba54..a16e204 100644 --- a/clock-bound/src/daemon/async_ring_buffer.rs +++ b/clock-bound/src/daemon/async_ring_buffer.rs @@ -80,10 +80,13 @@ impl Sender { /// Returns [`BufferClosedError`] if the receiver dropped, and therefore nothing /// is available to receive messages. #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] - pub fn send(&self, value: T) -> Result<(), BufferClosedError> { + pub fn send(&self, value: T) -> Result<(), SendError> { let mut guard = self.inner.lock().unwrap(); if guard.receiver_dropped { - return Err(BufferClosedError); + return Err(BufferClosedError.into()); + } + if let Some(Side::Receiver) = guard.disruption_handled { + return Err(SendError::Disrupted(value)); } guard.push(value); drop(guard); @@ -91,6 +94,14 @@ impl Sender { Ok(()) } + /// Handle a clock disruption event + /// + /// This clears the internal buffer and leaves a marker that sender has handled it. + pub fn handle_disruption(&self) { + #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] + self.inner.lock().unwrap().handle_disruption_sender(); + } + /// Return true if the buffer is empty #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] pub fn is_empty(&self) -> bool { @@ -152,6 +163,10 @@ impl Receiver { if guard.sender_dropped { return Err(BufferClosedError); } + if let Some(Side::Sender) = guard.disruption_handled { + // it's a bug for this to repeatedly fire from the same channel + tracing::debug!("Receiving when sender handled disruption"); + } if let Some(value) = guard.pop() { return Ok(value); } @@ -160,6 +175,14 @@ impl Receiver { } } + /// Handle a clock disruption event + /// + /// This clears the internal buffer and leaves a marker that the receiver has handled it. + pub fn handle_disruption(&self) { + #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] + self.inner.lock().unwrap().handle_disruption_receiver(); + } + /// Returns `true` if the sender has dropped, and therefore the channel is closed #[expect(clippy::missing_panics_doc, reason = "not handling poisoned mutex")] pub fn is_closed(&self) -> bool { @@ -181,6 +204,7 @@ struct Buffer { capacity: usize, sender_dropped: bool, receiver_dropped: bool, + disruption_handled: Option, } impl Buffer { @@ -190,6 +214,7 @@ impl Buffer { capacity, sender_dropped: false, receiver_dropped: false, + disruption_handled: None, } } @@ -214,6 +239,52 @@ impl Buffer { pub fn is_empty(&self) -> bool { self.data.is_empty() } + + fn handle_disruption_sender(&mut self) { + match self.disruption_handled { + None => { + // not handled yet. Clear the buffer + self.data.clear(); + self.disruption_handled = Some(Side::Sender); + } + Some(Side::Sender) => tracing::warn!("handle disruption sender called multiple times"), + Some(Side::Receiver) => { + // already handled. Clear disruption_handled flag + self.disruption_handled = None; + } + } + } + + fn handle_disruption_receiver(&mut self) { + match self.disruption_handled { + None => { + // not handled yet. Clear the buffer + self.data.clear(); + self.disruption_handled = Some(Side::Receiver); + } + Some(Side::Sender) => { + // already handled. Clear disruption_handled flag + self.disruption_handled = None; + } + Some(Side::Receiver) => { + tracing::warn!("handle disruption receiver called multiple times"); + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Side { + Sender, + Receiver, +} + +#[derive(Debug, thiserror::Error)] +pub enum SendError { + #[error(transparent)] + BufferClosed(#[from] BufferClosedError), + #[error("Send when disrupted")] + Disrupted(T), } #[derive(Debug, thiserror::Error)] @@ -312,7 +383,7 @@ mod tests { } #[tokio::test] - async fn test_cancel_safety() { + async fn cancel_safety() { let (tx, rx) = create(2); tx.send(1).unwrap(); @@ -329,4 +400,81 @@ mod tests { // The value should still be available assert_eq!(rx.recv().await.unwrap(), 1); } + + // nothing async, but needs tokio runtime due to inner notify + #[tokio::test] + async fn handle_disruption_sender_first() { + let (tx, rx) = create(2); + tx.send(1).unwrap(); + + tx.handle_disruption(); + { + let inner = tx.inner.lock().unwrap(); + assert!(inner.data.is_empty()); + assert_eq!(inner.disruption_handled, Some(Side::Sender)); + } + rx.handle_disruption(); + let inner = rx.inner.lock().unwrap(); + assert!(inner.data.is_empty()); + assert_eq!(inner.disruption_handled, None); + } + + #[tokio::test] + async fn handle_disruption_receiver_first() { + let (tx, rx) = create(2); + tx.send(1).unwrap(); + + rx.handle_disruption(); + { + let inner = rx.inner.lock().unwrap(); + assert!(inner.data.is_empty()); + assert_eq!(inner.disruption_handled, Some(Side::Receiver)); + } + tx.handle_disruption(); + let inner = tx.inner.lock().unwrap(); + assert!(inner.data.is_empty()); + assert_eq!(inner.disruption_handled, None); + } + + #[tokio::test] + async fn handle_disruption_send_after_receive_handles() { + let (tx, rx) = create(2); + tx.send(1).unwrap(); + + // this should clear + rx.handle_disruption(); + + // this should still send + let res = tx.send(42); + let Err(SendError::Disrupted(val)) = &res else { + panic!("Expected send to be disrupted {res:?}"); + }; + + assert_eq!(*val, 42); + } + + #[tokio::test] + async fn sender_handles_disruption_while_recv() { + let (tx, rx) = create(2); + let recv_fut = rx.recv(); + + tokio::select! { + biased; + _ = recv_fut => { + panic!("This branch should not complete first"); + } + _ = async { + tx.handle_disruption(); + tx.send(5).unwrap(); + } => { + // this branch should complete first + } + } + + let received = rx.recv().await.unwrap(); + assert_eq!(received, 5); + + // this should clear + rx.handle_disruption(); + } } From 6915b1afbffe749c802c44c78607af5f3eb7a68b Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Fri, 7 Nov 2025 13:30:49 -0500 Subject: [PATCH 086/177] Link local set to start in burst mode. (#108) Updated link local object to start in burst mode and updated related integration tests. --- clock-bound/src/daemon/io/link_local.rs | 24 ++++++----- clock-bound/src/daemon/io/ntp.rs | 2 + test/link-local/src/main.rs | 53 ++++++++++++++++++++++--- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index f18ccb8..581ebbb 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -5,13 +5,13 @@ use tokio::{ io, net::UdpSocket, sync::{mpsc, watch}, - time::{self, Duration, Instant, Interval, MissedTickBehavior, interval, timeout}, + time::{self, Instant, Interval, MissedTickBehavior, interval, timeout}, }; use tracing::{debug, info}; use super::ntp::{ - LINK_LOCAL_ADDRESS, LINK_LOCAL_BURST_DURATION, LINK_LOCAL_INTERVAL_DURATION, - LINK_LOCAL_TIMEOUT, packet, + LINK_LOCAL_ADDRESS, LINK_LOCAL_BURST_DURATION, LINK_LOCAL_BURST_INTERVAL_DURATION, + LINK_LOCAL_INTERVAL_DURATION, LINK_LOCAL_TIMEOUT, packet, }; use super::tsc::read_timestamp_counter; use super::{ClockDisruptionEvent, ControlRequest}; @@ -23,9 +23,6 @@ use crate::daemon::{ use packet::Packet; -/// The amount of time between source polls when in burst mode. -const BURST_INTERVAL_DURATION: Duration = Duration::from_millis(50); - #[derive(Debug, Error)] pub enum LinkLocalError { #[error("IO failure.")] @@ -54,13 +51,17 @@ pub struct LinkLocal { impl LinkLocal { /// Constructs a new `LinkLocal` with using given parameters. + /// + /// NOTE: + /// The `LinkLocal` object will start in burst mode. The timer for burst mode begins when the object is constructed, + /// NOT when the run loop begins. pub fn construct( socket: UdpSocket, event_sender: async_ring_buffer::Sender, ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, ) -> Self { - let mut link_local_interval = interval(LINK_LOCAL_INTERVAL_DURATION); + let mut link_local_interval = interval(LINK_LOCAL_BURST_INTERVAL_DURATION); link_local_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); LinkLocal { socket, @@ -69,7 +70,7 @@ impl LinkLocal { clock_disruption_receiver, ntp_buffer: [0u8; Packet::SIZE], interval: link_local_interval, - mode: Mode::Normal, + mode: Mode::burst(), } } @@ -202,7 +203,7 @@ impl LinkLocal { } = self; *mode = Mode::burst(); - *ll_interval = interval(BURST_INTERVAL_DURATION); + *ll_interval = interval(LINK_LOCAL_BURST_INTERVAL_DURATION); ll_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); ll_interval.reset_immediately(); } @@ -281,7 +282,10 @@ mod tests { link_local.transition_to_burst_mode(); assert!(matches!(link_local.mode, Mode::Burst(_))); - assert_eq!(link_local.interval.period(), BURST_INTERVAL_DURATION); + assert_eq!( + link_local.interval.period(), + LINK_LOCAL_BURST_INTERVAL_DURATION + ); } #[tokio::test] diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index 320b3de..6711846 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -9,6 +9,8 @@ pub use packet::{Fec2V1Value as DaemonInfo, Packet}; use crate::daemon::{async_ring_buffer, event}; pub const LINK_LOCAL_BURST_DURATION: Duration = Duration::from_secs(1); +/// The amount of time between source polls when in burst mode. +pub const LINK_LOCAL_BURST_INTERVAL_DURATION: Duration = Duration::from_millis(50); pub const UNSPECIFIED_SOCKET_ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0); pub const LINK_LOCAL_ADDRESS: SocketAddrV4 = diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index ac6913f..c8f7b21 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -3,7 +3,8 @@ //! This executable tests that the link local runner is able to send and receive packets from the //! link local address and that the polling rate is roughly once a second. -use clock_bound::daemon::io::SourceIO; +use clock_bound::daemon::event::Ntp; +use clock_bound::daemon::io::{SourceIO, ntp::LINK_LOCAL_BURST_INTERVAL_DURATION}; use clock_bound::daemon::selected_clock::SelectedClockSource; use clock_bound::daemon::{async_ring_buffer, io::ntp::DaemonInfo}; use std::sync::Arc; @@ -20,9 +21,7 @@ async fn main() { .init(); println!("Lets get a NTP packet!"); - let (link_local_sender, link_local_receiver) = async_ring_buffer::create(1); - - let mut start = time::Instant::now(); + let (link_local_sender, mut link_local_receiver) = async_ring_buffer::create(1); let daemon_info = DaemonInfo { major_version: 2, @@ -34,11 +33,55 @@ async fn main() { sourceio.create_link_local(link_local_sender).await; sourceio.spawn_all(); + validate_burst_mode(&mut link_local_receiver).await; + validate_normal_mode(&mut link_local_receiver).await; +} + +async fn validate_burst_mode(receiver: &mut async_ring_buffer::Receiver) { + let start = time::Instant::now(); + let mut polling_rate = time::Duration::from_secs(0); + let mut count = 0; + loop { + // On instances that aren't able to connect to link local the runner will run infinitely. + // To address this we timeout if an NTP event has not been received. + let lap_start = time::Instant::now(); + let ntpevent = timeout(Duration::from_secs(5), receiver.recv()) + .await + .unwrap(); + let now = time::Instant::now(); + let d = now - lap_start; + println!( + "It looks like we got an ntp packet \n{ntpevent:#?}\n{:?} ms", + d.as_millis() + ); + + // Skip the first sample, the IO runner will poll immediately after it's created. + if count == 0 { + count += 1; + } else { + polling_rate += d; + count += 1; + } + + if start.elapsed() >= Duration::from_secs(1) { + break; + } + } + polling_rate /= count - 1; + println!("Burst Polling rate avg: {polling_rate:?}"); + assert!( + polling_rate.abs_diff(LINK_LOCAL_BURST_INTERVAL_DURATION) + < time::Duration::from_millis(100) + ); +} + +async fn validate_normal_mode(receiver: &mut async_ring_buffer::Receiver) { + let mut start = time::Instant::now(); let mut polling_rate = time::Duration::from_secs(0); for i in 0..11 { // On instances that aren't able to connect to link local the runner will run infinitely. // To address this we timeout if an NTP event has not been received. - let ntpevent = timeout(Duration::from_secs(5), link_local_receiver.recv()) + let ntpevent = timeout(Duration::from_secs(5), receiver.recv()) .await .unwrap(); let now = time::Instant::now(); From 8df520fe46dc8ed2c906e688544c0dbf507bf537 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 7 Nov 2025 13:52:14 -0500 Subject: [PATCH 087/177] [ClockSyncAlg] Add Clock Selector (#100) Adds the Selector to the ClockSyncAlgorithm struct. This aggregates clock parameters from all of the ff algs, and selects the best one. The current selection criteria is the lowest clock error bound while accounting for max dispersion growth. Additionally, adds support for updating the SelectedClock component based on the update --- clock-bound/src/daemon.rs | 3 +- .../src/daemon/clock_sync_algorithm.rs | 113 +++++-- .../daemon/clock_sync_algorithm/selector.rs | 293 ++++++++++++++++++ 3 files changed, 384 insertions(+), 25 deletions(-) create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/selector.rs diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 74c316b..b142edd 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -23,7 +23,7 @@ pub mod selected_clock; use std::sync::Arc; use crate::daemon::{ - clock_sync_algorithm::{ClockSyncAlgorithm, source::NTPSource}, + clock_sync_algorithm::{ClockSyncAlgorithm, Selector, source::NTPSource}, io::ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, receiver_stream::{ReceiverStream, RoutableEvent}, selected_clock::SelectedClockSource, @@ -82,6 +82,7 @@ impl Daemon { ), ) .selected_clock(selected_clock.clone()) + .selector(Selector::new(MAX_DISPERSION_GROWTH)) .build(); // Initializing async ring buffers for IO event delivery diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 5b8d665..9d543b3 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -4,17 +4,23 @@ expect(dead_code, reason = "remove when RoutableEvent is added") )] +mod selector; +pub use selector::{Selector, SourceInfo}; + pub mod ff; mod ring_buffer; -use std::{net::SocketAddr, sync::Arc}; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, +}; pub use ring_buffer::RingBuffer; use crate::daemon::{ - clock_parameters::ClockParameters, event, receiver_stream::RoutableEvent, - selected_clock::SelectedClockSource, subscriber::PRIMER_TARGET, + clock_parameters::ClockParameters, event, io::ntp::LINK_LOCAL_ADDRESS, + receiver_stream::RoutableEvent, selected_clock::SelectedClockSource, subscriber::PRIMER_TARGET, }; pub mod source; @@ -39,6 +45,8 @@ pub struct ClockSyncAlgorithm { pub ntp_sources: Vec, /// Shared reference to the current selected clock source selected_clock: Arc, + /// Selector. Chooses the best clock source + selector: Selector, } impl ClockSyncAlgorithm { @@ -77,26 +85,44 @@ impl ClockSyncAlgorithm { } } + /// Get the current best clock parameters + pub fn clock_parameters(&self) -> Option<&ClockParameters> { + self.selector.current().map(|o| &o.clock_parameters) + } + /// Convenience function to allow for easy instrumenting fn feed_inner(&mut self, routable_event: RoutableEvent) -> Option<&ClockParameters> { - match routable_event { - RoutableEvent::LinkLocal(event) => self.feed_link_local(event), + // First route the event to the correct inner source + let alg_output = match routable_event { + RoutableEvent::LinkLocal(event) => Self::feed_link_local(&mut self.link_local, event), RoutableEvent::NtpSource(sender_address, event) => { - self.feed_ntp_source(sender_address, &event) + Self::feed_ntp_source(&mut self.ntp_sources, sender_address, event) } RoutableEvent::Phc(_data) => { todo!("Implement PHC IO event delivery") } + }; + let (clock_parameters, source_info) = alg_output?; + + let output = self.selector.update(clock_parameters, source_info); + if output.is_some() { + Self::update_selected_clock(&self.selected_clock, source_info); } + + output } /// Feed event into the link local - /// TODO: make this function private and call into it from `fn feed` when we have a routable event - #[expect(clippy::missing_panics_doc, reason = "serialization will not fail")] - pub fn feed_link_local(&mut self, event: event::Ntp) -> Option<&ClockParameters> { + fn feed_link_local( + link_local: &mut source::LinkLocal, + event: event::Ntp, + ) -> Option<(&ClockParameters, SourceInfo)> { + // associated method to help borrow checker let serialized = serde_json::to_string(&event).unwrap(); - let output = self.link_local.feed(event); + let stratum = event.data().stratum; + + let output = link_local.feed(event); tracing::info!( target: PRIMER_TARGET, @@ -104,24 +130,36 @@ impl ClockSyncAlgorithm { output = serde_json::to_string(&output).unwrap(), "feed link local" ); - output + + output.map(|params| (params, SourceInfo::LinkLocal(stratum))) } /// Feed event into ntp source - /// TODO: make this function private and call into it from `fn feed` when we have a routable event - pub fn feed_ntp_source( - &mut self, + fn feed_ntp_source( + ntp_sources: &mut [source::NTPSource], sender_address: SocketAddr, - event: &event::Ntp, - ) -> Option<&ClockParameters> { - let mut clock_parameters: Option<&ClockParameters> = None; - for source in &mut self.ntp_sources { - if source.socket_address() == sender_address { - clock_parameters = source.feed(event.clone()); - break; + event: event::Ntp, + ) -> Option<(&ClockParameters, SourceInfo)> { + // associated method to help borrow checker + let stratum = event.data().stratum; + ntp_sources + .iter_mut() + .find(|source| source.socket_address() == sender_address) + .and_then(|source| source.feed(event)) + .map(|params| (params, SourceInfo::NtpSource(sender_address, stratum))) + } + + fn update_selected_clock(selected_clock: &Arc, source_info: SourceInfo) { + // associated method to help borrow checker + match source_info { + SourceInfo::LinkLocal(stratum) => { + selected_clock.set_to_server(IpAddr::V4(*LINK_LOCAL_ADDRESS.ip()), stratum); + } + SourceInfo::NtpSource(address, stratum) => { + selected_clock.set_to_server(address.ip(), stratum); } + SourceInfo::Phc => selected_clock.set_to_phc(), } - clock_parameters } /// Handle a clock disruption event @@ -137,6 +175,7 @@ impl ClockSyncAlgorithm { link_local, ntp_sources, selected_clock, + selector, } = self; selected_clock.set_to_none(); @@ -144,14 +183,21 @@ impl ClockSyncAlgorithm { for source in ntp_sources { source.handle_disruption(); } + selector.handle_disruption(); tracing::info!("Handled clock disruption event"); } } #[cfg(test)] mod tests { + use core::str; + use std::{net::Ipv4Addr, str::FromStr}; + + use rstest::rstest; + use crate::daemon::{ event::Stratum, + selected_clock::ClockSource, time::{Duration, Instant, TscCount, tsc::Skew}, }; @@ -179,13 +225,15 @@ mod tests { .link_local(source::LinkLocal::new(Skew::from_ppm(15.0))) .ntp_sources(vec![]) .selected_clock(Arc::new(SelectedClockSource::default())) + .selector(Selector::new(Skew::from_ppm(15.0))) .build(); - let clock_parameters = csa.feed_link_local(event.clone()); + let clock_parameters = + ClockSyncAlgorithm::feed_link_local(&mut csa.link_local, event.clone()); assert!(clock_parameters.is_none()); let serialized_event = serde_json::to_string(&event).unwrap(); - let serialized_output = serde_json::to_string(&clock_parameters).unwrap(); + let serialized_output = serde_json::to_string(&clock_parameters.map(|out| out.0)).unwrap(); // tracing escapes quotes let serialized_event = serialized_event.replace("\"", r#"\""#); @@ -194,4 +242,21 @@ mod tests { assert!(logs_contain(&serialized_event)); assert!(logs_contain(&serialized_output)); } + + #[rstest] + #[case(SourceInfo::LinkLocal(Stratum::TWO), ClockSource::Server(Ipv4Addr::from_str("169.254.169.123").unwrap().into()), Stratum::TWO)] + #[case(SourceInfo::NtpSource("169.254.169.101:123".parse().unwrap(), Stratum::ONE), ClockSource::Server(Ipv4Addr::from_str("169.254.169.101").unwrap().into()), Stratum::ONE)] + #[case(SourceInfo::NtpSource("[2001:db8::1:1234]:123".parse().unwrap(), Stratum::TWO), ClockSource::Server(Ipv4Addr::from_str("199.132.19.175").unwrap().into()), Stratum::TWO)] + #[case(SourceInfo::Phc, ClockSource::Phc, Stratum::Unspecified)] + fn update_selected_clock( + #[case] source_info: SourceInfo, + #[case] expected_clock_source: ClockSource, + #[case] expected_stratum: Stratum, + ) { + let selected_clock_source = Arc::new(SelectedClockSource::default()); + ClockSyncAlgorithm::update_selected_clock(&selected_clock_source, source_info); + let (clock_source, stratum) = selected_clock_source.get(); + assert_eq!(clock_source, expected_clock_source); + assert_eq!(stratum, expected_stratum); + } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/selector.rs b/clock-bound/src/daemon/clock_sync_algorithm/selector.rs new file mode 100644 index 0000000..4236e10 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/selector.rs @@ -0,0 +1,293 @@ +//! Select from multiple clock sources + +use std::net::SocketAddr; + +use crate::daemon::{clock_parameters::ClockParameters, event::Stratum, time::tsc::Skew}; + +/// Select from multiple clock sources +/// +/// Takes in [`ClockParameters`] values from multiple clock sources and +/// decides if it's a more accurate than the current best. +/// +/// The current methodology is to purely compare the `ClockErrorBound` of inputs against the current best and update +/// if the clock error bound is lower. This does take into account dispersion growth via the `max_dispersion_growth` +/// parameter. +/// +/// When picking the TSC Period to use, the current `ClockParameters` value is used +#[derive(Debug, Clone)] +pub struct Selector { + current: Option, + max_dispersion_growth: Skew, +} + +impl Selector { + /// Constructor + pub fn new(max_dispersion_growth: Skew) -> Self { + Self { + current: None, + max_dispersion_growth, + } + } + + /// Compare an input `ClockParameters` against the current best + /// + /// Returns `Some` if the new value is more accurate than the current best. None otherwise. + pub fn update( + &mut self, + clock_parameters: &ClockParameters, + source_info: SourceInfo, + ) -> Option<&ClockParameters> { + let Some(current) = &self.current else { + self.current = Some(SourceParams { + clock_parameters: clock_parameters.clone(), + source_info, + }); + return self.current.as_ref().map(|sp| &sp.clock_parameters); + }; + + if current + .clock_parameters + .more_accurate_than(clock_parameters, self.max_dispersion_growth) + { + None + } else { + self.current = Some(SourceParams { + clock_parameters: clock_parameters.clone(), + source_info, + }); + self.current.as_ref().map(|sp| &sp.clock_parameters) + } + } + + /// Clear inner state during a disruption event + pub fn handle_disruption(&mut self) { + let Self { + current, + max_dispersion_growth: _, + } = self; + *current = None; + } + + /// Get the current best clock parameters + pub fn current(&self) -> Option<&SourceParams> { + self.current.as_ref() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SourceParams { + pub clock_parameters: ClockParameters, + pub source_info: SourceInfo, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceInfo { + /// Link Local + LinkLocal(Stratum), + /// NTP Source, + NtpSource(SocketAddr, Stratum), + /// PHC + Phc, +} + +#[cfg(test)] +mod tests { + use crate::daemon::{ + event::{self, TscRtt}, + time::{Duration, Instant, TscCount, tsc::Period}, + }; + + use super::*; + use rstest::rstest; + + #[rstest] + #[case::same_events_zero_skew( + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event (identical) + event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(10), + root_dispersion: Duration::from_micros(5), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Skew::from_ppm(0.0), + true, + )] + #[case::different_rtt_zero_skew( + // First event with better RTT + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event with worse RTT + event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_002_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(15), + root_dispersion: Duration::from_micros(5), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Skew::from_ppm(15.0), + false, + )] + #[case::time_difference_with_skew( + // First event (older) + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event (newer, 1 second later) + event::Ntp::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1) + Duration::from_secs(1), + server_send_time: Instant::from_days(1) + Duration::from_secs(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(10), + root_dispersion: Duration::from_micros(5), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Skew::from_ppm(25.0), + true + )] + #[case::different_period( + // First event + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event + event::Ntp::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_003_300)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(10), + root_dispersion: Duration::from_micros(5), + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(3.3e-9), + Skew::from_ppm(10.0), + false, + )] + #[case::first_better_despite_age( + // First event + ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }, + // Second event + event::Ntp::builder() + .tsc_pre(TscCount::new(5_000_000_000)) + .tsc_post(TscCount::new(5_000_003_300)) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_days(1), + server_send_time: Instant::from_days(1) + Duration::from_micros(1), + root_delay: Duration::from_micros(10), + root_dispersion: Duration::from_micros(50), // CEB of second degraded + stratum: Stratum::TWO, + }) + .build() + .unwrap(), + Period::from_seconds(0.303e-9), + Skew::from_ppm(10.0), + false + )] + fn update( + #[case] first: ClockParameters, + #[case] second: event::Ntp, + #[case] period: Period, + #[case] max_dispersion: Skew, + #[case] expected: bool, + ) { + let val = ClockParameters { + tsc_count: second.tsc_midpoint(), + time: second + .data() + .server_recv_time + .midpoint(second.data().server_send_time), + clock_error_bound: second.calculate_clock_error_bound(period), + period, + period_max_error: Period::from_seconds(1e-11), // unused + }; + let mut selector = Selector { + current: Some(SourceParams { + clock_parameters: first, + source_info: SourceInfo::Phc, + }), + max_dispersion_growth: max_dispersion, + }; + let result = selector.update(&val, SourceInfo::Phc).is_some(); + assert_eq!(result, expected); + } + + #[test] + fn first_update_sets_current() { + let clock_parameters = ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }; + let mut selector = Selector::new(Skew::from_ppm(0.0)); + assert!(selector.current().is_none()); + let result = selector.update(&clock_parameters, SourceInfo::Phc).unwrap(); + assert_eq!(result, &clock_parameters); + assert_eq!(selector.current().unwrap().source_info, SourceInfo::Phc); + } + + #[test] + fn handle_disruption() { + let clock_parameters = ClockParameters { + tsc_count: TscCount::new(1_000_000_500), + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(10_500), + period: Period::from_seconds(1e-9), // unused + period_max_error: Period::from_seconds(1e-11), // unused + }; + let skew = Skew::from_ppm(1.0); + let mut selector = Selector::new(skew); + selector.update(&clock_parameters, SourceInfo::Phc).unwrap(); + selector.handle_disruption(); + assert!(selector.current().is_none()); + assert_eq!(selector.max_dispersion_growth, skew) + } +} From dbbdd4dad7271c0e9c6ef4d8d811b59bee8a5ba8 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Fri, 7 Nov 2025 15:22:57 -0500 Subject: [PATCH 088/177] Implements ordered time stamp counter reads and adds fences to ntp (#107) * Implements ordered time stamp counter reads and adds fences to ntp This commit implements ordered time stamp counter reads which guarantee instruction ordering when reading the time stamp counter. Additionally, this commit adds memory fences before and after ntp polls. * removed atomic fences and replaced with lfence and arch64 equivalent * begin wraps in lfence, end uses rdtscp --------- Co-authored-by: Shamik Chakraborty --- clock-bound/src/daemon/event.rs | 6 +-- clock-bound/src/daemon/io/link_local.rs | 8 ++-- clock-bound/src/daemon/io/ntp_source.rs | 8 ++-- clock-bound/src/daemon/io/tsc.rs | 56 +++++++++++++++++++++---- 4 files changed, 58 insertions(+), 20 deletions(-) diff --git a/clock-bound/src/daemon/event.rs b/clock-bound/src/daemon/event.rs index f0d6f7a..b907e7e 100644 --- a/clock-bound/src/daemon/event.rs +++ b/clock-bound/src/daemon/event.rs @@ -52,11 +52,11 @@ impl SystemClockMeasurement { #[expect(clippy::missing_panics_doc, reason = "unwrap won't panic")] #[expect(clippy::cast_possible_wrap)] pub fn now() -> Self { - use crate::daemon::io::tsc::read_timestamp_counter; + use crate::daemon::io::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; use crate::daemon::time::Instant; - let pre = read_timestamp_counter(); + let pre = read_timestamp_counter_begin(); let now = std::time::SystemTime::now(); - let post = read_timestamp_counter(); + let post = read_timestamp_counter_end(); let now = now.duration_since(std::time::UNIX_EPOCH).unwrap(); let tsc = pre.midpoint(post); diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index 581ebbb..3033b2f 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -13,7 +13,7 @@ use super::ntp::{ LINK_LOCAL_ADDRESS, LINK_LOCAL_BURST_DURATION, LINK_LOCAL_BURST_INTERVAL_DURATION, LINK_LOCAL_INTERVAL_DURATION, LINK_LOCAL_TIMEOUT, packet, }; -use super::tsc::read_timestamp_counter; +use super::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; use super::{ClockDisruptionEvent, ControlRequest}; use crate::daemon::{ async_ring_buffer, @@ -85,9 +85,7 @@ impl LinkLocal { let packet = Packet::new_request(0); packet.emit_bytes(&mut self.ntp_buffer); - // TODO: tsc reads and ntp samples need to be fenced. - // We are currently investigating how to implement this appropriately. - let sent_timestamp = read_timestamp_counter(); + let sent_timestamp = read_timestamp_counter_begin(); // Request and Receive NTP sample. let recv_packet_result = timeout(LINK_LOCAL_TIMEOUT, { @@ -98,7 +96,7 @@ impl LinkLocal { }) .await?; - let received_timestamp = read_timestamp_counter(); + let received_timestamp = read_timestamp_counter_end(); #[cfg(all(not(test), feature = "test-side-by-side"))] let system_clock_reading = crate::daemon::event::SystemClockMeasurement::now(); diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 8ae0a98..6e88bdb 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -11,7 +11,7 @@ use tokio::{ }; use tracing::{debug, info}; -use super::tsc::read_timestamp_counter; +use super::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; use crate::daemon::{ async_ring_buffer, event::{self, NtpData}, @@ -86,9 +86,7 @@ impl NTPSource { let packet = Packet::new_request(0); packet.emit_bytes(&mut self.ntp_buffer); - // TODO: tsc reads and ntp samples need to be fenced. - // We are currently investigating how to implement this appropriately. - let sent_timestamp = read_timestamp_counter(); + let sent_timestamp = read_timestamp_counter_begin(); // Request and Receive NTP sample. let recv_packet_result = timeout(NTP_SOURCE_TIMEOUT, { @@ -97,7 +95,7 @@ impl NTPSource { }) .await?; - let received_timestamp = read_timestamp_counter(); + let received_timestamp = read_timestamp_counter_end(); let (_, ntp_packet) = Packet::parse_from_bytes(&self.ntp_buffer) .map_err(|e| NTPSourceError::PacketParsing(e.to_string()))?; diff --git a/clock-bound/src/daemon/io/tsc.rs b/clock-bound/src/daemon/io/tsc.rs index 5cd9926..a1d4b95 100644 --- a/clock-bound/src/daemon/io/tsc.rs +++ b/clock-bound/src/daemon/io/tsc.rs @@ -6,25 +6,43 @@ pub trait ReadTsc { pub struct ReadTscImpl; impl ReadTsc for ReadTscImpl { fn read_tsc(&self) -> u64 { - read_timestamp_counter() + read_timestamp_counter_begin() } } -/// Reads the current value of the processor's time-stamp counter. +/// Brackets time-stamp counter read with synchronization barrier instructions. #[cfg(target_arch = "aarch64")] -pub fn read_timestamp_counter() -> u64 { +#[inline] +pub fn read_timestamp_counter_end() -> u64 { // aarch64 documentation: https://developer.arm.com/documentation/ddi0601/2021-12/AArch64-Registers/CNTVCT-EL0--Counter-timer-Virtual-Count-register use std::arch::asm; let rv: u64; unsafe { - asm!("mrs {}, cntvct_el0", out(reg) rv); + asm!("isb; mrs {}, cntvct_el0; isb;", out(reg) rv); } rv } +/// Brackets time-stamp counter read with synchronization barrier instructions. +#[cfg(target_arch = "aarch64")] +#[inline] +pub fn read_timestamp_counter_begin() -> u64 { + // aarch64 documentation: https://developer.arm.com/documentation/ddi0601/2021-12/AArch64-Registers/CNTVCT-EL0--Counter-timer-Virtual-Count-register + // instruction barrier documentation: https://developer.arm.com/documentation/100941/0101/Barriers + use std::arch::asm; + + let rv: u64; + unsafe { + asm!("isb; mrs {}, cntvct_el0; isb;", out(reg) rv); + } + rv +} + +/// Reads the current value of the processor's time-stamp counter. #[cfg(target_arch = "x86_64")] -pub fn read_timestamp_counter() -> u64 { +#[inline] +pub fn read_timestamp_counter_begin() -> u64 { /* There are a number of options for getting tsc values on x86_64 cpus. We could get them from the registers ourselves leveraging assembly @@ -52,6 +70,30 @@ pub fn read_timestamp_counter() -> u64 { I've chosen to get the values from llvm because as I'm confident they are implemented correctly. */ - use core::arch::x86_64::_rdtsc; - unsafe { _rdtsc() } + // Fencing is discussed in Vol 2B 4-550 of Intel architecture software development manual + use core::arch::x86_64::{_mm_lfence, _rdtsc}; + let tsc; + unsafe { + _mm_lfence(); + tsc = _rdtsc(); + _mm_lfence(); + } + tsc +} + +/// Applies a synchronization barrier then reads the current value of the processor's time-stamp counter. +#[cfg(target_arch = "x86_64")] +#[inline] +pub fn read_timestamp_counter_end() -> u64 { + use core::arch::x86_64::{__rdtscp, _mm_lfence}; + // Fencing is discussed in Vol 2B 4-552 of Intel architecture software development manual + // `__rdtscp` writes the IA32_TSC_AUX value to `aux`. IA32_TSC_AUX is usually the cpu id, but + // the meaning depends on the operating system. Currently, we do not use this value. + let mut aux = 0u32; + let tsc; + unsafe { + tsc = __rdtscp(&raw mut aux); + _mm_lfence(); + } + tsc } From f9a9796bffe6ff15249c16768be699751da77443 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 7 Nov 2025 15:27:39 -0500 Subject: [PATCH 089/177] [repro] Enable reproducible testing for all event types (#101) --- clock-bound-ff-tester/src/repro.rs | 81 ++++++++++++++++--- .../src/repro/test_10_29_2025.log | 5 -- .../src/repro/test_11_06_2025.log | 13 +++ .../src/daemon/clock_sync_algorithm.rs | 44 +++++----- clock-bound/src/daemon/receiver_stream.rs | 2 +- 5 files changed, 108 insertions(+), 37 deletions(-) delete mode 100644 clock-bound-ff-tester/src/repro/test_10_29_2025.log create mode 100644 clock-bound-ff-tester/src/repro/test_11_06_2025.log diff --git a/clock-bound-ff-tester/src/repro.rs b/clock-bound-ff-tester/src/repro.rs index 4da5db2..0147618 100644 --- a/clock-bound-ff-tester/src/repro.rs +++ b/clock-bound-ff-tester/src/repro.rs @@ -7,9 +7,11 @@ use std::{ path::Path, }; -use clock_bound::daemon::{clock_parameters::ClockParameters, event}; - use crate::events::{Scenario, v1}; +use crate::time::CbBridge; +use clock_bound::daemon::{ + clock_parameters::ClockParameters, event, receiver_stream::RoutableEvent, +}; /// Read a logfile and return all inputs and outputs from the `ClockSyncAlgorithm` /// @@ -86,7 +88,7 @@ pub fn scenario_from_reader( let events: Vec<_> = events .into_iter() - .map(|event| tester_event_from_clock_bound(&event, "link_local".to_string())) + .map(|event| tester_event_from_routable(&event)) .collect(); let scenario = Scenario::V1(v1::Scenario { @@ -98,8 +100,7 @@ pub fn scenario_from_reader( Ok((scenario, clock_parameters)) } -pub fn tester_event_from_clock_bound(event: &event::Ntp, source_id: String) -> v1::Event { - use crate::time::CbBridge; +fn tester_event_from_cb_ntp(event: &event::Ntp, source_id: String) -> v1::Event { v1::Event { variants: v1::EventKind::Ntp(v1::Ntp { server_system_recv_time: event.data().server_recv_time.into_estimate(), @@ -114,6 +115,29 @@ pub fn tester_event_from_clock_bound(event: &event::Ntp, source_id: String) -> v } } +fn tester_event_from_cb_phc(event: &event::Phc, source_id: String) -> v1::Event { + v1::Event { + variants: v1::EventKind::Phc(v1::Phc { + phc_time: event.data().time.into_estimate(), + clock_error_bound: Some(event.data().clock_error_bound.into_estimate()), + source_id, + client_system_times: None, + }), + client_tsc_pre_time: event.tsc_pre(), + client_tsc_post_time: event.tsc_post(), + } +} + +fn tester_event_from_routable(event: &RoutableEvent) -> v1::Event { + match event { + RoutableEvent::LinkLocal(event) => { + tester_event_from_cb_ntp(event, String::from("link_local")) + } + RoutableEvent::NtpSource(addr, event) => tester_event_from_cb_ntp(event, addr.to_string()), + RoutableEvent::Phc(event) => tester_event_from_cb_phc(event, String::from("ptp_ena")), + } +} + /// Convenience struct to parse each line of the log /// /// It's not exhaustive, but has the minimum number of fields @@ -142,9 +166,9 @@ impl Fields { Ok(retval) } - fn parse_event(&self) -> anyhow::Result { + fn parse_event(&self) -> anyhow::Result { let unescaped = self.event.replace("\\\"", "\""); - let retval: event::Ntp = serde_json::from_str(&unescaped)?; + let retval: RoutableEvent = serde_json::from_str(&unescaped)?; Ok(retval) } } @@ -158,7 +182,7 @@ mod tests { use super::*; #[test] - fn convert_clock_bound_event_to_tester() { + fn convert_ntp_clock_bound_event_to_tester() { let event = event::Ntp::builder() .ntp_data(clock_bound::daemon::event::NtpData { server_recv_time: 1.into(), @@ -173,7 +197,7 @@ mod tests { .unwrap(); let source_id = "source_id".to_string(); - let tester_event = tester_event_from_clock_bound(&event, source_id.clone()); + let tester_event = tester_event_from_cb_ntp(&event, source_id.clone()); assert_eq!(tester_event.client_tsc_pre_time, TscCount::new(500)); assert_eq!(tester_event.client_tsc_post_time, TscCount::new(600)); assert_eq!( @@ -189,15 +213,48 @@ mod tests { ); } + #[test] + fn phc_conversion() { + let event = event::Phc::builder() + .data(event::PhcData { + time: 1.into(), + clock_error_bound: 2.into(), + }) + .tsc_pre(TscCount::new(500)) + .tsc_post(TscCount::new(600)) + .build() + .unwrap(); + + let source_id = "source_id".to_string(); + let tester_event = tester_event_from_cb_phc(&event, source_id.clone()); + assert_eq!(tester_event.client_tsc_pre_time, TscCount::new(500)); + assert_eq!(tester_event.client_tsc_post_time, TscCount::new(600)); + assert_eq!( + tester_event.variants, + v1::EventKind::Phc(v1::Phc { + phc_time: EstimateInstant::new(1), + clock_error_bound: Some(EstimateDuration::new(2)), + source_id, + client_system_times: None, + }) + ); + } + #[test] fn scenario_from_logs() { - let example_log = include_str!("repro/test_10_29_2025.log"); + let example_log = include_str!("repro/test_11_06_2025.log"); let (scenario, clock_parameters) = scenario_from_reader(example_log.as_bytes()).unwrap(); - assert_eq!(clock_parameters.len(), 5); + assert_eq!(clock_parameters.len(), 13); let Scenario::V1(scenario) = scenario; - assert_eq!(scenario.events.len(), 5); + assert_eq!(scenario.events.len(), 13); assert!(scenario.oscillator.is_none()); + + let param_count = clock_parameters + .iter() + .filter_map(|params| params.as_ref()) + .count(); + assert_eq!(param_count, 10); // 3 of the values are the first inputs for sources. So 10 calculate clock parameters } } diff --git a/clock-bound-ff-tester/src/repro/test_10_29_2025.log b/clock-bound-ff-tester/src/repro/test_10_29_2025.log deleted file mode 100644 index 04aed65..0000000 --- a/clock-bound-ff-tester/src/repro/test_10_29_2025.log +++ /dev/null @@ -1,5 +0,0 @@ -{"timestamp":"2025-10-28T20:32:45.436522Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684683194245128,\"tsc_post\":684683194794794,\"data\":{\"server_recv_time\":1761683565436333156000000,\"server_send_time\":1761683565436348058000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} -{"timestamp":"2025-10-28T20:32:46.436878Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684685795242462,\"tsc_post\":684685795712854,\"data\":{\"server_recv_time\":1761683566436724697000000,\"server_send_time\":1761683566436738169000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} -{"timestamp":"2025-10-28T20:32:47.436275Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684688393604176,\"tsc_post\":684688394117962,\"data\":{\"server_recv_time\":1761683567436112791000000,\"server_send_time\":1761683567436127810000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} -{"timestamp":"2025-10-28T20:32:48.436644Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684690994547794,\"tsc_post\":684690995078662,\"data\":{\"server_recv_time\":1761683568436488072000000,\"server_send_time\":1761683568436503955000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} -{"timestamp":"2025-10-28T20:32:49.437019Z","level":"INFO","fields":{"message":"feed link local","event":"{\"tsc_pre\":684693595469390,\"tsc_post\":684693596006290,\"data\":{\"server_recv_time\":1761683569436853672000000,\"server_send_time\":1761683569436868285000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}","output":"null"},"target":"clock_bound::primer"} \ No newline at end of file diff --git a/clock-bound-ff-tester/src/repro/test_11_06_2025.log b/clock-bound-ff-tester/src/repro/test_11_06_2025.log new file mode 100644 index 0000000..a3666cd --- /dev/null +++ b/clock-bound-ff-tester/src/repro/test_11_06_2025.log @@ -0,0 +1,13 @@ +{"timestamp":"2025-11-06T17:23:20.677297Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635623413319024,\"tsc_post\":635623413864816,\"data\":{\"server_recv_time\":1762449800677131039000000,\"server_send_time\":1762449800677146186000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"null"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:20.679810Z","level":"INFO","fields":{"message":"feed","event":"{\"NtpSource\":[\"3.33.186.244:123\",{\"tsc_pre\":635623413320948,\"tsc_post\":635623420481660,\"data\":{\"server_recv_time\":1762449800678976843000000,\"server_send_time\":1762449800678979550000000,\"root_delay\":396728515625,\"root_dispersion\":213623046875,\"stratum\":4}}]}","output":"null"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:20.680790Z","level":"INFO","fields":{"message":"feed","event":"{\"NtpSource\":[\"166.117.111.42:123\",{\"tsc_pre\":635623413338108,\"tsc_post\":635623423053060,\"data\":{\"server_recv_time\":1762449800680026184000000,\"server_send_time\":1762449800680028784000000,\"root_delay\":320434570313,\"root_dispersion\":106811523438,\"stratum\":4}}]}","output":"null"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:21.677180Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635626012858850,\"tsc_post\":635626013503676,\"data\":{\"server_recv_time\":1762449801676980568000000,\"server_send_time\":1762449801676996985000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635626013181263,\"time\":1762449801676975335410671,\"clock_error_bound\":154521300572,\"period\":3.846114221419201e-10,\"period_max_error\":0.00009904890398763192}"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:22.677596Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635628614054460,\"tsc_post\":635628614614812,\"data\":{\"server_recv_time\":1762449802677452417000000,\"server_send_time\":1762449802677467030000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635628614334636,\"time\":1762449802677456221841042,\"clock_error_bound\":154524585725,\"period\":3.846216114096018e-10,\"period_max_error\":7.27883037202635e-6}"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:23.676962Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635631212425820,\"tsc_post\":635631212940984,\"data\":{\"server_recv_time\":1762449803676818048000000,\"server_send_time\":1762449803676830966000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635631212683402,\"time\":1762449803676828446395546,\"clock_error_bound\":154524235855,\"period\":3.8462052625000083e-10,\"period_max_error\":0.000010210398587621446}"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:24.677332Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635633813347468,\"tsc_post\":635633813877296,\"data\":{\"server_recv_time\":1762449804677183402000000,\"server_send_time\":1762449804677200320000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635633813612382,\"time\":1762449804677195746363843,\"clock_error_bound\":154524107539,\"period\":3.846201282635384e-10,\"period_max_error\":0.000011322851775378121}"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:25.677750Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635636414411388,\"tsc_post\":635636414933806,\"data\":{\"server_recv_time\":1762449805677601518000000,\"server_send_time\":1762449805677616430000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635636414672597,\"time\":1762449805677613373412736,\"clock_error_bound\":154524043595,\"period\":3.8461992993393077e-10,\"period_max_error\":7.575687159656162e-6}"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:26.677146Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635639012763924,\"tsc_post\":635639013340708,\"data\":{\"server_recv_time\":1762449806676989219000000,\"server_send_time\":1762449806677004526000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635639013052316,\"time\":1762449806677001456062930,\"clock_error_bound\":154524043595,\"period\":3.8461992993393077e-10,\"period_max_error\":7.575687159656162e-6}"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:27.677500Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635641613777950,\"tsc_post\":635641614230662,\"data\":{\"server_recv_time\":1762449807677365666000000,\"server_send_time\":1762449807677380695000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635641614004306,\"time\":1762449807677382216698850,\"clock_error_bound\":154524239741,\"period\":3.8462053830299693e-10,\"period_max_error\":0.000013296697176547366}"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:28.676850Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635644211952256,\"tsc_post\":635644212513180,\"data\":{\"server_recv_time\":1762449808676698680000000,\"server_send_time\":1762449808676713987000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635644212232718,\"time\":1762449808676713524606154,\"clock_error_bound\":154524239741,\"period\":3.8462053830299693e-10,\"period_max_error\":0.000013296697176547366}"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:29.677278Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635646812999900,\"tsc_post\":635646813596236,\"data\":{\"server_recv_time\":1762449809677119595000000,\"server_send_time\":1762449809677137569000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635646813298068,\"time\":1762449809677136107860340,\"clock_error_bound\":154524239741,\"period\":3.8462053830299693e-10,\"period_max_error\":0.000013296697176547366}"},"target":"clock_bound::primer"} +{"timestamp":"2025-11-06T17:23:30.677777Z","level":"INFO","fields":{"message":"feed","event":"{\"LinkLocal\":{\"tsc_pre\":635649414121046,\"tsc_post\":635649414868104,\"data\":{\"server_recv_time\":1762449810677631185000000,\"server_send_time\":1762449810677646076000000,\"root_delay\":30517578125,\"root_dispersion\":15258789063,\"stratum\":1}}}","output":"{\"tsc_count\":635649414494575,\"time\":1762449810677598781058191,\"clock_error_bound\":174184152455,\"period\":3.8461959936042316e-10,\"period_max_error\":0.000025162859923619214}"},"target":"clock_bound::primer"} diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 9d543b3..a2d38ce 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -56,12 +56,12 @@ impl ClockSyncAlgorithm { { use crate::daemon::event::TscRtt; let Some(system_clock) = routable_event.system_clock() else { - return self.feed_inner(routable_event); + return self.feed_repro(routable_event); }; let system = system_clock.system_time; let system_tsc = system_clock.tsc; let tsc_rtt = routable_event.rtt(); - let retval = self.feed_inner(routable_event); + let retval = self.feed_repro(routable_event); if let Some(new_params) = &retval { let system_clock_tsc_age = system_tsc - new_params.tsc_count; let system_clock_age = system_clock_tsc_age * new_params.period; @@ -81,7 +81,7 @@ impl ClockSyncAlgorithm { } #[cfg(any(not(feature = "test-side-by-side"), test))] { - self.feed_inner(routable_event) + self.feed_repro(routable_event) } } @@ -112,26 +112,31 @@ impl ClockSyncAlgorithm { output } + // wrapper around feed_inner that emits reproducibility + fn feed_repro(&mut self, routable_event: RoutableEvent) -> Option<&ClockParameters> { + let serialized = serde_json::to_string(&routable_event).unwrap(); + let output = self.feed_inner(routable_event); + tracing::info!( + target: PRIMER_TARGET, + event = serialized, + output = serde_json::to_string(&output).unwrap(), + "feed" + ); + + output + } + /// Feed event into the link local fn feed_link_local( link_local: &mut source::LinkLocal, event: event::Ntp, ) -> Option<(&ClockParameters, SourceInfo)> { // associated method to help borrow checker - let serialized = serde_json::to_string(&event).unwrap(); - let stratum = event.data().stratum; - let output = link_local.feed(event); - - tracing::info!( - target: PRIMER_TARGET, - event = serialized, - output = serde_json::to_string(&output).unwrap(), - "feed link local" - ); - - output.map(|params| (params, SourceInfo::LinkLocal(stratum))) + link_local + .feed(event) + .map(|params| (params, SourceInfo::LinkLocal(stratum))) } /// Feed event into ntp source @@ -205,7 +210,7 @@ mod tests { #[test] #[tracing_test::traced_test] - fn feed_link_local_event() { + fn feed_serializes_events() { // Most logs are permeable to change. Make sure that we log a json event. let event = event::Ntp::builder() @@ -221,6 +226,8 @@ mod tests { .build() .unwrap(); + let event = RoutableEvent::LinkLocal(event); + let mut csa = ClockSyncAlgorithm::builder() .link_local(source::LinkLocal::new(Skew::from_ppm(15.0))) .ntp_sources(vec![]) @@ -228,12 +235,11 @@ mod tests { .selector(Selector::new(Skew::from_ppm(15.0))) .build(); - let clock_parameters = - ClockSyncAlgorithm::feed_link_local(&mut csa.link_local, event.clone()); + let clock_parameters = csa.feed(event.clone()); assert!(clock_parameters.is_none()); let serialized_event = serde_json::to_string(&event).unwrap(); - let serialized_output = serde_json::to_string(&clock_parameters.map(|out| out.0)).unwrap(); + let serialized_output = serde_json::to_string(&clock_parameters).unwrap(); // tracing escapes quotes let serialized_event = serialized_event.replace("\"", r#"\""#); diff --git a/clock-bound/src/daemon/receiver_stream.rs b/clock-bound/src/daemon/receiver_stream.rs index 11e6a2e..10c6c8d 100644 --- a/clock-bound/src/daemon/receiver_stream.rs +++ b/clock-bound/src/daemon/receiver_stream.rs @@ -112,7 +112,7 @@ impl ReceiverStream { } } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)] pub enum RoutableEvent { LinkLocal(event::Ntp), NtpSource(SocketAddr, event::Ntp), From 90a79209628740fe86335510662de22335165f3f Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 7 Nov 2025 16:07:10 -0500 Subject: [PATCH 090/177] remove tracing features from vmclock test crate (#109) This can mess with calling cargo commands at the workspace level due to feature unification --- clock-bound/src/client.rs | 1 + test/vmclock-updater/Cargo.toml | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index 5ffe02c..5e24e73 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -645,6 +645,7 @@ mod lib_tests { /// We avoid writing to the shared memory for the default shared memory segment path /// because it is possible actual clients are relying on the ClockBound data at this location. #[test] + #[ignore = "can fail if daemon has run previously with root privs"] fn test_new_sanity_check() { let result = ClockBoundClient::new(); if Path::new(CLOCKBOUND_SHM_DEFAULT_PATH).exists() { diff --git a/test/vmclock-updater/Cargo.toml b/test/vmclock-updater/Cargo.toml index 6966bee..740e3f4 100644 --- a/test/vmclock-updater/Cargo.toml +++ b/test/vmclock-updater/Cargo.toml @@ -25,10 +25,7 @@ libc = { version = "0.2", default-features = false, features = [ "extra_traits", ] } nix = { version = "0.26", features = ["feature", "time"] } -tracing = { version = "0.1", features = [ - "max_level_debug", - "release_max_level_info", -] } +tracing = { version = "0.1" } tracing-subscriber = { version = "0.3", features = ["std", "fmt", "json"] } [dev-dependencies] From 550d75da48cedb20392106a600660e3bbf38e869 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Fri, 7 Nov 2025 17:11:42 -0500 Subject: [PATCH 091/177] Move `ntp_adjtime` into submodule of `clock_adjust` (#104) The NtpAdjTime trait was taking up a lot of space, when it is mostly a wrapper around ways that we call into the `ntp_adjtime` interface, and less of the actual business logic in the `ClockAdjuster`. We move that trait into a separate module, plus mimic the existing `ClockAdjuster` methods for adjust_clock and step_clock into an `NtpAdjTimeExt` trait instead. These are basically just our own abstractions around the underlying `ntp_adjtime` calls, so implementing them with the underlying `NtpAdjTime` call parameters makes things a bit more ergonomic. Also, introducing some separate methods for phase correction and frequency corrections to be made separately, rather than both in the same call like `adjust_clock` does - this is going to be useful for modifications to `ClockAdjuster` in upcoming commits. --- .../src/daemon/clock_state/clock_adjust.rs | 168 +--------- .../clock_state/clock_adjust/ntp_adjtime.rs | 287 ++++++++++++++++++ clock-bound/src/daemon/time/timex.rs | 215 ++++++++++++- clock-bound/src/daemon/time/tsc.rs | 42 +++ 4 files changed, 546 insertions(+), 166 deletions(-) create mode 100644 clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index 6548519..b36a92e 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -1,7 +1,5 @@ //! Adjust system clock -use errno::Errno; -use libc::{TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT, ntp_adjtime}; -use thiserror::Error; +use libc::{TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT}; use tracing::{debug, error, info}; use crate::daemon::{ @@ -9,45 +7,10 @@ use crate::daemon::{ time::{Duration, inner::ClockOffsetAndRtt, instant::Utc, timex::Timex, tsc::Skew}, }; -/// Error type returned when dealing with underlying `adjtimex` or `ntp_adjtime` -/// results. -#[derive(Debug, Error)] -pub enum NtpAdjTimeError { - #[error("Failed to adjust the clock: {0}")] - Failure(Errno), - #[error("Unexpected bad state return value from ntp_adjtime: {0}")] - BadState(i32), - #[error("Invalid return value from ntp_adjtime: {0}")] - InvalidState(i32), -} - -/// Concrete struct implementing `ntp_adjtime` by delegating to the `libc` -/// implementation. Should be the only actual concrete implementation. -pub struct KAPIClockAdjuster; -impl NtpAdjTime for KAPIClockAdjuster { - fn ntp_adjtime(&self, tx: &mut Timex) -> i32 { - // # Safety - // `tx` should point to a valid struct because of validation guarantees of `Timex` - unsafe { ntp_adjtime(tx.expose()) } - } -} - -/// Noop Clock Adjuster, which doesn't actually adjust the clock parameters but just -/// returns `TIME_OK`. -pub struct NoopClockAdjuster; -impl NtpAdjTime for NoopClockAdjuster { - fn ntp_adjtime(&self, _tx: &mut Timex) -> i32 { - TIME_OK - } -} - -/// Lightweight trait around `ntp_adjtime` function (formerly `adjtimex`). -/// Useful for mocking, or potentially as an abstraction around modifying -/// other clocks' parameters in the future. -#[cfg_attr(test, mockall::automock)] -pub trait NtpAdjTime { - fn ntp_adjtime(&self, tx: &mut Timex) -> i32; -} +mod ntp_adjtime; +pub use ntp_adjtime::{ + KAPIClockAdjuster, NoopClockAdjuster, NtpAdjTime, NtpAdjTimeError, NtpAdjTimeExt, +}; pub struct ClockAdjuster { ntp_adjtime: T, @@ -196,127 +159,10 @@ impl ClockAdjuster { #[cfg(test)] mod test { use mockall::predicate::eq; - use rstest::rstest; - - use super::*; - - #[rstest] - #[case::positives(Duration::from_nanos(500), Skew::from_ppm(1.0))] - #[case::negatives(Duration::from_nanos(-500), Skew::from_ppm(-1.0))] - #[case::zeroes(Duration::from_nanos(0), Skew::from_ppm(0.0))] - #[case::positive_offset_negative_skew(Duration::from_nanos(500), Skew::from_ppm(-1.0))] - #[case::negative_offset_positive_skew(Duration::from_nanos(-500), Skew::from_ppm(1.0))] - fn adjust_clock_happy_paths( - #[case] input_phase_correction: Duration, - #[case] input_skew: Skew, - ) { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - assert!(clock_adjuster.should_step); - - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(TIME_OK); - - // Call adjust_clock with test values - let result = clock_adjuster.adjust_clock(input_phase_correction, input_skew); - - assert!(result.is_ok()); - assert!(clock_adjuster.should_step); - } - - #[test] - fn adjust_clock_failure() { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - assert!(clock_adjuster.should_step); - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(-1); - - // Call adjust_clock with test values - assert!(matches!( - clock_adjuster - .adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)) - .unwrap_err(), - NtpAdjTimeError::Failure(_) - )); - assert!(clock_adjuster.should_step); - } - - #[test] - fn adjust_clock_bad_state() { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - assert!(clock_adjuster.should_step); + use crate::daemon::clock_state::clock_adjust::ntp_adjtime::MockNtpAdjTime; - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(TIME_ERROR); - - // Call adjust_clock with test values - assert!(matches!( - clock_adjuster - .adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)) - .unwrap_err(), - NtpAdjTimeError::BadState(_) - )); - assert!(clock_adjuster.should_step); - } - - #[test] - fn adjust_clock_unexpected_value() { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - assert!(clock_adjuster.should_step); - - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(12345); - - // Call adjust_clock with test values - assert!(matches!( - clock_adjuster - .adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)) - .unwrap_err(), - NtpAdjTimeError::InvalidState(_) - )); - assert!(clock_adjuster.should_step); - } - - #[rstest] - #[case::positive(Duration::from_millis(100))] - #[case::negative(-Duration::from_millis(100))] - #[case::zero(Duration::from_millis(0))] - fn step_clock_happy_paths(#[case] input_phase_correction: Duration) { - let mock_ntp_adj_time = MockNtpAdjTime::new(); - let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); - assert!(clock_adjuster.should_step); - - // Set up mock expectations - clock_adjuster - .ntp_adjtime - .expect_ntp_adjtime() - .times(1) - .return_const(TIME_ERROR); - - // Call step_clock with test values - clock_adjuster.step_clock(input_phase_correction).unwrap(); - assert!(!clock_adjuster.should_step); - } + use super::*; #[test] fn handle_disruption() { diff --git a/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs b/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs new file mode 100644 index 0000000..24759ad --- /dev/null +++ b/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs @@ -0,0 +1,287 @@ +//! Adjust system clock +use errno::Errno; +use libc::{TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT, ntp_adjtime}; +use thiserror::Error; +use tracing::{debug, info}; + +use crate::daemon::time::{Duration, timex::Timex, tsc::Skew}; + +/// Error type returned when dealing with underlying `adjtimex` or `ntp_adjtime` +/// results. +#[derive(Debug, Error)] +pub enum NtpAdjTimeError { + #[error("Failed to adjust the clock: {0}")] + Failure(Errno), + #[error("Unexpected bad state return value from ntp_adjtime: {0}")] + BadState(i32), + #[error("Invalid return value from ntp_adjtime: {0}")] + InvalidState(i32), +} + +/// Concrete struct implementing `ntp_adjtime` by delegating to the `libc` +/// implementation. Should be the only actual concrete implementation. +pub struct KAPIClockAdjuster; +impl NtpAdjTime for KAPIClockAdjuster { + fn ntp_adjtime(&self, tx: &mut Timex) -> i32 { + // # Safety + // `tx` should point to a valid struct because of validation guarantees of `Timex` + unsafe { ntp_adjtime(tx.expose()) } + } +} +impl NtpAdjTimeExt for KAPIClockAdjuster {} + +/// Noop Clock Adjuster, which doesn't actually adjust the clock parameters but just +/// returns `TIME_OK`. +pub struct NoopClockAdjuster; +impl NtpAdjTime for NoopClockAdjuster { + fn ntp_adjtime(&self, _tx: &mut Timex) -> i32 { + TIME_OK + } +} +impl NtpAdjTimeExt for NoopClockAdjuster {} + +/// Lightweight trait around `ntp_adjtime` function (formerly `adjtimex`). +/// Useful for mocking, or potentially as an abstraction around modifying +/// other clocks' parameters in the future. +#[cfg_attr(test, mockall::automock)] +pub trait NtpAdjTime { + fn ntp_adjtime(&self, tx: &mut Timex) -> i32; +} + +#[cfg(test)] +impl NtpAdjTimeExt for MockNtpAdjTime {} + +pub trait NtpAdjTimeExt: NtpAdjTime { + /// Performs an adjustment of the clock, to apply the given phase correction + /// and skew values, in a single system call. + /// + /// # Errors + /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno + /// `NtpAdjTimeError::BadState` if some state other than `TIME_OK` is returned from `ntp_adjtime` + /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` + fn adjust_clock( + &self, + phase_correction: Duration, + skew: Skew, + ) -> Result { + let mut tx = Timex::clock_adjustment() + .phase_correction(phase_correction) + .skew(skew) + .call(); + + info!( + "calling ntp_adjtime to adjust clock with phase_correction {phase_correction:?} and skew {skew:?}" + ); + match self.ntp_adjtime(&mut tx) { + TIME_OK => Ok(tx), + cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { + Err(NtpAdjTimeError::BadState(cs)) + } + -1 => Err(NtpAdjTimeError::Failure(errno::errno())), + unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), + } + } + + /// Applies a phase correction to `CLOCK_REALTIME`. + /// + /// # Errors + /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno + /// `NtpAdjTimeError::BadState` if some state other than `TIME_ERROR` is returned from `ntp_adjtime` + /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` + fn apply_phase_correction(&self, phase_correction: Duration) -> Result { + let mut tx = Timex::phase_correction(phase_correction); + + debug!( + "calling ntp_adjtime to apply phase_correction {phase_correction:?} {:?}", + tx + ); + match self.ntp_adjtime(&mut tx) { + TIME_OK => Ok(tx), + cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { + Err(NtpAdjTimeError::BadState(cs)) + } + -1 => Err(NtpAdjTimeError::Failure(errno::errno())), + unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), + } + } + + /// Applies a frequency correction to `CLOCK_REALTIME`. + /// + /// # Errors + /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno + /// `NtpAdjTimeError::BadState` if some state other than `TIME_ERROR` is returned from `ntp_adjtime` + /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` + fn apply_frequency_correction( + &self, + frequency_correction: Skew, + ) -> Result { + let mut tx = Timex::frequency_correction(frequency_correction); + + debug!( + "calling ntp_adjtime to apply frequency_correction {frequency_correction:?} {:?}", + tx + ); + match self.ntp_adjtime(&mut tx) { + TIME_OK => Ok(tx), + cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { + Err(NtpAdjTimeError::BadState(cs)) + } + -1 => Err(NtpAdjTimeError::Failure(errno::errno())), + unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), + } + } + + /// Applies an instantaneous step of `CLOCK_REALTIME` based on the passed `phase_correction` value. + /// + /// # Errors + /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno + /// `NtpAdjTimeError::BadState` if some state other than `TIME_ERROR` is returned from `ntp_adjtime` + /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` + fn step_clock(&self, phase_correction: Duration) -> Result { + let mut tx = Timex::clock_step() + .phase_correction(phase_correction) + .call(); + + debug!( + "calling ntp_adjtime to step clock with phase_correction {phase_correction:?} {:?}", + tx + ); + // NOTE: we actually expect TIME_ERROR if the clock adjustment succeeds, since + // that indicates the clock is now "unsynchronized" (expected after we step the clock + // discontinuously) + match self.ntp_adjtime(&mut tx) { + TIME_ERROR => Ok(tx), + cs @ (TIME_OK | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { + Err(NtpAdjTimeError::BadState(cs)) + } + -1 => Err(NtpAdjTimeError::Failure(errno::errno())), + unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), + } + } + + /// Reads the current set of kernel clock adjustment variables. + /// + /// # Errors + /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno + /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` + fn read_adjtime(&self) -> Result { + let mut tx = Timex::retrieve(); + + debug!( + "calling ntp_adjtime to retrieve kernel params with {:?}", + tx + ); + match self.ntp_adjtime(&mut tx) { + TIME_OK | TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT => Ok(tx), + -1 => Err(NtpAdjTimeError::Failure(errno::errno())), + unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), + } + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::positives(Duration::from_nanos(500), Skew::from_ppm(1.0))] + #[case::negatives(Duration::from_nanos(-500), Skew::from_ppm(-1.0))] + #[case::zeroes(Duration::from_nanos(0), Skew::from_ppm(0.0))] + #[case::positive_offset_negative_skew(Duration::from_nanos(500), Skew::from_ppm(-1.0))] + #[case::negative_offset_positive_skew(Duration::from_nanos(-500), Skew::from_ppm(1.0))] + fn adjust_clock_happy_paths( + #[case] input_phase_correction: Duration, + #[case] input_skew: Skew, + ) { + let mut mock_ntp_adj_time = MockNtpAdjTime::new(); + + // Set up mock expectations + mock_ntp_adj_time + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_OK); + + // Call adjust_clock with test values + let result = mock_ntp_adj_time.adjust_clock(input_phase_correction, input_skew); + + assert!(result.is_ok()); + } + + #[test] + fn adjust_clock_failure() { + let mut mock_ntp_adj_time = MockNtpAdjTime::new(); + + // Set up mock expectations + mock_ntp_adj_time + .expect_ntp_adjtime() + .times(1) + .return_const(-1); + + // Call adjust_clock with test values + assert!(matches!( + mock_ntp_adj_time + .adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)) + .unwrap_err(), + NtpAdjTimeError::Failure(_) + )); + } + + #[test] + fn adjust_clock_bad_state() { + let mut mock_ntp_adj_time = MockNtpAdjTime::new(); + + // Set up mock expectations + mock_ntp_adj_time + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_ERROR); + + // Call adjust_clock with test values + assert!(matches!( + mock_ntp_adj_time + .adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)) + .unwrap_err(), + NtpAdjTimeError::BadState(_) + )); + } + + #[test] + fn adjust_clock_unexpected_value() { + let mut mock_ntp_adj_time = MockNtpAdjTime::new(); + + // Set up mock expectations + mock_ntp_adj_time + .expect_ntp_adjtime() + .times(1) + .return_const(12345); + + // Call adjust_clock with test values + assert!(matches!( + mock_ntp_adj_time + .adjust_clock(Duration::from_nanos(500), Skew::from_ppm(1.0)) + .unwrap_err(), + NtpAdjTimeError::InvalidState(_) + )); + } + + #[rstest] + #[case::positive(Duration::from_millis(100))] + #[case::negative(-Duration::from_millis(100))] + #[case::zero(Duration::from_millis(0))] + fn step_clock_happy_paths(#[case] input_phase_correction: Duration) { + let mut mock_ntp_adj_time = MockNtpAdjTime::new(); + + // Set up mock expectations + mock_ntp_adj_time + .expect_ntp_adjtime() + .times(1) + .return_const(TIME_ERROR); + + // Call step_clock with test values + mock_ntp_adj_time + .step_clock(input_phase_correction) + .unwrap(); + } +} diff --git a/clock-bound/src/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs index fd51aa4..0a4effa 100644 --- a/clock-bound/src/daemon/time/timex.rs +++ b/clock-bound/src/daemon/time/timex.rs @@ -16,7 +16,7 @@ const MAX_SKEW: Skew = Skew::from_ppm(512.0); /// Newtype wrapping `libc::timex` to provide valid /// constructors for each type of `adjtimex`/`ntp_adjtime` operation. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub struct Timex(timex); #[bon] @@ -35,10 +35,6 @@ impl Timex { /// /// The value may be expressed in microseconds by default, but if the call is made /// with `status` bit `STA_NANO` set, `tv_usec` represents a nanosecond value. - /// - /// Arguments - /// * `is_sta_nano` - whether the `tv_usec` should be interpreted as a microsecond or nanosecond - /// value, based on the `status` bit `STA_NANO` in the `timex` struct. pub fn time(&self) -> Instant { let tv = self.0.time; let fractional_part = if self.0.status & STA_NANO > 0 { @@ -49,6 +45,20 @@ impl Timex { Instant::from_secs(tv.tv_sec.into()) + fractional_part } + /// Reads the given `freq` from the underlying `timex`. + /// + /// In struct timex, freq, ppsfreq, and stabil are ppm (parts per + /// million) with a 16-bit fractional part, which means that a value + /// of 1 in one of those fields actually means 2^-16 ppm, and + /// 2^16=65536 is 1 ppm. This is the case for both input values (in + /// the case of freq) and output values. + /// ref: See NOTES in + /// + /// This function constructs a `Skew` value from the given `freq` value set on the `timex`. + pub fn freq(&self) -> Skew { + Skew::from_timex_freq(self.0.freq) + } + /// Builds a `libc::timex` used for adjustment of the system clock, to apply the given phase correction /// and skew values, in a single system call. /// @@ -150,6 +160,147 @@ impl Timex { }) } + #[allow( + clippy::cast_possible_truncation, + reason = "phase correction is clamped then converted so no truncation" + )] + pub fn phase_correction(mut phase_correction: Duration) -> Self { + if phase_correction > MAX_PHASE_OFFSET || phase_correction < -MAX_PHASE_OFFSET { + warn!( + "Phase correction of {}ns is outside of bounds +/-{}ns, clamping the value", + phase_correction.as_nanos(), + MAX_PHASE_OFFSET.as_nanos() + ); + phase_correction = phase_correction.clamp(-MAX_PHASE_OFFSET, MAX_PHASE_OFFSET); + } + Self(timex { + // Set `modes` bits for all fields we modify, plus ADJ_NANO to use nanosecond units + // and ADJ_STATUS to set status bits below. + modes: MOD_OFFSET | MOD_TIMECONST | MOD_NANO | MOD_STATUS, + offset: phase_correction.as_nanos_trunc() as i64, + freq: 0, + maxerror: 0, + esterror: 0, + // STA_FREQHOLD: Hold the frequency that we prescribe, if this is omitted the PLL would modify `freq` + // which we do not want since ClockBound's clock sync algorithm should determine the proper + // frequency setting. + // STA_PLL: Additionally, only rely on PLL to perform phase adjustments + status: STA_FREQHOLD | STA_PLL, + // PLL clock adjustment proportion is dependent on this time constant. + // The clock adjustment factor over the length of a second + // is calculated as `shift_right(offset, SHIFT_PLL + ntpdata->time_constant)`, where const `SHIFT_PLL` = 2 + // So, if we want to correct the clock quickly, we use a lower time constant. + // The value is clamped between 0 and 10. + // For now, we use 0, to aggressively correct the clock, which means we'd expect for + // offset to be corrected by `offset >> 2` every second (exponentially decaying) + constant: 0, + precision: 0, + tolerance: 0, + #[cfg(not(target_os = "macos"))] + time: timeval { + tv_sec: 0, + tv_usec: 0, + }, + #[cfg(not(target_os = "macos"))] + tick: 0, + ppsfreq: 0, + jitter: 0, + shift: 0, + stabil: 0, + jitcnt: 0, + calcnt: 0, + errcnt: 0, + stbcnt: 0, + #[cfg(not(target_os = "macos"))] + tai: 0, + #[cfg(not(target_os = "macos"))] + __unused1: 0, + #[cfg(not(target_os = "macos"))] + __unused2: 0, + #[cfg(not(target_os = "macos"))] + __unused3: 0, + #[cfg(not(target_os = "macos"))] + __unused4: 0, + #[cfg(not(target_os = "macos"))] + __unused5: 0, + #[cfg(not(target_os = "macos"))] + __unused6: 0, + #[cfg(not(target_os = "macos"))] + __unused7: 0, + #[cfg(not(target_os = "macos"))] + __unused8: 0, + #[cfg(not(target_os = "macos"))] + __unused9: 0, + #[cfg(not(target_os = "macos"))] + __unused10: 0, + #[cfg(not(target_os = "macos"))] + __unused11: 0, + }) + } + + pub fn frequency_correction(mut skew: Skew) -> Self { + if skew > MAX_SKEW || skew < -MAX_SKEW { + warn!("Skew of {skew} is outside of bounds +/-{MAX_SKEW}, clamping the value",); + skew = skew.clamp(-MAX_SKEW, MAX_SKEW); + } + Self(timex { + // Set `modes` bits for all fields we modify, plus ADJ_NANO to use nanosecond units + // and ADJ_STATUS to set status bits below. + modes: MOD_FREQUENCY | MOD_NANO | MOD_STATUS, + offset: 0, + freq: skew.to_timex_freq(), + maxerror: 0, + esterror: 0, + // STA_FREQHOLD: Hold the frequency that we prescribe, if this is omitted the PLL would modify `freq` + // which we do not want since ClockBound's clock sync algorithm should determine the proper + // frequency setting. + // STA_PLL: Additionally, only rely on PLL to perform phase adjustments + status: STA_FREQHOLD | STA_PLL, + constant: 0, + precision: 0, + tolerance: 0, + #[cfg(not(target_os = "macos"))] + time: timeval { + tv_sec: 0, + tv_usec: 0, + }, + #[cfg(not(target_os = "macos"))] + tick: 0, + ppsfreq: 0, + jitter: 0, + shift: 0, + stabil: 0, + jitcnt: 0, + calcnt: 0, + errcnt: 0, + stbcnt: 0, + #[cfg(not(target_os = "macos"))] + tai: 0, + #[cfg(not(target_os = "macos"))] + __unused1: 0, + #[cfg(not(target_os = "macos"))] + __unused2: 0, + #[cfg(not(target_os = "macos"))] + __unused3: 0, + #[cfg(not(target_os = "macos"))] + __unused4: 0, + #[cfg(not(target_os = "macos"))] + __unused5: 0, + #[cfg(not(target_os = "macos"))] + __unused6: 0, + #[cfg(not(target_os = "macos"))] + __unused7: 0, + #[cfg(not(target_os = "macos"))] + __unused8: 0, + #[cfg(not(target_os = "macos"))] + __unused9: 0, + #[cfg(not(target_os = "macos"))] + __unused10: 0, + #[cfg(not(target_os = "macos"))] + __unused11: 0, + }) + } + /// Construct a `libc::timex` used for stepping the clock by some phase correction, /// with a full step (can go forwards or backwards). /// This is used to set the system clock to the current time, which is useful for @@ -214,6 +365,60 @@ impl Timex { __unused11: 0, }) } + + /// Completely zeroed, allows for retrieving the current kernel values + pub fn retrieve() -> Self { + Self(timex { + modes: 0, + offset: 0, + freq: 0, + maxerror: 0, + esterror: 0, + status: 0, + constant: 0, + precision: 0, + tolerance: 0, + #[cfg(not(target_os = "macos"))] + time: timeval { + tv_sec: 0, + tv_usec: 0, + }, + #[cfg(not(target_os = "macos"))] + tick: 0, + ppsfreq: 0, + jitter: 0, + shift: 0, + stabil: 0, + jitcnt: 0, + calcnt: 0, + errcnt: 0, + stbcnt: 0, + #[cfg(not(target_os = "macos"))] + tai: 0, + #[cfg(not(target_os = "macos"))] + __unused1: 0, + #[cfg(not(target_os = "macos"))] + __unused2: 0, + #[cfg(not(target_os = "macos"))] + __unused3: 0, + #[cfg(not(target_os = "macos"))] + __unused4: 0, + #[cfg(not(target_os = "macos"))] + __unused5: 0, + #[cfg(not(target_os = "macos"))] + __unused6: 0, + #[cfg(not(target_os = "macos"))] + __unused7: 0, + #[cfg(not(target_os = "macos"))] + __unused8: 0, + #[cfg(not(target_os = "macos"))] + __unused9: 0, + #[cfg(not(target_os = "macos"))] + __unused10: 0, + #[cfg(not(target_os = "macos"))] + __unused11: 0, + }) + } } impl AsRef for Timex { diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index e2e9ab3..6c71a85 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -298,6 +298,26 @@ impl Skew { /// 2^16=65536 is 1 ppm. This is the case for both input values (in /// the case of freq) and output values. /// ref: See NOTES in + /// + /// This function constructs a `Skew` value from a given kernel value. + pub fn from_timex_freq(timex_freq: i64) -> Self { + if timex_freq >= 0 { + Self::from_ppm((timex_freq >> 16) as f64) + } else { + // i64::MAX = -i64::MIN - 1, prefer to `saturating_neg` rather + // than overflow and wrap + -Self::from_ppm((timex_freq.saturating_neg() >> 16) as f64) + } + } + + /// In struct timex, freq, ppsfreq, and stabil are ppm (parts per + /// million) with a 16-bit fractional part, which means that a value + /// of 1 in one of those fields actually means 2^-16 ppm, and + /// 2^16=65536 is 1 ppm. This is the case for both input values (in + /// the case of freq) and output values. + /// ref: See NOTES in + /// + /// This function constructs a given kernel value from a `Skew` value. pub fn to_timex_freq(self) -> i64 { (FREQUENCY_TO_TIMEX_SCALE * self.0 / Self::PPM) as i64 } @@ -555,6 +575,28 @@ mod tests { assert_eq!(skew.to_timex_freq(), i64::MIN); } + #[test] + fn skew_from_timex_freq() { + let skew = Skew::from_timex_freq(65536); + assert_abs_diff_eq!(skew.get(), 1.0 * 1.0e-6); + let skew = Skew::from_timex_freq(-65536); + assert_abs_diff_eq!(skew.get(), -1.0 * 1.0e-6); + let skew = Skew::from_timex_freq(0); + assert_abs_diff_eq!(skew.get(), 0.0); + let skew = Skew::from_timex_freq(i64::MAX); + assert_abs_diff_eq!( + skew.get(), + i64::MAX as f64 / (65536.0 * 1e6), + epsilon = 0.11 + ); + let skew = Skew::from_timex_freq(i64::MIN); + assert_abs_diff_eq!( + skew.get(), + -i64::MAX as f64 / (65536.0 * 1e6), + epsilon = 0.11 + ); + } + #[test] fn skew_display() { let skew = Skew::from_ppm(100.0); From ca759e0c1e13e1884f2fa3b2cdf114686d893651 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 10 Nov 2025 11:23:45 -0500 Subject: [PATCH 092/177] Add ff-tester simulation: a perfect scenario (#95) * Add ff-tester simulation: a perfect scenario This is a baseline test where the algorithm should be absolutely perfect. Currently this is accurate within 1 nanosecond on a 2 week test, with imperfections coming from rounding error. TODO: ff-tester interpolation should be more resistant to large durations * workaround fact that coverage cannot run with trace logs --- Cargo.lock | 23 +++ clock-bound-ff-tester/Cargo.toml | 9 +- clock-bound-ff-tester/src/bin/sim_ll.rs | 71 +++++++ clock-bound-ff-tester/src/lib.rs | 6 +- clock-bound-ff-tester/src/sim_ll.rs | 178 ++++++++++++++++++ .../ff/event_buffer/estimate.rs | 2 +- .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 7 +- 7 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 clock-bound-ff-tester/src/bin/sim_ll.rs create mode 100644 clock-bound-ff-tester/src/sim_ll.rs diff --git a/Cargo.lock b/Cargo.lock index 0acba2d..7c2a0ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -323,8 +323,10 @@ dependencies = [ "serde_json", "statrs", "tempfile", + "test-log", "thiserror 2.0.17", "tracing", + "tracing-subscriber", "varpro", ] @@ -1467,6 +1469,27 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "test-log" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" +dependencies = [ + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/clock-bound-ff-tester/Cargo.toml b/clock-bound-ff-tester/Cargo.toml index 422ae83..e9c8c26 100644 --- a/clock-bound-ff-tester/Cargo.toml +++ b/clock-bound-ff-tester/Cargo.toml @@ -14,6 +14,7 @@ version.workspace = true [dependencies] anyhow = "1.0.100" +approx = "0.5" bon = "3.8.1" clap = { version = "4.5", features = ["derive"] } clock-bound = { path = "../clock-bound", features = [ @@ -28,12 +29,18 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.145" statrs = "0.18.0" thiserror = { version = "2.0" } +tracing-subscriber = { version = "0.3", features = [ + "std", + "fmt", + "json", + "env-filter", +] } tracing = "0.1.41" [dev-dependencies] -approx = "0.5" mockall = "0.13.1" nalgebra = "0.33" rstest = "0.25" varpro = "0.11.0" tempfile = "3.20" +test-log = { version = "0.2", default-features = false, features = ["trace"] } diff --git a/clock-bound-ff-tester/src/bin/sim_ll.rs b/clock-bound-ff-tester/src/bin/sim_ll.rs new file mode 100644 index 0000000..e5c8d35 --- /dev/null +++ b/clock-bound-ff-tester/src/bin/sim_ll.rs @@ -0,0 +1,71 @@ +//! Simulation tests against ClockBound's Link Local +#![expect(clippy::cast_possible_truncation)] + +use std::sync::Arc; + +use clock_bound::daemon::{ + clock_sync_algorithm::{ClockSyncAlgorithm, source}, + selected_clock::SelectedClockSource, + subscriber::PRIMER_TARGET, +}; +use clock_bound_ff_tester::{ + events::{Scenario, v1::EventKind}, + sim_ll::{self, TestLinkLocal}, + time::{CbBridge, Skew, TrueDuration}, +}; +use tracing::Level; +use tracing_subscriber::EnvFilter; + +fn main() { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::builder() + .with_default_directive(Level::INFO.into()) + .from_env_lossy() + .add_directive(format!("{PRIMER_TARGET}=error").parse().unwrap()), // ignore primer + ) + .init(); + + let mut tester = TestLinkLocal::new( + ClockSyncAlgorithm::builder() + .link_local(source::LinkLocal::new(Skew::from_ppm(15.0))) + .ntp_sources(vec![]) + .selected_clock(Arc::new(SelectedClockSource::default())) + .build(), + ); + + let scenario = sim_ll::perfect_symmetric(TrueDuration::from_days(14)); + let Scenario::V1(scenario) = scenario; + let mut events = scenario.events.iter().enumerate(); + let (_, first_event) = events.next().unwrap(); + + let first_output = tester.feed_ntp(first_event); + assert!(first_output.is_none()); + + for (idx, event) in events { + let output = tester.feed_ntp(event); + let param = output.unwrap(); + + let tsc_midpoint = event + .client_tsc_pre_time + .midpoint(event.client_tsc_post_time); + let EventKind::Ntp(ntp) = &event.variants else { + panic!("Expected NTP event, found {event:?}"); + }; + let ref_clock_midpoint = ntp + .server_system_recv_time + .midpoint(ntp.server_system_send_time); + + assert_eq!(param.tsc_count, tsc_midpoint, "Failure at idx {idx}"); + assert!( + approx::abs_diff_eq!( + param.time.into_estimate().as_picos() as i64, + ref_clock_midpoint.as_picos() as i64, + epsilon = 1000 + ), + "Failure at idx {idx}. parameters not expected: {param:#?}\nexpected\t{}\ngot\t\t{}", + ref_clock_midpoint.get(), + param.time.get() + ); + } +} diff --git a/clock-bound-ff-tester/src/lib.rs b/clock-bound-ff-tester/src/lib.rs index a02813c..1e9fd0c 100644 --- a/clock-bound-ff-tester/src/lib.rs +++ b/clock-bound-ff-tester/src/lib.rs @@ -1,9 +1,9 @@ //! Feed Forward Time sync algorithm tester +pub mod events; pub mod repro; +pub mod simulation; pub mod time; -pub mod events; - -pub mod simulation; +pub mod sim_ll; diff --git a/clock-bound-ff-tester/src/sim_ll.rs b/clock-bound-ff-tester/src/sim_ll.rs new file mode 100644 index 0000000..2793880 --- /dev/null +++ b/clock-bound-ff-tester/src/sim_ll.rs @@ -0,0 +1,178 @@ +//! Code dedicated to generating Link Local simulations and testing them against ClockBound +//! +//! This is a constrained environment where there is only Link Local data. + +use clock_bound::daemon::{ + clock_parameters::ClockParameters, + clock_sync_algorithm::ClockSyncAlgorithm, + event, + time::{Duration, Instant, tsc::Frequency}, +}; + +use crate::{ + events::{ + Scenario, + v1::{Event, EventKind}, + }, + simulation::{ + self, + generator::GeneratorExt, + ntp::RoundTripDelays, + oscillator::{FullModel, Oscillator}, + }, + time::{EstimateDuration, TrueDuration, TrueInstant}, +}; + +pub struct TestLinkLocal { + pub algorithm: ClockSyncAlgorithm, +} + +impl TestLinkLocal { + pub fn new(algorithm: ClockSyncAlgorithm) -> Self { + Self { algorithm } + } + + /// Run a whole scenario against a clock sync algorithm. + /// + /// Returns a list of outputs from the inner `feed_link_local` function call + pub fn run(&mut self, scenario: &Scenario) -> Vec> { + let Scenario::V1(scenario) = scenario; + scenario + .events + .iter() + .map(tester_event_to_ntp) + .map(|e| self.algorithm.feed_link_local(e.clone()).cloned()) + .collect() + } + + pub fn feed_ntp(&mut self, event: &Event) -> Option<&ClockParameters> { + let ntp = tester_event_to_ntp(event); + self.algorithm.feed_link_local(ntp) + } +} + +/// Generate a perfect scenario with no ambiguity +/// +/// Perfect means: +/// - The TSC oscillator does not deviate from the expected clock frequency (no noise nor systematic drift) +/// - All network traffic is the same and perfectly symmetric +pub fn perfect_symmetric(scenario_duration: TrueDuration) -> Scenario { + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(3.3)) + .start_time(NOW_ISH) + .duration(scenario_duration) + .call(); + + let round_trip_delays = RoundTripDelays::builder() + .server(TrueDuration::from_micros(50)) + .forward_network(TrueDuration::from_micros(38)) + .backward_network(TrueDuration::from_micros(38)) + .build(); + + let full_model = FullModel::calculate_from_oscillator(oscillator); + + let mut generator = simulation::ntp::PerfectGenerator::builder() + .id("ll".into()) + .poll_period(EstimateDuration::from_secs(2)) + .root_delay(EstimateDuration::from_micros(31)) + .root_dispersion(EstimateDuration::from_micros(80)) + .network_delays(round_trip_delays) + .build(); + + generator.create_scenario(full_model) +} + +fn tester_event_to_ntp(e: &Event) -> event::Ntp { + use crate::time::CbBridge; + let EventKind::Ntp(n) = &e.variants else { + panic!("Expected NTP event, found {e:?}"); + }; + + event::Ntp::builder() + .tsc_pre(e.client_tsc_pre_time) + .tsc_post(e.client_tsc_post_time) + .ntp_data(event::NtpData { + server_recv_time: Instant::from_estimate(n.server_system_recv_time), + server_send_time: Instant::from_estimate(n.server_system_send_time), + root_delay: Duration::from_estimate(n.root_delay), + root_dispersion: Duration::from_estimate(n.root_dispersion), + stratum: event::Stratum::ONE, + }) + .build() + .unwrap() +} + +// now-ish. Oct 27 2025 +const NOW_ISH: TrueInstant = TrueInstant::from_days(365 * 55 + 299); + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use clock_bound::daemon::{ + clock_sync_algorithm::source::LinkLocal, selected_clock::SelectedClockSource, + time::tsc::Skew, + }; + + use crate::time::CbBridge; + + use super::*; + + #[test] + fn perfect_does_not_panic() { + let mut tester = TestLinkLocal::new( + ClockSyncAlgorithm::builder() + .link_local(LinkLocal::new(Skew::from_ppm(15.0))) + .ntp_sources(vec![]) + .selected_clock(Arc::new(SelectedClockSource::default())) + .build(), + ); + // running a week long scenario in -O0 is asking for trouble + let scenario = perfect_symmetric(TrueDuration::from_secs(1024 * 5)); + let _ = tester.run(&scenario); + } + + #[test] + fn alg_is_perfect_under_perfect_conditions() { + let mut tester = TestLinkLocal::new( + ClockSyncAlgorithm::builder() + .link_local(LinkLocal::new(Skew::from_ppm(15.0))) + .ntp_sources(vec![]) + .selected_clock(Arc::new(SelectedClockSource::default())) + .build(), + ); + let scenario = perfect_symmetric(TrueDuration::from_secs(1024 * 5)); + let Scenario::V1(scenario) = &scenario; + let mut events = scenario.events.iter().enumerate(); + let (_, first_event) = events.next().unwrap(); + let first_output = tester.feed_ntp(first_event); + assert!(first_output.is_none()); + + for (idx, event) in events { + let output = tester.feed_ntp(event); + let param = output.unwrap(); + + let tsc_midpoint = event + .client_tsc_pre_time + .midpoint(event.client_tsc_post_time); + let EventKind::Ntp(ntp) = &event.variants else { + panic!("Expected NTP event, found {event:?}"); + }; + let ref_clock_midpoint = ntp + .server_system_recv_time + .midpoint(ntp.server_system_send_time); + + assert_eq!(param.tsc_count, tsc_midpoint, "Failure at idx {idx}"); + assert!( + approx::abs_diff_eq!( + param.time.into_estimate().as_picos() as i64, + ref_clock_midpoint.as_picos() as i64, + epsilon = 10 + ), + "Failure at idx {idx}. parameters not expected: {param:#?}\nexpected {}\ngot{}", + ref_clock_midpoint.get(), + param.time.get() + ); + } + } +} diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs index 14d9fa6..3bfc31e 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs @@ -328,7 +328,7 @@ mod tests { ); } - // last event triggers the 1000 second SKM window expiring. + // last event triggers the 1024 second SKM window expiring. local.feed(events[100].clone()).unwrap(); local.expunge_old_events(period); let result = estimate.feed(&local, period); diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 2fb581e..db91323 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -256,8 +256,10 @@ impl Ntp { // // A complex strategy we could take on would be to calculate error values on slopes and directly compare // these values in search. That kind of approach is TBD. - let p_estimate = (newest.data().server_send_time - oldest.data().server_send_time) - / (newest.tsc_post() - oldest.tsc_post()); + let ref_clock_diff = newest.data().server_send_time - oldest.data().server_send_time; + let tsc_diff = newest.tsc_post() - oldest.tsc_post(); + tracing::trace!(?ref_clock_diff, ?tsc_diff, "estimate period inputs"); + let p_estimate = ref_clock_diff / tsc_diff; let k = p_estimate * TscDiff::new(newest.tsc_post().get()); let k = newest.data().server_send_time - k; @@ -406,6 +408,7 @@ impl Ntp { * uncorrected_clock.p_estimate.get() * ((now_post - event.tsc_post()).get() as f64); let skew_correction_nsec = skew_correction_seconds * 1e9; + tracing::trace!(?event, %weight, ?offset, skew_correction = ?Duration::from_seconds_f64(skew_correction_seconds), "theta iteration"); numerator += weight * (offset_nsec + skew_correction_nsec); denominator += weight; } From 78907723073457005da1fdc5aa6af0d6c9b2e93b Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 10 Nov 2025 13:27:05 -0500 Subject: [PATCH 093/177] Fix compilation errors from multiple CRs in flight (#117) --- clock-bound-ff-tester/src/bin/sim_ll.rs | 3 ++- clock-bound-ff-tester/src/sim_ll.rs | 22 ++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/clock-bound-ff-tester/src/bin/sim_ll.rs b/clock-bound-ff-tester/src/bin/sim_ll.rs index e5c8d35..4acc013 100644 --- a/clock-bound-ff-tester/src/bin/sim_ll.rs +++ b/clock-bound-ff-tester/src/bin/sim_ll.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use clock_bound::daemon::{ - clock_sync_algorithm::{ClockSyncAlgorithm, source}, + clock_sync_algorithm::{ClockSyncAlgorithm, Selector, source}, selected_clock::SelectedClockSource, subscriber::PRIMER_TARGET, }; @@ -31,6 +31,7 @@ fn main() { .link_local(source::LinkLocal::new(Skew::from_ppm(15.0))) .ntp_sources(vec![]) .selected_clock(Arc::new(SelectedClockSource::default())) + .selector(Selector::new(Skew::from_ppm(15.0))) .build(), ); diff --git a/clock-bound-ff-tester/src/sim_ll.rs b/clock-bound-ff-tester/src/sim_ll.rs index 2793880..682204f 100644 --- a/clock-bound-ff-tester/src/sim_ll.rs +++ b/clock-bound-ff-tester/src/sim_ll.rs @@ -6,6 +6,7 @@ use clock_bound::daemon::{ clock_parameters::ClockParameters, clock_sync_algorithm::ClockSyncAlgorithm, event, + receiver_stream::RoutableEvent, time::{Duration, Instant, tsc::Frequency}, }; @@ -40,14 +41,14 @@ impl TestLinkLocal { scenario .events .iter() - .map(tester_event_to_ntp) - .map(|e| self.algorithm.feed_link_local(e.clone()).cloned()) + .map(tester_event_to_link_local) + .map(|e| self.algorithm.feed(e.clone()).cloned()) .collect() } pub fn feed_ntp(&mut self, event: &Event) -> Option<&ClockParameters> { - let ntp = tester_event_to_ntp(event); - self.algorithm.feed_link_local(ntp) + let ntp = tester_event_to_link_local(event); + self.algorithm.feed(ntp) } } @@ -82,13 +83,13 @@ pub fn perfect_symmetric(scenario_duration: TrueDuration) -> Scenario { generator.create_scenario(full_model) } -fn tester_event_to_ntp(e: &Event) -> event::Ntp { +fn tester_event_to_link_local(e: &Event) -> RoutableEvent { use crate::time::CbBridge; let EventKind::Ntp(n) = &e.variants else { panic!("Expected NTP event, found {e:?}"); }; - event::Ntp::builder() + let event = event::Ntp::builder() .tsc_pre(e.client_tsc_pre_time) .tsc_post(e.client_tsc_post_time) .ntp_data(event::NtpData { @@ -99,7 +100,9 @@ fn tester_event_to_ntp(e: &Event) -> event::Ntp { stratum: event::Stratum::ONE, }) .build() - .unwrap() + .unwrap(); + + RoutableEvent::LinkLocal(event) } // now-ish. Oct 27 2025 @@ -110,7 +113,8 @@ mod tests { use std::sync::Arc; use clock_bound::daemon::{ - clock_sync_algorithm::source::LinkLocal, selected_clock::SelectedClockSource, + clock_sync_algorithm::{Selector, source::LinkLocal}, + selected_clock::SelectedClockSource, time::tsc::Skew, }; @@ -125,6 +129,7 @@ mod tests { .link_local(LinkLocal::new(Skew::from_ppm(15.0))) .ntp_sources(vec![]) .selected_clock(Arc::new(SelectedClockSource::default())) + .selector(Selector::new(Skew::from_ppm(15.0))) .build(), ); // running a week long scenario in -O0 is asking for trouble @@ -139,6 +144,7 @@ mod tests { .link_local(LinkLocal::new(Skew::from_ppm(15.0))) .ntp_sources(vec![]) .selected_clock(Arc::new(SelectedClockSource::default())) + .selector(Selector::new(Skew::from_ppm(15.0))) .build(), ); let scenario = perfect_symmetric(TrueDuration::from_secs(1024 * 5)); From af5260e3e10de256e8d1646661497de238258462 Mon Sep 17 00:00:00 2001 From: mk <55758543+mekabir@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:26:45 -0500 Subject: [PATCH 094/177] Populate fields in request packets (#105) * Populate fields in request packets In this commit, - clockbound now includes stratum and refid in all outgoing packets in accordance with the rfc - clockbound now also includes the 0xFEC2 extension in the outgoing packets to Amazon Public TIME Addresses --------- Co-authored-by: MOHAMMED KABIR --- Cargo.lock | 1 + clock-bound/src/daemon/event/ntp.rs | 29 +++ clock-bound/src/daemon/io.rs | 3 + clock-bound/src/daemon/io/link_local.rs | 32 +++- clock-bound/src/daemon/io/ntp/packet.rs | 67 ++++++- .../src/daemon/io/ntp/packet/extension.rs | 3 + clock-bound/src/daemon/io/ntp_source.rs | 29 ++- clock-bound/src/daemon/selected_clock.rs | 19 +- test/link-local/src/main.rs | 167 ++++++++++++++---- test/ntp-source/Cargo.toml | 1 + test/ntp-source/src/main.rs | 166 ++++++++++++++--- 11 files changed, 439 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c2a0ac..88fce53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,6 +912,7 @@ name = "ntp-source" version = "2.0.3" dependencies = [ "clock-bound", + "md5", "rand 0.9.2", "tokio", "tracing-subscriber", diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index 8a32388..4b1d12c 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -205,6 +205,24 @@ impl Stratum { _ => None, } } + + /// Get the incremented stratum for this NTP client + /// + /// Returns this stratum + 1, capped at `Unsynchronized` (16). + /// + /// # Panics + /// Never panics - all incremented values are guaranteed to be valid. + #[must_use] + pub fn incremented(&self) -> Stratum { + let current_value = u8::from(*self); + match current_value { + 0..=14 => Stratum::Level( + ValidStratumLevel::new(current_value + 1) + .expect("value 1-15 should be valid stratum level"), + ), + _ => Stratum::Unsynchronized, + } + } } impl From for u8 { @@ -263,6 +281,17 @@ mod tests { use super::*; use rstest::rstest; + #[rstest] + #[case(Stratum::Unspecified, Stratum::Level(ValidStratumLevel::new(1).unwrap()))] + #[case(Stratum::ONE, Stratum::TWO)] + #[case(Stratum::TWO, Stratum::Level(ValidStratumLevel::new(3).unwrap()))] + #[case(Stratum::Level(ValidStratumLevel::new(14).unwrap()), Stratum::Level(ValidStratumLevel::new(15).unwrap()))] + #[case(Stratum::Level(ValidStratumLevel::new(15).unwrap()), Stratum::Unsynchronized)] + #[case(Stratum::Unsynchronized, Stratum::Unsynchronized)] + fn stratum_incremented(#[case] input: Stratum, #[case] expected: Stratum) { + assert_eq!(input.incremented(), expected); + } + #[test] fn valid_ntp_event() { let event = Ntp::builder() diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index b962d0e..ea68792 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -86,6 +86,7 @@ impl SourceIO { event_sender, ctrl_receiver, clock_disruption_receiver, + self.selected_clock.clone(), ); Some(Source { state: SourceState::Initialized(link_local), @@ -123,6 +124,7 @@ impl SourceIO { event_sender, ctrl_receiver, clock_disruption_receiver, + self.selected_clock.clone(), self.daemon_info.clone(), ); @@ -297,6 +299,7 @@ mod tests { event_sender, ctrl_receiver, clock_disruption_receiver, + Arc::new(SelectedClockSource::default()), ); let current_state = SourceState::Initialized(link_local); assert!(current_state.is_initialized()) diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index 3033b2f..ae93d8e 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -1,5 +1,6 @@ //! Link Local IO Source +use std::sync::Arc; use thiserror::Error; use tokio::{ io, @@ -18,6 +19,8 @@ use super::{ClockDisruptionEvent, ControlRequest}; use crate::daemon::{ async_ring_buffer, event::{self, NtpData}, + io::ntp::packet::Timestamp, + selected_clock::SelectedClockSource, time::tsc::TscCount, }; @@ -44,9 +47,10 @@ pub struct LinkLocal { event_sender: async_ring_buffer::Sender, ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, - ntp_buffer: [u8; Packet::SIZE], + ntp_buffer: [u8; Packet::MIN_SIZE], interval: Interval, mode: Mode, + selected_clock: Arc, } impl LinkLocal { @@ -60,6 +64,7 @@ impl LinkLocal { event_sender: async_ring_buffer::Sender, ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, + selected_clock: Arc, ) -> Self { let mut link_local_interval = interval(LINK_LOCAL_BURST_INTERVAL_DURATION); link_local_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); @@ -68,9 +73,10 @@ impl LinkLocal { event_sender, ctrl_receiver, clock_disruption_receiver, - ntp_buffer: [0u8; Packet::SIZE], + ntp_buffer: [0u8; Packet::MIN_SIZE], interval: link_local_interval, mode: Mode::burst(), + selected_clock, } } @@ -82,7 +88,12 @@ impl LinkLocal { /// collected the NTP sample we construct the `Event` and push that event through /// to the ring buffer. async fn sample(&mut self) -> Result { - let packet = Packet::new_request(0); + let (refid, stratum) = self.selected_clock.get(); + let packet = Packet::builder() + .transmit_timestamp(Timestamp::new(0)) + .stratum(stratum.incremented().into()) + .reference_id(refid.into()) + .build(); packet.emit_bytes(&mut self.ntp_buffer); let sent_timestamp = read_timestamp_counter_begin(); @@ -198,6 +209,7 @@ impl LinkLocal { ntp_buffer: _ntp_buffer, interval: ll_interval, mode, + selected_clock: _selected_clock, } = self; *mode = Mode::burst(); @@ -217,6 +229,7 @@ impl LinkLocal { ntp_buffer: _ntp_buffer, interval: ll_interval, mode, + selected_clock: _selected_clock, } = self; *mode = Mode::Normal; @@ -252,7 +265,11 @@ mod tests { use super::*; use crate::daemon::io::ntp; - async fn create_link_local() -> (LinkLocal, watch::Sender) { + async fn create_link_local() -> ( + LinkLocal, + watch::Sender, + Arc, + ) { let (event_sender, _) = async_ring_buffer::create::(1); let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) .await @@ -263,20 +280,23 @@ mod tests { disruption_marker: None, }); + let selected_clock = Arc::new(SelectedClockSource::default()); ( LinkLocal::construct( socket, event_sender, ctrl_receiver, clock_disruption_receiver, + selected_clock.clone(), ), clock_disruption_sender, + selected_clock, ) } #[tokio::test] async fn validate_to_burst_mode() { - let (mut link_local, _) = create_link_local().await; + let (mut link_local, _, _) = create_link_local().await; link_local.transition_to_burst_mode(); assert!(matches!(link_local.mode, Mode::Burst(_))); @@ -288,7 +308,7 @@ mod tests { #[tokio::test] async fn validate_to_normal_mode() { - let (mut link_local, _) = create_link_local().await; + let (mut link_local, _, _) = create_link_local().await; link_local.transition_to_burst_mode(); link_local.transition_to_normal_mode(); diff --git a/clock-bound/src/daemon/io/ntp/packet.rs b/clock-bound/src/daemon/io/ntp/packet.rs index 5c9cca3..6e75efa 100644 --- a/clock-bound/src/daemon/io/ntp/packet.rs +++ b/clock-bound/src/daemon/io/ntp/packet.rs @@ -122,7 +122,7 @@ impl Display for Packet { impl Packet { /// NTP packet size in bytes, without extensions - pub const SIZE: usize = 48; + pub const MIN_SIZE: usize = 48; /// Construct an NTP request packet using an arbitrary u32 as the transmit timestamp /// @@ -162,9 +162,18 @@ impl Packet { /// Emit packet into bytes /// /// ## Panics - /// Panics if buffer is smaller than [`Packet::SIZE`] + /// Panics if buffer is smaller than total packet size pub fn emit_bytes(&self, mut buffer: &mut [u8]) { use bytes::BufMut; + + let total_size = self.total_size(); + assert!( + buffer.len() >= total_size, + "Buffer too small: {} < {}", + buffer.len(), + total_size + ); + let header: u8 = self.mode.to_bits() | self.version.get() << 3 | self.leap_indicator.to_bits() << 6; buffer.put_u8(header); @@ -178,6 +187,20 @@ impl Packet { buffer.put_u64_ne(self.origin_timestamp.into_network_endian()); buffer.put_u64_ne(self.receive_timestamp.into_network_endian()); buffer.put_u64_ne(self.transmit_timestamp.into_network_endian()); + + for extension in &self.extensions { + extension.emit_bytes(buffer); + } + } + + /// Calculate total packet size including extensions + pub fn total_size(&self) -> usize { + Self::MIN_SIZE + + self + .extensions + .iter() + .map(|ext| ext.length() as usize) + .sum::() } /// Parse from packet payload @@ -356,12 +379,44 @@ mod test { .assert_within_error_bound(DateTime::from_timestamp(1738189694, 689379474).unwrap()); } - #[test] - fn emit() { + #[rstest] + #[case::no_extensions(Packet::builder().build(), 48)] + #[case::with_fec2v1( + Packet::builder() + .extensions(vec![ExtensionField::Fec2V1(Fec2V1Value { + major_version: 2, + minor_version: 100, + startup_id: 0xdeadbeef, + })]) + .build(), + 64 + )] + fn packet_total_size(#[case] packet: Packet, #[case] expected_size: usize) { + assert_eq!(packet.total_size(), expected_size); + } + + #[rstest] + #[case::no_extensions({ let (_, packet) = Packet::parse_from_bytes(NTP_PACKET).unwrap(); - let mut output = vec![0u8; Packet::SIZE]; + (packet, NTP_PACKET.to_vec()) + })] + #[case::with_extension({ + let (_, mut packet) = Packet::parse_from_bytes(NTP_PACKET).unwrap(); + packet.extensions.push(ExtensionField::Fec2V1(Fec2V1Value { + major_version: 2, + minor_version: 100, + startup_id: 0xdeadbeef, + })); + let mut expected = NTP_PACKET.to_vec(); + expected.extend_from_slice(&[0xFE, 0xC2, 0x00, 0x10]); // Extension header + expected.extend_from_slice(&[1, 2, 100, 0]); // FEC2V1 data + expected.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF]); + (packet, expected) + })] + fn emit(#[case] (packet, expected): (Packet, Vec)) { + let mut output = vec![0u8; packet.total_size()]; packet.emit_bytes(&mut output); - assert_eq!(output, NTP_PACKET); + assert_eq!(output, expected); } #[test] diff --git a/clock-bound/src/daemon/io/ntp/packet/extension.rs b/clock-bound/src/daemon/io/ntp/packet/extension.rs index 78a95ba..8ee81b1 100644 --- a/clock-bound/src/daemon/io/ntp/packet/extension.rs +++ b/clock-bound/src/daemon/io/ntp/packet/extension.rs @@ -52,6 +52,9 @@ pub struct Fec2V1Value { } impl ExtensionField { + /// Minimum NTP extension field size in bytes + pub const MIN_SIZE: usize = 16; + /// Returns the NTP extension Field Type as defined in RFC 5905 /// /// A 16-bit value that uniquely identifies the specific extension field and its function diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 6e88bdb..0d7e034 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -1,7 +1,6 @@ //! NTP Server IO Source -use std::net::SocketAddr; - +use std::{net::SocketAddr, sync::Arc}; use thiserror::Error; use tokio::{ io, @@ -15,7 +14,11 @@ use super::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; use crate::daemon::{ async_ring_buffer, event::{self, NtpData}, - io::{ClockDisruptionEvent, ControlRequest, DaemonInfo}, + io::{ + ClockDisruptionEvent, ControlRequest, DaemonInfo, + ntp::packet::{ExtensionField, Timestamp}, + }, + selected_clock::SelectedClockSource, time::tsc::TscCount, }; @@ -46,8 +49,9 @@ pub struct NTPSource { event_sender: async_ring_buffer::Sender, ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, - ntp_buffer: [u8; Packet::SIZE], + ntp_buffer: [u8; Packet::MIN_SIZE + ExtensionField::MIN_SIZE], interval: Interval, + selected_clock: Arc, daemon_info: DaemonInfo, } @@ -59,6 +63,7 @@ impl NTPSource { event_sender: async_ring_buffer::Sender, ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, + selected_clock: Arc, daemon_info: DaemonInfo, ) -> Self { let mut ntp_source_interval = interval(NTP_SOURCE_INTERVAL_DURATION); @@ -69,8 +74,9 @@ impl NTPSource { event_sender, ctrl_receiver, clock_disruption_receiver, - ntp_buffer: [0u8; Packet::SIZE], + ntp_buffer: [0u8; Packet::MIN_SIZE + ExtensionField::MIN_SIZE], interval: ntp_source_interval, + selected_clock, daemon_info, } } @@ -83,14 +89,23 @@ impl NTPSource { /// collected the NTP sample we construct the `Event` and push that event through /// to the ring buffer. async fn sample(&mut self) -> Result { - let packet = Packet::new_request(0); + let (refid, stratum) = self.selected_clock.get(); + let packet = Packet::builder() + .transmit_timestamp(Timestamp::new(0)) + .stratum(stratum.incremented().into()) + .reference_id(refid.into()) + .extensions(vec![ExtensionField::Fec2V1(self.daemon_info.clone())]) + .build(); packet.emit_bytes(&mut self.ntp_buffer); let sent_timestamp = read_timestamp_counter_begin(); // Request and Receive NTP sample. let recv_packet_result = timeout(NTP_SOURCE_TIMEOUT, { - self.socket.send_to(&self.ntp_buffer, self.address).await?; + let packet_size = packet.total_size(); + self.socket + .send_to(&self.ntp_buffer[..packet_size], self.address) + .await?; self.socket.recv_from(&mut self.ntp_buffer) }) .await?; diff --git a/clock-bound/src/daemon/selected_clock.rs b/clock-bound/src/daemon/selected_clock.rs index 56e1a04..d008ae1 100644 --- a/clock-bound/src/daemon/selected_clock.rs +++ b/clock-bound/src/daemon/selected_clock.rs @@ -135,6 +135,12 @@ impl From for u32 { } } +impl From for [u8; 4] { + fn from(source: ClockSource) -> [u8; 4] { + u32::from(source).to_be_bytes() + } +} + impl Display for ClockSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -237,11 +243,22 @@ mod tests { #[case(ClockSource::None, 0)] #[case(ClockSource::Phc, 0x5850_4843)] // "XPHC" #[case(ClockSource::VMClock, 0x5856_4D43)] // "XVMC" - #[case(ClockSource::Server(0xC0A8_0101), 0xC0A8_0101)] + #[case(ClockSource::Server(u32::from_be_bytes([192, 168, 1, 1])), 0xC0A8_0101)] fn clock_source_to_u32(#[case] source: ClockSource, #[case] expected: u32) { assert_eq!(u32::from(source), expected); } + #[rstest] + #[case(ClockSource::Init, [73, 78, 73, 84])] // "INIT" + #[case(ClockSource::None, [0, 0, 0, 0])] + #[case(ClockSource::Phc, [88, 80, 72, 67])] // "XPHC" + #[case(ClockSource::VMClock, [88, 86, 77, 67])] // "XVMC" + #[case(ClockSource::Server(u32::from_be_bytes([192, 168, 1, 1])), [192, 168, 1, 1])] + #[case(ClockSource::Server(u32::from_be_bytes([169, 254, 169, 123])), [169, 254, 169, 123])] + fn clock_source_to_bytes(#[case] source: ClockSource, #[case] expected: [u8; 4]) { + assert_eq!(<[u8; 4]>::from(source), expected); + } + #[test] fn selected_clock_source_display() { let clock = SelectedClockSource::default(); diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index c8f7b21..1ee84b5 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -3,10 +3,14 @@ //! This executable tests that the link local runner is able to send and receive packets from the //! link local address and that the polling rate is roughly once a second. -use clock_bound::daemon::event::Ntp; +use clock_bound::daemon::event::{Ntp, Stratum, ValidStratumLevel}; use clock_bound::daemon::io::{SourceIO, ntp::LINK_LOCAL_BURST_INTERVAL_DURATION}; -use clock_bound::daemon::selected_clock::SelectedClockSource; -use clock_bound::daemon::{async_ring_buffer, io::ntp::DaemonInfo}; +use clock_bound::daemon::selected_clock::{ClockSource, SelectedClockSource}; +use clock_bound::daemon::{ + async_ring_buffer::{self, BufferClosedError, Receiver}, + io::ntp::DaemonInfo, +}; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time; @@ -14,14 +18,25 @@ use rand::{RngCore, rng}; use tokio::time::{Duration, timeout}; use tracing_subscriber::EnvFilter; +/// Time out for waiting on source polling task to produce an NTP event +/// +/// On instances that aren't able to connect to link local the runner will run infinitely. +const TIMEOUT_SECS: u64 = 8; // Should yield 3 or more polls + #[tokio::main(flavor = "current_thread")] async fn main() { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); - println!("Lets get a NTP packet!"); - let (link_local_sender, mut link_local_receiver) = async_ring_buffer::create(1); + test_normal_polling_rate().await; + test_burst_polling_rate().await; + test_polls_with_selected_clock_source_combos().await; +} + +/// Set up link local source io for testing +async fn setup() -> (Receiver, Arc, SourceIO) { + let (sender, receiver) = async_ring_buffer::create(1); let daemon_info = DaemonInfo { major_version: 2, @@ -29,15 +44,50 @@ async fn main() { startup_id: rng().next_u64(), }; - let mut sourceio = SourceIO::construct(Arc::new(SelectedClockSource::default()), daemon_info); - sourceio.create_link_local(link_local_sender).await; + let selected_clock = Arc::new(SelectedClockSource::default()); + let mut sourceio = SourceIO::construct(selected_clock.clone(), daemon_info); + sourceio.create_link_local(sender).await; sourceio.spawn_all(); + (receiver, selected_clock, sourceio) +} + +/// Test normal polling +async fn test_normal_polling_rate() { + println!("Testing normal polling rate ..."); + let polling_iterations: u32 = 10; + let (receiver, _selected_clock, _sourceio) = setup().await; + + // Wait for burst mode to end before testing normal polling rate + tokio::time::sleep(Duration::from_secs(3)).await; + + // Clear any burst mode packets from buffer + while let Ok(Ok(_)) = timeout(Duration::from_millis(100), receiver.recv()).await {} - validate_burst_mode(&mut link_local_receiver).await; - validate_normal_mode(&mut link_local_receiver).await; + let mut start = time::Instant::now(); + let mut polling_rate = time::Duration::from_secs(0); + for _ in 1..=polling_iterations { + let ntpevent = timeout(Duration::from_secs(TIMEOUT_SECS), receiver.recv()) + .await + .unwrap(); + let now = time::Instant::now(); + let d = now - start; + println!( + "It looks like we got an ntp packet \n{ntpevent:#?}\n{:?} ms", + d.as_millis() + ); + polling_rate += d; + start = now; + } + polling_rate /= polling_iterations; + println!("Polling rate avg: {polling_rate:?}"); + assert!(polling_rate.abs_diff(Duration::from_secs(2)) < time::Duration::from_millis(100)); + println!("Normal poll rate test PASSED"); } -async fn validate_burst_mode(receiver: &mut async_ring_buffer::Receiver) { +/// Test burst polling +async fn test_burst_polling_rate() { + println!("Testing burst polling rate ..."); + let (receiver, _selected_clock, _sourceio) = setup().await; let start = time::Instant::now(); let mut polling_rate = time::Duration::from_secs(0); let mut count = 0; @@ -73,32 +123,85 @@ async fn validate_burst_mode(receiver: &mut async_ring_buffer::Receiver) { polling_rate.abs_diff(LINK_LOCAL_BURST_INTERVAL_DURATION) < time::Duration::from_millis(100) ); + println!("Burst poll rate test PASSED"); } -async fn validate_normal_mode(receiver: &mut async_ring_buffer::Receiver) { - let mut start = time::Instant::now(); - let mut polling_rate = time::Duration::from_secs(0); - for i in 0..11 { - // On instances that aren't able to connect to link local the runner will run infinitely. - // To address this we timeout if an NTP event has not been received. - let ntpevent = timeout(Duration::from_secs(5), receiver.recv()) - .await - .unwrap(); - let now = time::Instant::now(); - let d = now - start; - println!( - "It looks like we got an ntp packet \n{ntpevent:#?}\n{:?} ms", - d.as_millis() - ); +/// Test polling with varied selected clock source and stratum combinations +async fn test_polls_with_selected_clock_source_combos() { + println!("Testing polling with all selected clock source combinations ..."); - // Skip the first sample, the IO runner will poll immediately after it's created. - if i > 0 { - polling_rate += d; + let combinations = generate_selected_clock_combos(); + println!( + "Generated {} selected clock source combinations", + combinations.len() + ); + + let (receiver, selected_clock, _sourceio) = setup().await; + + for (source, stratum) in combinations { + match source.clone() { + ClockSource::Init => {} // Default state + ClockSource::Phc => selected_clock.set_to_phc(), + ClockSource::VMClock => selected_clock.set_to_vmclock(), + ClockSource::None => selected_clock.set_to_none(), + ClockSource::Server(ip) => { + selected_clock.set_to_server(IpAddr::V4(Ipv4Addr::from(ip)), stratum); + } } - start = now; + // Clear any stale events from the buffer + while let Ok(Ok(_)) = timeout(Duration::from_millis(100), receiver.recv()).await {} + + println!("Polling with selected clock: {selected_clock}"); + #[allow( + clippy::match_wild_err_arm, + reason = "Only possible error from timeout is Elapsed, which has a private constructor" + )] + match timeout(Duration::from_secs(TIMEOUT_SECS), receiver.recv()).await { + Ok(Ok(_ntp_event)) => { + println!(" Polling succeeded."); + } + Ok(Err(BufferClosedError)) => { + panic!(" Polling failed: Buffer closed"); + } + Err(_) => { + panic!(" Polling failed: Timed out. Selected clock: {selected_clock}"); + } + } } - polling_rate /= 10; - println!("Polling rate avg: {polling_rate:?}"); - assert!(polling_rate.abs_diff(time::Duration::from_secs(2)) < time::Duration::from_millis(100)); + + println!("Poll test with all selected clock source combinations PASSED"); +} + +fn generate_selected_clock_combos() -> Vec<(ClockSource, Stratum)> { + let mut combos = Vec::new(); + + // Exhaustive match to fail compilation if new ClockSource variants are added + let base_sources = [ + ClockSource::Init, + ClockSource::Phc, + ClockSource::VMClock, + ClockSource::None, + ClockSource::Server(0), // placeholder + ]; + + for source in base_sources { + match source { + ClockSource::Init => combos.push((ClockSource::Init, Stratum::Unspecified)), + ClockSource::Phc => combos.push((ClockSource::Phc, Stratum::Unspecified)), + ClockSource::VMClock => combos.push((ClockSource::VMClock, Stratum::Unspecified)), + ClockSource::None => combos.push((ClockSource::None, Stratum::Unsynchronized)), + ClockSource::Server(_) => { + for level in 1..=15 { + let stratum = Stratum::Level(ValidStratumLevel::new(level).unwrap()); + combos.push(( + ClockSource::Server(u32::from_be_bytes([169, 254, 169, 123])), + stratum, + )); + } + } + } + } + + combos } diff --git a/test/ntp-source/Cargo.toml b/test/ntp-source/Cargo.toml index bad1bcf..6ffa4c4 100644 --- a/test/ntp-source/Cargo.toml +++ b/test/ntp-source/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" clock-bound = { version = "2.0", path = "../../clock-bound", features = [ "daemon", ] } +md5 = "0.8.0" rand = "0.9.2" tokio = { version = "1.47.1", features = ["macros", "rt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/ntp-source/src/main.rs b/test/ntp-source/src/main.rs index 443e5ac..89430d1 100644 --- a/test/ntp-source/src/main.rs +++ b/test/ntp-source/src/main.rs @@ -3,65 +3,179 @@ //! This executable tests that the NTP Server runner is able to send and receive packets from the //! specified NTP Server address and that the polling rate is roughly once a second. -use clock_bound::daemon::async_ring_buffer::{self, Receiver}; -use clock_bound::daemon::event::Ntp; +use clock_bound::daemon::async_ring_buffer::{self, BufferClosedError, Receiver}; +use clock_bound::daemon::event::{Ntp, Stratum, ValidStratumLevel}; use clock_bound::daemon::io::ntp::NTPSourceSender; use clock_bound::daemon::io::{ SourceIO, ntp::{AWS_TEMP_PUBLIC_TIME_ADDRESSES, DaemonInfo}, }; -use clock_bound::daemon::selected_clock::SelectedClockSource; +use clock_bound::daemon::selected_clock::{ClockSource, SelectedClockSource}; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time::Duration; +use tokio::time::timeout; use rand::{RngCore, rng}; use tracing_subscriber::EnvFilter; +/// Time out for waiting on source polling task to produce an NTP event +const TIMEOUT_SECS: u64 = 48; // Should yield 3 or more polls + #[tokio::main(flavor = "current_thread")] async fn main() { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); + test_ntp_sources().await; + test_polls_with_selected_clock_source_combos().await; +} + +/// Set up ntp source io for testing +async fn setup() -> (Vec>, Arc, SourceIO) { let daemon_info = DaemonInfo { major_version: 2, minor_version: 100, startup_id: rng().next_u64(), }; - let mut sourceio = SourceIO::construct(Arc::new(SelectedClockSource::default()), daemon_info); + let selected_clock = Arc::new(SelectedClockSource::default()); + + let mut sourceio = SourceIO::construct(selected_clock.clone(), daemon_info); let mut receiver_vec: Vec> = Vec::new(); for address in AWS_TEMP_PUBLIC_TIME_ADDRESSES { - // Create ring buffer channels for ntp io sources let (tx, rx) = async_ring_buffer::create(1); receiver_vec.push(rx); - - // Create IO time sources let sender_with_address: NTPSourceSender = (address, tx); sourceio.create_ntp_source(sender_with_address).await; } - println!("NTP Source creation complete!"); - // Get NTP packet from both specified servers - println!("Lets get NTP packets!"); sourceio.spawn_all(); + (receiver_vec, selected_clock, sourceio) +} + +/// Test basic NTP source functionality +async fn test_ntp_sources() { + println!("Testing NTP sources ..."); + let (receiver_vec, _selected_clock, _sourceio) = setup().await; + for i in 0..AWS_TEMP_PUBLIC_TIME_ADDRESSES.len() { - let timeout_err_msg = &format!( - "Timeout was reached before a packet was received from {:#?}", - AWS_TEMP_PUBLIC_TIME_ADDRESSES[i] - ); - - let event_result = tokio::time::timeout(Duration::from_secs(48), receiver_vec[i].recv()) - .await - .expect(timeout_err_msg); - - event_result.unwrap(); - println!( - "Packet received from host ({} / {})", - i + 1, - AWS_TEMP_PUBLIC_TIME_ADDRESSES.len() - ); + #[allow( + clippy::match_wild_err_arm, + reason = "Only possible error from timeout is Elapsed, which has a private constructor" + )] + match tokio::time::timeout(Duration::from_secs(TIMEOUT_SECS), receiver_vec[i].recv()).await + { + Ok(Ok(_ntp_event)) => { + println!( + "Packet received from host ({} / {})", + i + 1, + AWS_TEMP_PUBLIC_TIME_ADDRESSES.len() + ); + } + Ok(Err(BufferClosedError)) => { + panic!( + "Buffer closed for NTP source {:#?}", + AWS_TEMP_PUBLIC_TIME_ADDRESSES[i] + ); + } + Err(_) => { + panic!( + "Timeout reached before packet received from {:#?}", + AWS_TEMP_PUBLIC_TIME_ADDRESSES[i] + ); + } + } + } + println!("NTP sources test PASSED"); +} + +/// Test NTP sources with varied selected clock source combinations +async fn test_polls_with_selected_clock_source_combos() { + println!("Testing polling with selected clock source combinations ..."); + + let combinations = generate_selected_clock_combos(); + println!( + "Generated {} selected clock source combinations", + combinations.len() + ); + + let (receiver_vec, selected_clock, _sourceio) = setup().await; + for (source, stratum) in combinations { + // Set up the specific clock source and stratum + match source.clone() { + ClockSource::Init => {} // Default state + ClockSource::Phc => selected_clock.set_to_phc(), + ClockSource::VMClock => selected_clock.set_to_vmclock(), + ClockSource::None => selected_clock.set_to_none(), + ClockSource::Server(refid) => { + selected_clock.set_to_server(IpAddr::V4(Ipv4Addr::from(refid)), stratum); + } + } + + // Clear any prior events from the buffer + for receiver in &receiver_vec { + while let Ok(Ok(_)) = timeout(Duration::from_millis(100), receiver.recv()).await {} + } + + println!("Polling with selected clock: {selected_clock}"); + // First response from any NTP source is fine + tokio::select! { + result = receiver_vec[0].recv() => match result { + Ok(_) => println!(" Polling succeeded."), + Err(BufferClosedError) => panic!(" Polling failed: Buffer closed"), + }, + result = receiver_vec[1].recv() => match result { + Ok(_) => println!(" Polling succeeded. Selected clock: {selected_clock}"), + Err(BufferClosedError) => panic!(" Polling failed: Buffer closed"), + }, + () = tokio::time::sleep(Duration::from_secs(TIMEOUT_SECS)) => { + panic!(" Polling failed: Timed out. Selected clock: {selected_clock}"); + } + } } - println!("TEST COMPLETE"); + + println!("Poll test with all selected clock source combinations PASSED"); +} + +fn generate_selected_clock_combos() -> Vec<(ClockSource, Stratum)> { + let mut combos = Vec::new(); + + let base_sources = [ + ClockSource::Init, + ClockSource::Phc, + ClockSource::VMClock, + ClockSource::None, + ClockSource::Server(0), // placeholder + ]; + + for source in base_sources { + match source { + ClockSource::Init => combos.push((ClockSource::Init, Stratum::Unspecified)), + ClockSource::Phc => combos.push((ClockSource::Phc, Stratum::Unspecified)), + ClockSource::VMClock => combos.push((ClockSource::VMClock, Stratum::Unspecified)), + ClockSource::None => combos.push((ClockSource::None, Stratum::Unsynchronized)), + ClockSource::Server(_) => { + combos.push(( + ClockSource::Server(u32::from_be_bytes([169, 254, 169, 123])), + Stratum::Level(ValidStratumLevel::new(1).unwrap()), + )); + combos.push(( + ClockSource::Server({ + let ipv6_bytes = "fd00:ec2::123" + .parse::() + .unwrap() + .octets(); + let hash = md5::compute(ipv6_bytes); + u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]]) + }), + Stratum::Level(ValidStratumLevel::new(1).unwrap()), + )); + } + } + } + + combos } From d0d150b7692e6b4519476ae7da1a1e8a8319c567 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Mon, 10 Nov 2025 12:45:29 -0800 Subject: [PATCH 095/177] Refactor the C FFI client to use the Rust ClockBoundClient (#99) * Refactor the C FFI client to use the Rust ClockBoundClient This patch does not change the behavior of the C client, and keeps the API offered to C clients untouched. Here, the code is refactored to make use of the Rust ClockBoundClient. This helps keeping the implementation details of how to read the various shared memory segment in a single location (the Rust client). With this patch, also refactored the code paths that open the C context to handle possible panic cases that were left there. These changes allow close on previous changes and remove code that is now redundant in the vmclock module. --------- Co-authored-by: Julien Ridoux --- clock-bound-ffi/src/lib.rs | 191 ++++++++++++++------------ clock-bound/src/client.rs | 10 +- clock-bound/src/vmclock.rs | 274 +------------------------------------ 3 files changed, 107 insertions(+), 368 deletions(-) diff --git a/clock-bound-ffi/src/lib.rs b/clock-bound-ffi/src/lib.rs index 439fe70..9c3951a 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -5,12 +5,14 @@ // Align with C naming conventions #![allow(non_camel_case_types)] -use clock_bound::shm::{ClockStatus, ShmError, ShmReader}; -use clock_bound::vmclock::VMClock; +use clock_bound::client::{ + ClockBoundClient, ClockBoundError, ClockBoundErrorKind, ClockBoundNowResult, +}; +use clock_bound::shm::ClockStatus; use clock_bound::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use core::ptr; -use nix::sys::time::TimeSpec; -use std::ffi::{CStr, c_char}; +use errno::Errno; +use std::ffi::{CStr, CString, c_char}; /// Error kind exposed over the FFI. /// @@ -45,27 +47,31 @@ impl Default for clockbound_err { } } -impl From for clockbound_err { - fn from(value: ShmError) -> Self { - let kind = match value { - ShmError::SyscallError(_, _) => clockbound_err_kind::CLOCKBOUND_ERR_SYSCALL, - ShmError::SegmentNotInitialized => { +impl From for clockbound_err { + fn from(value: ClockBoundError) -> Self { + let kind = match value.kind { + ClockBoundErrorKind::Syscall(_) => clockbound_err_kind::CLOCKBOUND_ERR_SYSCALL, + ClockBoundErrorKind::SegmentNotInitialized => { clockbound_err_kind::CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED } - ShmError::SegmentMalformed => clockbound_err_kind::CLOCKBOUND_ERR_SEGMENT_MALFORMED, - ShmError::CausalityBreach => clockbound_err_kind::CLOCKBOUND_ERR_CAUSALITY_BREACH, - ShmError::SegmentVersionNotSupported => { + ClockBoundErrorKind::SegmentMalformed => { + clockbound_err_kind::CLOCKBOUND_ERR_SEGMENT_MALFORMED + } + ClockBoundErrorKind::CausalityBreach => { + clockbound_err_kind::CLOCKBOUND_ERR_CAUSALITY_BREACH + } + ClockBoundErrorKind::SegmentVersionNotSupported => { clockbound_err_kind::CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED } }; - let errno = match value { - ShmError::SyscallError(errno, _) => errno.0, + let errno = match value.kind { + ClockBoundErrorKind::Syscall(_) => value.errno.0, _ => 0, }; - let detail = match value { - ShmError::SyscallError(_, detail) => detail.as_ptr(), + let detail = match value.kind { + ClockBoundErrorKind::Syscall(detail) => detail.as_ptr(), _ => ptr::null(), }; @@ -84,33 +90,18 @@ impl From for clockbound_err { /// This allow to extend the context with extra information if needed. pub struct clockbound_ctx { err: clockbound_err, - clockbound_shm_reader: Option, - vmclock: Option, + clockbound_client: ClockBoundClient, } impl clockbound_ctx { /// Obtain error-bounded timestamps and the `ClockStatus`. - /// - /// The result on success is a tuple of: - /// - `TimeSpec`: earliest timestamp. - /// - `TimeSpec`: latest timestamp. - /// - `ClockStatus`: Status of the clock. - fn now(&mut self) -> Result<(TimeSpec, TimeSpec, ClockStatus), ShmError> { - if let Some(ref mut clockbound_shm_reader) = self.clockbound_shm_reader { - match clockbound_shm_reader.snapshot() { - Ok(clockerrorbound_snapshot) => clockerrorbound_snapshot.now(), - Err(e) => Err(e), - } - } else if let Some(ref mut vmclock) = self.vmclock { - vmclock.now() - } else { - Err(ShmError::SegmentNotInitialized) - } + fn now(&mut self) -> Result { + self.clockbound_client.now() } } /// Clock status exposed over the FFI. -///. +/// #[repr(C)] #[derive(Debug, PartialEq)] pub enum clockbound_clock_status { @@ -141,6 +132,22 @@ pub struct clockbound_now_result { clock_status: clockbound_clock_status, } +impl From for clockbound_now_result { + fn from(value: ClockBoundNowResult) -> Self { + let ClockBoundNowResult { + earliest, + latest, + clock_status, + } = value; + + Self { + earliest: *earliest.as_ref(), + latest: *latest.as_ref(), + clock_status: clock_status.into(), + } + } +} + /// Open and create a reader to the Clockbound shared memory segment. /// /// Create a `ShmReader` pointing at the path passed to this call, and package it (and any other side @@ -149,41 +156,18 @@ pub struct clockbound_now_result { /// /// # Safety /// Rely on the caller to pass valid pointers. -#[expect(clippy::missing_panics_doc, reason = "todo")] #[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_open( clockbound_shm_path: *const c_char, err: *mut clockbound_err, ) -> *mut clockbound_ctx { - // Safety: Rely on caller to pass valid pointers - let clockbound_shm_path_cstr = unsafe { CStr::from_ptr(clockbound_shm_path) }; - let clockbound_shm_path = clockbound_shm_path_cstr - .to_str() - .expect("Failed to convert ClockBound shared memory path to str"); - let vmclock_shm_path = VMCLOCK_SHM_DEFAULT_PATH; - - let vmclock: VMClock = match VMClock::new(clockbound_shm_path, vmclock_shm_path) { - Ok(vmclock) => vmclock, - Err(e) => { - if !err.is_null() { - // Safety: rely on caller to pass valid pointers - unsafe { err.write(e.into()) } - } - return ptr::null_mut(); - } - }; - - let ctx = clockbound_ctx { - err: clockbound_err::default(), - clockbound_shm_reader: None, - vmclock: Some(vmclock), - }; - - // Return the clockbound_ctx. - // - // The caller is responsible for calling clockbound_close() with this context which will - // perform memory clean-up. - Box::leak(Box::new(ctx)) + // Safety: Convert the default path to a CString, and then an array of bytes. The conversion of + // the default path into a CString is unit tested and safe. + unsafe { + #[expect(clippy::missing_panics_doc, reason = "infallible")] + let vmclock_shm_path = CString::new(VMCLOCK_SHM_DEFAULT_PATH).unwrap(); + clockbound_vmclock_open(clockbound_shm_path, vmclock_shm_path.as_ptr(), err) + } } /// Open and create a reader to the Clockbound shared memory segment and the VMClock shared memory segment. @@ -194,7 +178,12 @@ pub unsafe extern "C" fn clockbound_open( /// /// # Safety /// Rely on the caller to pass valid pointers. -#[expect(clippy::missing_panics_doc, reason = "todo")] +/// +// # TODO +// Currently handles errors when converting paths provided by the caller, but it is a bit of a +// stretch to map a UTF conversion error to a `SegmentNotInitialized` kind. A better option would +// be to have a more meaningful variant added to `ClockBoundErrorKind` but don't want to change the +// C API just yet. #[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_vmclock_open( clockbound_shm_path: *const c_char, @@ -203,36 +192,58 @@ pub unsafe extern "C" fn clockbound_vmclock_open( ) -> *mut clockbound_ctx { // Safety: Rely on caller to pass valid pointers let clockbound_shm_path_cstr = unsafe { CStr::from_ptr(clockbound_shm_path) }; - let clockbound_shm_path = clockbound_shm_path_cstr - .to_str() - .expect("Failed to convert ClockBound shared memory path to str"); + let clockbound_shm_path = match clockbound_shm_path_cstr.to_str() { + Ok(path) => path, + Err(e) => { + if !err.is_null() { + let cb_err = ClockBoundError { + kind: ClockBoundErrorKind::SegmentNotInitialized, + errno: Errno(22_i32), // EINVAL, Invalid argument. + detail: format!("Failed to convert ClockBound shared memory path to str: {e}"), + }; + // Safety: rely on caller to pass valid pointers + unsafe { err.write(cb_err.into()) } + } + return ptr::null_mut(); + } + }; + // Safety: Rely on caller to pass valid pointers let vmclock_shm_path_cstr = unsafe { CStr::from_ptr(vmclock_shm_path) }; - let vmclock_shm_path = vmclock_shm_path_cstr - .to_str() - .expect("Failed to convert VMClock shared memory path to str"); - - let vmclock: VMClock = match VMClock::new(clockbound_shm_path, vmclock_shm_path) { - Ok(vmclock) => vmclock, + let vmclock_shm_path = match vmclock_shm_path_cstr.to_str() { + Ok(path) => path, Err(e) => { if !err.is_null() { - // Safety: Rely on caller to pass valid pointers - unsafe { err.write(e.into()) } + let cb_err = ClockBoundError { + kind: ClockBoundErrorKind::SegmentNotInitialized, + errno: Errno(22_i32), // EINVAL, Invalid argument. + detail: format!("Failed to convert VMClock shared memory path to str: {e}"), + }; + // Safety: rely on caller to pass valid pointers + unsafe { err.write(cb_err.into()) } } return ptr::null_mut(); } }; + let clockbound_client = + match ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path) { + Ok(client) => client, + Err(e) => { + if !err.is_null() { + // Safety: rely on caller to pass valid pointers + unsafe { err.write(e.into()) } + } + return ptr::null_mut(); + } + }; + + // Return the clockbound_ctx. The caller is responsible for calling clockbound_close() with + // this context which will perform memory clean-up. let ctx = clockbound_ctx { err: clockbound_err::default(), - clockbound_shm_reader: None, - vmclock: Some(vmclock), + clockbound_client, }; - - // Return the clockbound_ctx. - // - // The caller is responsible for calling clockbound_close() with this context which will - // perform memory clean-up. Box::leak(Box::new(ctx)) } @@ -267,20 +278,18 @@ pub unsafe extern "C" fn clockbound_now( // Safety: Rely on caller to pass valid pointers let ctx = unsafe { &mut *ctx }; - let (earliest, latest, clock_status) = match ctx.now() { + // Get earliest and latest timestamps, as well as the clock status + let cb_now = match ctx.now() { Ok(now) => now, Err(e) => { ctx.err = e.into(); return &raw const ctx.err; } }; + // Safety: Rely on caller to pass valid pointers unsafe { - output.write(clockbound_now_result { - earliest: *earliest.as_ref(), - latest: *latest.as_ref(), - clock_status: clock_status.into(), - }); + output.write(clockbound_now_result::from(cb_now)); } ptr::null() } @@ -427,7 +436,7 @@ mod t_ffi { assert!(errptr.is_null()); assert_eq!( now_result.clock_status, - clockbound_clock_status::CLOCKBOUND_STA_UNKNOWN + clockbound_clock_status::CLOCKBOUND_STA_DISRUPTED ); let errptr = clockbound_close(ctx); diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index 5e24e73..3464b91 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -8,7 +8,7 @@ pub use crate::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use crate::vmclock::shm_reader::VMClockShmReader; use errno::Errno; use nix::sys::time::TimeSpec; -use std::ffi::CString; +use std::ffi::{CStr, CString}; use std::path::Path; /// The `ClockBoundClient` @@ -240,7 +240,7 @@ pub struct ClockBoundError { impl From for ClockBoundError { fn from(value: ShmError) -> Self { let kind = match value { - ShmError::SyscallError(_, _) => ClockBoundErrorKind::Syscall, + ShmError::SyscallError(_, detail) => ClockBoundErrorKind::Syscall(detail), ShmError::SegmentNotInitialized => ClockBoundErrorKind::SegmentNotInitialized, ShmError::SegmentMalformed => ClockBoundErrorKind::SegmentMalformed, ShmError::CausalityBreach => ClockBoundErrorKind::CausalityBreach, @@ -270,7 +270,9 @@ impl From for ClockBoundError { #[derive(Hash, PartialEq, Eq, Clone, Debug)] pub enum ClockBoundErrorKind { - Syscall, + // FIXME: the `detail` static CString is referenced on the Syscall variant. This is a temporary + // implementation until the FFI to C is changed to have the caller allocate memory for it. + Syscall(&'static CStr), SegmentNotInitialized, SegmentMalformed, CausalityBreach, @@ -756,7 +758,7 @@ mod lib_tests { let shm_error = ShmError::SyscallError(errno, detail); // Perform the conversion. let clockbounderror = ClockBoundError::from(shm_error); - assert_eq!(ClockBoundErrorKind::Syscall, clockbounderror.kind); + assert_eq!(ClockBoundErrorKind::Syscall(detail), clockbounderror.kind); assert_eq!(errno, clockbounderror.errno); assert_eq!(detail_string, clockbounderror.detail); } diff --git a/clock-bound/src/vmclock.rs b/clock-bound/src/vmclock.rs index 03864e0..45b19b7 100644 --- a/clock-bound/src/vmclock.rs +++ b/clock-bound/src/vmclock.rs @@ -1,277 +1,5 @@ -use std::ffi::CString; -use tracing::debug; - -use crate::shm::{ClockStatus, ShmError, ShmReader}; -use crate::vmclock::shm_reader::VMClockShmReader; -use nix::sys::time::TimeSpec; +//! VMClock access pub mod shm; pub mod shm_reader; pub mod shm_writer; - -/// TODO: remove this module once the ffi code relies on the `ClockBoundClient` rather than the -/// VMClock struct here. -/// -/// VMClock provides the following capabilities: -/// -/// - Error-bounded timestamps obtained from ClockBound daemon. -/// - Clock disruption signaling via the VMClock. -pub struct VMClock { - clockbound_shm_reader: ShmReader, - vmclock_shm_path: String, - vmclock_shm_reader: Option, -} - -impl VMClock { - /// Open the VMClock shared memory segment and the ClockBound shared memory segment for reading. - /// - /// On error, returns an appropriate `Errno`. If the content of the segment - /// is uninitialized, unparseable, or otherwise malformed, EPROTO will be - /// returned. - #[expect(clippy::missing_errors_doc, reason = "todo")] - #[expect(clippy::missing_panics_doc, reason = "todo")] - pub fn new(clockbound_shm_path: &str, vmclock_shm_path: &str) -> Result { - let clockbound_shm_path = CString::new(clockbound_shm_path).expect("CString::new failed"); - let mut clockbound_shm_reader = ShmReader::new(clockbound_shm_path.as_c_str())?; - let clockbound_snapshot = clockbound_shm_reader.snapshot()?; - - let mut vmclock_shm_reader: Option = None; - if clockbound_snapshot.clock_disruption_support_enabled { - vmclock_shm_reader = Some(VMClockShmReader::new(vmclock_shm_path)?); - } - - Ok(VMClock { - clockbound_shm_reader, - vmclock_shm_path: String::from(vmclock_shm_path), - vmclock_shm_reader, - }) - } - - /// The VMClock equivalent of `clock_gettime()`, but with bound on accuracy. - /// - /// Returns a pair of (earliest, latest) timespec between which current time exists. The - /// interval width is twice the clock error bound (ceb) such that: - /// (earliest, latest) = ((now - ceb), (now + ceb)) - /// The function also returns a clock status to assert that the clock is being synchronized, or - /// free-running, or ... - #[expect(clippy::missing_errors_doc, reason = "todo")] - pub fn now(&mut self) -> Result<(TimeSpec, TimeSpec, ClockStatus), ShmError> { - // Read from the ClockBound shared memory segment. - let clockbound_snapshot = self.clockbound_shm_reader.snapshot()?; - - if self.vmclock_shm_reader.is_none() && clockbound_snapshot.clock_disruption_support_enabled - { - self.vmclock_shm_reader = Some(VMClockShmReader::new(self.vmclock_shm_path.as_str())?); - } - - let (earliest, latest, clock_status) = clockbound_snapshot.now()?; - - if clockbound_snapshot.clock_disruption_support_enabled - && let Some(ref mut vmclock_shm_reader) = self.vmclock_shm_reader - { - // Read from the VMClock shared memory segment. - let vmclock_snapshot = vmclock_shm_reader.snapshot()?; - - // Comparing the disruption marker between the VMClock snapshot and the - // ClockBound snapshot will tell us if the clock status provided by the - // ClockBound daemon is trustworthy. - debug!( - "clock_status: {:?}, vmclock_snapshot.disruption_marker: {:?}, clockbound_snapshot.disruption_marker: {:?}", - clock_status, - vmclock_snapshot.disruption_marker, - clockbound_snapshot.disruption_marker - ); - - if vmclock_snapshot.disruption_marker == clockbound_snapshot.disruption_marker { - // ClockBound's shared memory segment has the latest clock disruption status from - // VMClock and this means the clock status here can be trusted. - return Ok((earliest, latest, clock_status)); - } - // ClockBound has stale clock disruption status and it is not up-to-date with - // VMClock. - - // Override the clock disruption status with ClockStatus::Unknown until - // ClockBound daemon is able to pick up the latest clock disruption status - // from VMClock. - return Ok((earliest, latest, ClockStatus::Unknown)); - } - - debug!("clock_status: {:?}", clock_status); - Ok((earliest, latest, clock_status)) - } -} - -#[cfg(test)] -mod t_lib { - use super::*; - - use crate::shm::{ClockErrorBound, ShmWrite, ShmWriter}; - use std::path::Path; - - use crate::vmclock::shm::{VMClockClockStatus, VMClockShmBody}; - use crate::vmclock::shm_writer::{VMClockShmWrite, VMClockShmWriter}; - /// We make use of tempfile::NamedTempFile to ensure that - /// local files that are created during a test get removed - /// afterwards. - use tempfile::NamedTempFile; - - macro_rules! vmclockshmbody { - () => { - VMClockShmBody { - disruption_marker: 10, - flags: 0_u64, - _padding: [0x00, 0x00], - clock_status: VMClockClockStatus::Unknown, - leap_second_smearing_hint: 0, - tai_offset_sec: 37_i16, - leap_indicator: 0, - counter_period_shift: 0, - counter_value: 0, - counter_period_frac_sec: 0, - counter_period_esterror_rate_frac_sec: 0, - counter_period_maxerror_rate_frac_sec: 0, - time_sec: 0, - time_frac_sec: 0, - time_esterror_nanosec: 0, - time_maxerror_nanosec: 0, - } - }; - } - - /// Helper function to remove files created during unit tests. - fn remove_file_or_directory(path: &str) { - // Busy looping on deleting the previous file, good enough for unit test - let p = Path::new(&path); - while p.exists() { - if p.is_dir() { - std::fs::remove_dir_all(&path).expect("failed to remove file"); - } else { - std::fs::remove_file(&path).expect("failed to remove file"); - } - } - } - - /// Assert that VMClock can be created successfully and now() function successful when - /// clock_disruption_support_enabled is true and a valid file exists at the vmclock_shm_path. - #[test] - fn test_vmclock_now_with_clock_disruption_support_enabled_success() { - let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); - let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); - let clockbound_shm_path = clockbound_shm_temppath.to_str().unwrap(); - remove_file_or_directory(&clockbound_shm_path); - let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); - let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); - let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); - remove_file_or_directory(&vmclock_shm_path); - - // Create and wipe the ClockBound memory segment. - let ceb = ClockErrorBound::new( - TimeSpec::new(1, 2), // as_of - TimeSpec::new(3, 4), // void_after - 123, // bound_nsec - 10, // disruption_marker - 100, // max_drift_ppb - ClockStatus::Synchronized, // clock_status - true, // clock_disruption_support_enabled - ); - - let mut clockbound_shm_writer = - ShmWriter::new(Path::new(&clockbound_shm_path)).expect("Failed to create a ShmWriter"); - clockbound_shm_writer.write(&ceb); - - // Create and write the VMClock memory segment. - let vmclock_shm_body = vmclockshmbody!(); - let mut vmclock_shm_writer = VMClockShmWriter::new(Path::new(&vmclock_shm_path)) - .expect("Failed to create a VMClockShmWriter"); - vmclock_shm_writer.write(&vmclock_shm_body); - - // Create the VMClock, and assert that the creation was successful. - let vmclock_new_result = VMClock::new(&clockbound_shm_path, &vmclock_shm_path); - match vmclock_new_result { - Ok(mut vmclock) => { - // Assert that now() does not return an error. - let now_result = vmclock.now(); - assert!(now_result.is_ok()); - } - Err(_) => { - assert!(false); - } - } - } - - /// Assert that VMClock will fail to be created when clock_disruption_support_enabled - /// is true and no file exists at the vmclock_shm_path. - #[test] - fn test_vmclock_now_with_clock_disruption_support_enabled_failure() { - let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); - let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); - let clockbound_shm_path = clockbound_shm_temppath.to_str().unwrap(); - remove_file_or_directory(&clockbound_shm_path); - let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); - let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); - let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); - remove_file_or_directory(&vmclock_shm_path); - - // Create and wipe the ClockBound memory segment. - let ceb = ClockErrorBound::new( - TimeSpec::new(1, 2), // as_of - TimeSpec::new(3, 4), // void_after - 123, // bound_nsec - 10, // disruption_marker - 100, // max_drift_ppb - ClockStatus::Synchronized, // clock_status - true, // clock_disruption_support_enabled - ); - - let mut clockbound_shm_writer = - ShmWriter::new(Path::new(&clockbound_shm_path)).expect("Failed to create a ShmWriter"); - clockbound_shm_writer.write(&ceb); - - // Create the VMClock, and assert that the creation was successful. - let vmclock_new_result = VMClock::new(&clockbound_shm_path, &vmclock_shm_path); - assert!(vmclock_new_result.is_err()); - } - - /// Assert that VMClock can be created successfully and now() runs successfully - /// when clock_disruption_support_enabled is false and no file exists at the vmclock_shm_path. - #[test] - fn test_vmclock_now_with_clock_disruption_support_not_enabled() { - let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); - let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); - let clockbound_shm_path = clockbound_shm_temppath.to_str().unwrap(); - remove_file_or_directory(&clockbound_shm_path); - let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); - let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); - let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); - remove_file_or_directory(&vmclock_shm_path); - - // Create and wipe the ClockBound memory segment. - let ceb = ClockErrorBound::new( - TimeSpec::new(1, 2), // as_of - TimeSpec::new(3, 4), // void_after - 123, // bound_nsec - 10, // disruption_marker - 100, // max_drift_ppb - ClockStatus::Synchronized, // clock_status - false, // clock_disruption_support_enabled - ); - - let mut clockbound_shm_writer = - ShmWriter::new(Path::new(&clockbound_shm_path)).expect("Failed to create a ShmWriter"); - clockbound_shm_writer.write(&ceb); - - // Create the VMClock, and assert that the creation was successful. - // There should be no error even though there is no file located at vmclock_shm_path. - let vmclock_new_result = VMClock::new(&clockbound_shm_path, &vmclock_shm_path); - match vmclock_new_result { - Ok(mut vmclock) => { - // Assert that now() does not return an error. - let now_result = vmclock.now(); - assert!(now_result.is_ok()); - } - Err(_) => { - assert!(false); - } - } - } -} From f4f55543345066266d852f32d9ccf0bda25f36e3 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Mon, 10 Nov 2025 12:48:40 -0800 Subject: [PATCH 096/177] Explicit representation of ClockStatus (#113) This patch fixes a wrong assumption made on the representation of the ClockStatus enum, and consequently, the data layout of the ClockBound shared memory segment. Field-less enum are *likely* to be represented by an `int` on most platforms (4 bytes on 64 bit x86 and aarch), but not always guaranteed. Different compilers may produce code against different ABI and/or passing flags that violate the assumption. This patch makes it explicit to remove this ambiguity. Co-authored-by: Julien Ridoux --- clock-bound/src/shm.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 4788b32..0939cd0 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -87,7 +87,11 @@ impl fmt::Display for ShmError { impl Error for ShmError {} /// Definition of mutually exclusive clock status exposed to the reader. -#[repr(C)] +/// +/// Note the data layout is explicitly set to i32. This enum is a field of the ClockBound shared +/// memory segment, and its representation *may* be different for C code compiled with specific +/// flags. Making it explicit removes this risk and ambiguity. +#[repr(i32)] #[derive(Debug, Copy, Clone, PartialEq)] pub enum ClockStatus { /// The status of the clock is unknown. From 5006f562b2ba134708b4ce43750e8de0b5446178 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Mon, 10 Nov 2025 15:56:21 -0500 Subject: [PATCH 097/177] Implement a state machine for ClockAdjust (#106) Our ClockAdjust component previously was intended to take a frequency measurement of the TSC, and apply that directly into the kernel if the kernel clocksource used TSC. This had its own problems with if the user changes clocksource, so we chose to move towards a more dynamic approach of frequency calculations. Unfortunately, this dynamic approach requires certain conditions to be met in order to make proper measurement and calculation of frequency, so we designed a state machine to enforce these guarantees. This state machine contains a set of finite states (ignoring the parameters and continuous variables within), which the ClockAdjust component will transition between by using the duration since last state change to determine if it should take some action, e.g. scheduling state changes. Many state changes involve mutations of the kernel clock as well, so those must be performed. We implement this state machine as a submodule of clock_adjust. It contains an enum with a variant and corresponding struct for each of these states, and implements a function `transition` on each of those inner structs in order to perform the transitions (which return the new state to be used). Additionally, we add the frequency estimating implementation in that same module as it is used for the transition between gathering of a snapshot and the actual clock adjustment.. all is explained in better detail in the doc comments themselves. For now, this simply introduces the module and implementation, but does not use it. - Also, minor changes to make SystemClockMeasurement available across crate, and NtpAdjTime trait mocking more usable --- .../src/daemon/clock_state/clock_adjust.rs | 2 + .../clock_state/clock_adjust/ntp_adjtime.rs | 30 + .../clock_state/clock_adjust/state_machine.rs | 731 ++++++++++++++++++ clock-bound/src/daemon/event.rs | 14 +- 4 files changed, 766 insertions(+), 11 deletions(-) create mode 100644 clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index b36a92e..623449b 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -11,6 +11,8 @@ mod ntp_adjtime; pub use ntp_adjtime::{ KAPIClockAdjuster, NoopClockAdjuster, NtpAdjTime, NtpAdjTimeError, NtpAdjTimeExt, }; +#[expect(unused)] +mod state_machine; pub struct ClockAdjuster { ntp_adjtime: T, diff --git a/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs b/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs index 24759ad..7773fea 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs @@ -51,6 +51,36 @@ pub trait NtpAdjTime { #[cfg(test)] impl NtpAdjTimeExt for MockNtpAdjTime {} +#[cfg(test)] +mod mock_ntp_adj_time_ext { + use super::*; + mockall::mock! { + pub NtpAdjTimeExt {} + impl NtpAdjTimeExt for NtpAdjTimeExt { + fn adjust_clock( + &self, + phase_correction: Duration, + skew: Skew, + ) -> Result; + fn apply_phase_correction(&self, phase_correction: Duration) -> Result; + fn apply_frequency_correction( + &self, + frequency_correction: Skew, + ) -> Result; + fn step_clock(&self, phase_correction: Duration) -> Result; + fn read_adjtime(&self) -> Result; + } + } + impl NtpAdjTime for MockNtpAdjTimeExt { + fn ntp_adjtime(&self, _tx: &mut Timex) -> i32 { + unimplemented!("mocks shouldn't call this") + } + } +} + +#[cfg(test)] +pub use mock_ntp_adj_time_ext::MockNtpAdjTimeExt; + pub trait NtpAdjTimeExt: NtpAdjTime { /// Performs an adjustment of the clock, to apply the given phase correction /// and skew values, in a single system call. diff --git a/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs b/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs new file mode 100644 index 0000000..87f494f --- /dev/null +++ b/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs @@ -0,0 +1,731 @@ +//! `ClockBound` tries to make `CLOCK_REALTIME` follow UTC as closely as possible. +//! It has its own notion of a "best internal clock" output by the `ClockSyncAlgorithm` and selector, +//! which is expressed as a set of `ClockParameters`, and reading that clock is done via +//! using those `ClockParameters` + corresponding TSC reads, similar to `clock_gettime` VDSO implementation. +//! To make `CLOCK_REALTIME` follow UTC, we make it follow `ClockBound` internal clock by steering it via +//! frequency and phase corrections. +//! +//! Frequency corrections are applied via `ntp_adjtime` - the frequency to apply is calculated by comparing +//! two `ClockSnapshot`s with timestamps of `CLOCK_REALTIME` along with `ClockBound` internal clock, calculating the relative +//! frequency, and applying the relative frequency change to the currently used `CLOCK_REALTIME` frequency (gathered +//! via `ntp_adjtime` as part of the `ClockSnapshot`. +//! +//! Phase corrections are applied via `ntp_adjtime` as well, and are a simpler task - we simply use the old trick +//! of interleaved reads to compare the offset of two clocks (offset of `ClockBound` w.r.t `CLOCK_REALTIME`) and apply +//! that as the phase correction to `CLOCK_REALTIME`, using the PLL slewing method in kernel. An alternative approach +//! to fixing phase corrections could involve temporary slewing of the clock frequency, but can risk overshooting, or +//! leaving the system in a bad state if in the middle of a slew and the daemon is terminated. +//! +//! We have to use a decently complex state machine to manage our clock adjustments - our method for frequency estimate +//! can be impacted by the PLL effect itself, which we unfortunately cannot perfectly avoid or compensate for (since the amount +//! the PLL has slewed the clock cannot be grabbed directly beyond a second's granularity, and estimation is unreliable). Thus, +//! we make it explicit that the snapshots of the clock we make are NOT overlapping with any PLL slew, by halting the slew. +//! +//! What we end up getting is a stable frequency calculation (which should not change often unless conditions change quickly) with +//! its error being solely due to `ClockParameters` period error itself as well as measurement error from `ClockSnapshot`s RTT of TSC and `ClockRealTime` +//! reads, and phase correction that converges and does not overshoot. +//! +//!```text +//! Clock Adjustment State Machine +//! ┌─────────────────┐ ┌─────────────────┐ +//! │ Initialized │ | Disrupted | (any other state can immediately transition to Disrupted) +//! │ │ | | +//! │ step clock + │ | reset clock | +//! │ halt PLL │ | adjustments | +//! │ (unreliable) │ | (unreliable) | +//! └─────────────────┘ └─────────────────┘ +//! | | +//! | | +//! | | +//! v | +//! ┌──────────────────────────┐ | +//! │ InitialPhaseCorrectHalted│ | +//! │ │ <────────┘ +//! │ halt PLL │ +//! │ (unreliable) │ +//! └──────────────────────────┘ +//! | +//! | +//! v +//! ┌──────────────────────────┐ +//! │ InitialSnapshotRetrieved │ +//! │ │ +//! │ take snapshot B + │ +//! │ calc frequency + │ +//! │ apply corrections │ +//! │ (unreliable) │ +//! └──────────────────────────┘ +//! | +//! | +//! v +//! ┌─────────────────┐ +//! │ ClockAdjusted │ +//! │ │ +//! ┌───────────│ apply freq + │<────────┐ +//! │ │ phase correct │ │ +//! │ │ (reliable) │ │ +//! │ └─────────────────┘ │ +//! │ | +//! │ | +//! │ | +//! v | +//! ┌──────────────────┐ ┌──────────────────┐ +//! │PhaseCorrectHalted│ │ SnapshotRetrieved│ +//! │ │ │ │ +//! │ halt PLL │ │ take snapshot B +│ +//! │ (reliable) │ │ calc frequency + │ +//! | │ | apply corrections│ +//! | | │ (reliable) │ +//! └──────────────────┘ └──────────────────┘ +//! │ ^ +//! │ │ +//! └────────────────────────────────────────┘ +//! ``` +use tracing::debug; + +use crate::daemon::{ + clock_parameters::ClockParameters, + event::SystemClockMeasurement, + io::tsc::ReadTscImpl, + time::{ + ClockExt, Duration, + clocks::{ClockBound, RealTime}, + timex::Timex, + tsc::Skew, + }, +}; + +use super::ntp_adjtime::NtpAdjTimeExt; + +#[derive(Debug, PartialEq)] +pub(super) enum State { + /// State indicating that the clock has been disrupted. + Disrupted(Disrupted), + /// Our initial state. + /// When we get a new event (which require some `ClockParameters`), + /// we step the clock, and transition out of this state, to `InitialPhaseCorrectHalted`. + /// + /// The clock is not yet reliable. + Initialized(Initialized), + /// State after we transition from `Initializing`. + /// Any Phase Correction supplied to the kernel has been halted, so that we can + /// take a snapshot of `CLOCK_REALTIME` outside of any slew, to use for our frequency calculation. + /// After `PHASE_CORRECTION_HALT_DURATION`, we take a `ClockSnapshot` (Snapshot A) and transition to `InitialSnapshotRetrieved`. + /// + /// The clock is not yet reliable. + InitialPhaseCorrectHalted(InitialPhaseCorrectHalted), + /// State after we have taken an initial `ClockSnapshot` to be used for a relative frequency estimation + /// (getting the frequency of `CLOCK_REALTIME` w.r.t. `ClockBound` internal clock, + /// so that we may adjust `CLOCK_REALTIME` to follow it) + /// + /// After `INITIAL_SNAPSHOT_A_DURATION`, we take another `ClockSnapshot` (Snapshot B), which we can then use for + /// our relative frequency calculation. We additionally calculate the offset of `CLOCK_REALTIME` w.r.t `ClockBound` + /// internal clock. With these two, we adjust the clock, and transition to `ClockAdjusted` state. + /// + /// The clock is not yet reliable. + InitialSnapshotRetrieved(InitialSnapshotRetrieved), + /// State after we have applied a frequency and phase correction to the clock. + /// + /// After `PHASE_CORRECTING_DURATION`, we have corrected a good portion of the phase offset using PLL correction. + /// We halt the PLL correction and then transition to `PhaseCorrectHalted`, so that we can take another measurement + /// of the frequency. From here, we are in steady state, and cycle through the states + /// `ClockAdjusted` -> `PhaseCorrectHalted` -> `SnapshotRetrieved` -> `ClockAdjusted` -> [...]. + /// + /// The clock is now reliable. + ClockAdjusted(ClockAdjusted), + /// State after we transition from `ClockAdjusted`. + /// Any Phase Correction supplied to the kernel has been halted, so that we can + /// take a snapshot of `CLOCK_REALTIME` outside of any slew, to use for our frequency calculation. + /// After `PHASE_CORRECTION_HALT_DURATION`, we take a `ClockSnapshot` (Snapshot A) and transition to `SnapshotRetrieved`. + /// + /// The clock is now reliable. + PhaseCorrectHalted(PhaseCorrectHalted), + /// State after we have taken an initial `ClockSnapshot` to be used for a relative frequency estimation + /// (getting the frequency of `CLOCK_REALTIME` w.r.t. `ClockBound` internal clock, + /// so that we may adjust `CLOCK_REALTIME` to follow it) + /// + /// After `SNAPSHOT_A_DURATION` (longer than `INITIAL_SNAPSHOT_A_DURATION`, to allow for a longer term frequency measurement), + /// we take another `ClockSnapshot` (Snapshot B), which we can then use for + /// our relative frequency calculation. We additionally calculate the offset of `CLOCK_REALTIME` w.r.t `ClockBound` + /// internal clock. With these two, we adjust the clock, and transition to `ClockAdjusted` state. + /// + /// The clock is now reliable. + SnapshotRetrieved(SnapshotRetrieved), +} +#[derive(Debug, PartialEq)] +pub(super) struct Disrupted; +impl Disrupted { + #[must_use] + #[allow(clippy::unused_self)] + pub(super) fn transition(&self, ntp_adjtime: &impl NtpAdjTimeExt) -> InitialPhaseCorrectHalted { + // Unwrap safety: If we can't adjust the clock, better to panic, + // else we are outside the expectations of our state machine + ntp_adjtime + .apply_phase_correction(Duration::from_secs(0)) + .unwrap(); + InitialPhaseCorrectHalted { + instant: tokio::time::Instant::now(), + } + } +} +#[derive(Debug, PartialEq)] +pub(super) struct Initialized; +impl Initialized { + #[must_use] + #[allow(clippy::unused_self)] + pub(super) fn transition( + &self, + ntp_adjtime: &impl NtpAdjTimeExt, + clock_params: &ClockParameters, + ) -> InitialPhaseCorrectHalted { + // Unwrap safety: If we can't adjust the clock, better to panic, + // else we are outside the expectations of our state machine + ntp_adjtime + .apply_phase_correction(Duration::from_secs(0)) + .unwrap(); + let clockbound_clock = ClockBound::new(clock_params.clone(), ReadTscImpl); + // TODO: implement multiple attempts in case of latency increase + let offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); + // Unwrap safety: If we can't adjust the clock, better to panic, + // else we are outside the expectations of our state machine + ntp_adjtime.step_clock(offset_and_rtt.offset()).unwrap(); + InitialPhaseCorrectHalted { + instant: tokio::time::Instant::now(), + } + } +} +#[derive(Debug, PartialEq)] +pub(super) struct InitialPhaseCorrectHalted { + pub(super) instant: tokio::time::Instant, +} +impl InitialPhaseCorrectHalted { + #[must_use] + #[allow(clippy::unused_self)] + pub(super) fn transition(&self, ntp_adjtime: &impl NtpAdjTimeExt) -> InitialSnapshotRetrieved { + debug!( + "ClockAdjustmentState: InitialPhaseCorrectHalted now transitioning to InitialSnapshotRetrieved" + ); + InitialSnapshotRetrieved { + instant: tokio::time::Instant::now(), + snapshot: ClockSnapshot::retrieve(ntp_adjtime), + } + } +} +#[derive(Debug, PartialEq)] +pub(super) struct InitialSnapshotRetrieved { + pub(super) instant: tokio::time::Instant, + pub(super) snapshot: ClockSnapshot, +} +impl InitialSnapshotRetrieved { + #[must_use] + #[allow(clippy::unused_self)] + pub(super) fn transition( + &self, + ntp_adjtime: &impl NtpAdjTimeExt, + clock_params: &ClockParameters, + snapshot: &ClockSnapshot, + ) -> ClockAdjusted { + let new_snapshot = ClockSnapshot::retrieve(ntp_adjtime); + let freq = calculate_frequency_correction(clock_params, snapshot, &new_snapshot); + let clockbound_clock = ClockBound::new(clock_params.clone(), ReadTscImpl); + // TODO: implement multiple attempts in case of latency increase + let offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); + // Unwrap safety: If we can't adjust the clock, better to panic, + // else we are outside the expectations of our state machine + ntp_adjtime + .adjust_clock(offset_and_rtt.offset(), freq) + .unwrap(); + debug!("ClockAdjustmentState: InitialSnapshotA now transitioning to PhaseCorrecting"); + ClockAdjusted { + instant: tokio::time::Instant::now(), + } + } +} +#[derive(Debug, PartialEq)] +pub(super) struct ClockAdjusted { + pub(super) instant: tokio::time::Instant, +} +impl ClockAdjusted { + #[must_use] + #[allow(clippy::unused_self)] + pub(super) fn transition(&self, ntp_adjtime: &impl NtpAdjTimeExt) -> PhaseCorrectHalted { + // Unwrap safety: If we can't adjust the clock, better to panic, + // else we are outside the expectations of our state machine + ntp_adjtime + .apply_phase_correction(Duration::from_secs(0)) + .unwrap(); + debug!("ClockAdjustmentPhaseCorrecting now transitioning to PhaseCorrectHalt"); + PhaseCorrectHalted { + instant: tokio::time::Instant::now(), + } + } +} +#[derive(Debug, PartialEq)] +pub(super) struct PhaseCorrectHalted { + pub(super) instant: tokio::time::Instant, +} +impl PhaseCorrectHalted { + #[must_use] + #[allow(clippy::unused_self)] + pub(super) fn transition(&self, ntp_adjtime: &impl NtpAdjTimeExt) -> SnapshotRetrieved { + debug!("ClockAdjustmentPhaseCorrectHalted now transitioning to SnapshotRetrieved"); + SnapshotRetrieved { + instant: tokio::time::Instant::now(), + snapshot: ClockSnapshot::retrieve(ntp_adjtime), + } + } +} +#[derive(Debug, PartialEq)] +pub(super) struct SnapshotRetrieved { + pub(super) instant: tokio::time::Instant, + pub(super) snapshot: ClockSnapshot, +} +impl SnapshotRetrieved { + #[must_use] + #[allow(clippy::unused_self)] + pub(super) fn transition( + &self, + ntp_adjtime: &impl NtpAdjTimeExt, + clock_params: &ClockParameters, + snapshot: &ClockSnapshot, + ) -> ClockAdjusted { + let new_snapshot = ClockSnapshot::retrieve(ntp_adjtime); + let freq = calculate_frequency_correction(clock_params, snapshot, &new_snapshot); + let clockbound_clock = ClockBound::new(clock_params.clone(), ReadTscImpl); + // TODO: implement multiple attempts in case of latency increase + let offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); + // Unwrap safety: If we can't adjust the clock, better to panic, + // else we are outside the expectations of our state machine + ntp_adjtime + .adjust_clock(offset_and_rtt.offset(), freq) + .unwrap(); + debug!("ClockAdjustmentState: SnapshotA now transitioning to PhaseCorrecting"); + ClockAdjusted { + instant: tokio::time::Instant::now(), + } + } +} + +/// Based on old and new snapshots of `CLOCK_REALTIME` timestamp and frequency and corresponding TSC reads, +/// alongside `ClockParameters`, we calculate a frequency correction (`Skew`) to supply to our `adjust_clock` routine. +/// +/// Our goal is to align `CLOCK_REALTIME` frequency with the frequency of the clock determined by `ClockBound`. +/// We use `ClockBound` clock as a frequency standard for `CLOCK_REALTIME`. +/// Let t = some instant +/// Let R(t) = `CLOCK_REALTIME` reading at t +/// Let C(t) = `ClockBound` clock estimate of UTC at t +/// Let F(t) = `CLOCK_REALTIME` frequency at t +/// +/// We take snapshots of the clock which allow us to construct the following timestamps at T(old) and T(new) +/// ```text +/// R(old) = `CLOCK_REALTIME` at old +/// C(old) = `ClockBound` at old +/// R(new) = `CLOCK_REALTIME` at new +/// C(new) = `ClockBound` at new +/// F(old) = `CLOCK_REALTIME` frequency correction at old +/// F(new) is not used, since we are calculating based on the interval between `old` and `new` +/// ``` +/// +/// To have the frequency of `CLOCK_REALTIME` match up with `ClockBound`, we calculate the relative frequency correction +/// w.r.t `F(old)` that would have `R(new) - R(old)` == `C(new) - C(old)`, and return that value. +/// +/// Example 1: +/// ```text +/// C(old) = 0.00, C(new) = 1.00 +/// R(old) = 0.00, R(new) = 1.00, F(old) = +10ppm = 1.000_010 +/// Frequency to set = F(old) * (C(new) - C(old)) / ((R(new) - R(old)) +/// Frequency to set = 1.000_010 * (1.00 - 0.00) / (1.00 - 0.00) = 1.000_010 (no change since clock rates are aligned) +/// ``` +/// Example 2: +/// ```text +/// C(old) = 0.00, C(new) = 1.01 +/// R(old) = 0.00, R(new) = 1.00, F(old) = +10ppm = 1.000_010 +/// Frequency to set = 1.000_010 * (1.01 - 0.00) / (1.00 - 0.00) = 1.000_010 +/// Frequency to set = 1.000_010 * 1.01 = ~1.010_010 (+1% since `ClockBound` was 1% faster over that interval) +/// ``` +/// +/// Note - this calculation is naively based on a linear interpolation of the two snapshots. If that relationship is non-linear, +/// it may not be perfectly precise. Sources of non-linearity include: +/// * Phase correction due to an ongoing slew (e.g. `offset` supplied to the kernel) +/// * This may be possible to mitigate if we are able to calculate this phase correction and adjust our `R(new) - R(old)` calculation based on it +/// * Any frequency change between the two snapshots (if another entity modifies `freq` in kernel while `ClockBound` is running) +/// +/// We at least partially mitigate the `PhaseCorrection` by reading the change in offset between snapshots, and modifying our `CLOCK_REALTIME` +/// interval by that amount. +/// But if another frequency or phase correction occurred between these two snapshots (another time service or operator?), all bets are off. +#[allow( + clippy::cast_precision_loss, + reason = "diff in two snapshot TSC values should not be susceptible to significant enough loss of precision to hurt us" +)] +fn calculate_frequency_correction( + clock_params: &ClockParameters, + old_snapshot: &ClockSnapshot, + new_snapshot: &ClockSnapshot, +) -> Skew { + let SystemClockMeasurement { + tsc: old_tsc_value, + system_time: old_realtime_ts, + } = old_snapshot.system_clock; + let SystemClockMeasurement { + tsc: new_tsc_value, + system_time: new_realtime_ts, + } = new_snapshot.system_clock; + + let old_freq = old_snapshot.kernel_state.freq(); + let diff_clock_realtime = new_realtime_ts - old_realtime_ts; + let diff_clockbound = Duration::from_seconds_f64( + (new_tsc_value.get() - old_tsc_value.get()) as f64 * clock_params.period.get(), + ); + let clockbound_rate_wrt_clock_realtime = + diff_clockbound.get() as f64 / diff_clock_realtime.get() as f64; + + let old_frequency_clock_realtime = old_freq.get() + 1.0; + let fractional_correction = + (clockbound_rate_wrt_clock_realtime * old_frequency_clock_realtime) - 1.0; + Skew::from_ppm(fractional_correction * 1e6) +} + +/// Snapshot of `CLOCK_REALTIME` at some point in time, including the frequency correction +/// via `ntp_adjtime` read-only call and the TSC value aligned with the approximate `CLOCK_REALTIME` +/// timestamp (only aligned via interleaved reads, and the TSC value is an approximation by getting the midpoint of those reads). +/// +/// Notably, the `clock_realtime_frequency_correction` may not be reliable if any other service or operator +/// modifies the `freq` value in kernel... +#[derive(Debug, PartialEq, Clone)] +pub(super) struct ClockSnapshot { + pub(super) system_clock: SystemClockMeasurement, + pub(super) kernel_state: Timex, +} +impl ClockSnapshot { + pub fn retrieve(ntp_adjtime: &impl NtpAdjTimeExt) -> Self { + // Unwrap safety: retrieving adjtime parameters should succeed regardless of + // timex status. If we received an actual error from the system call, we have + // nothing better we can do here + let kernel_state = ntp_adjtime.read_adjtime().unwrap(); + let system_clock = SystemClockMeasurement::now(); + Self { + system_clock, + kernel_state, + } + } +} + +#[cfg(test)] +mod test { + use crate::daemon::{ + clock_state::clock_adjust::{NtpAdjTimeError, ntp_adjtime::MockNtpAdjTimeExt}, + time::{ + Instant, TscCount, + tsc::{Frequency, Period}, + }, + }; + + use super::*; + + fn test_clock_parameters() -> ClockParameters { + ClockParameters { + tsc_count: TscCount::new(0), + period: Period::from_frequency(Frequency::from_hz(1_000_000.0)), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period_max_error: Period::from_seconds(0.0), + } + } + + fn test_clock_snapshot() -> ClockSnapshot { + ClockSnapshot { + system_clock: SystemClockMeasurement { + system_time: Instant::from_secs(0), + tsc: TscCount::new(0), + }, + kernel_state: Timex::retrieve(), + } + } + + #[test] + fn disrupted_to_initial_phase_correct_halted() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_apply_phase_correction() + .once() + .return_once(move |_: Duration| Ok(Timex::retrieve())); + let disrupted = Disrupted; + let _ = disrupted.transition(&mock_ntp_adjtime); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn disrupted_to_initial_phase_correct_halted_panic_on_failed_adjtime() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_apply_phase_correction() + .once() + .return_once(move |_: Duration| Err(NtpAdjTimeError::BadState(1))); + let disrupted = Disrupted; + let _ = disrupted.transition(&mock_ntp_adjtime); + } + + #[test] + fn initialized_to_initial_phase_correct_halted() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_apply_phase_correction() + .once() + .return_once(move |_: Duration| Ok(Timex::retrieve())); + mock_ntp_adjtime + .expect_step_clock() + .once() + .return_once(move |_: Duration| Ok(Timex::retrieve())); + let initialized = Initialized; + let _ = initialized.transition(&mock_ntp_adjtime, &test_clock_parameters()); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn initialized_to_initial_phase_correct_halted_panic_on_phase_correct_halt_failure() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_apply_phase_correction() + .once() + .return_once(move |_: Duration| Err(NtpAdjTimeError::BadState(1))); + mock_ntp_adjtime + .expect_step_clock() + .never() + .return_once(move |_: Duration| Ok(Timex::retrieve())); + let initialized = Initialized; + let _ = initialized.transition(&mock_ntp_adjtime, &test_clock_parameters()); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn initialized_to_initial_phase_correct_halted_panic_on_step_clock_failure() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_apply_phase_correction() + .once() + .return_once(move |_: Duration| Ok(Timex::retrieve())); + mock_ntp_adjtime + .expect_step_clock() + .once() + .return_once(move |_: Duration| Err(NtpAdjTimeError::BadState(1))); + let initialized = Initialized; + let _ = initialized.transition(&mock_ntp_adjtime, &test_clock_parameters()); + } + + #[test] + fn initial_phase_correct_halted_to_initial_snapshot_retrieved() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Ok(Timex::retrieve())); + let initial_phase_correct_halted = InitialPhaseCorrectHalted { + instant: tokio::time::Instant::now(), + }; + let _ = initial_phase_correct_halted.transition(&mock_ntp_adjtime); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn initial_phase_correct_halted_to_initial_snapshot_retrieved_panic_on_fail_adjtime() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Err(NtpAdjTimeError::BadState(1))); + let initial_phase_correct_halted = InitialPhaseCorrectHalted { + instant: tokio::time::Instant::now(), + }; + let _ = initial_phase_correct_halted.transition(&mock_ntp_adjtime); + } + + #[test] + fn initial_snapshot_retrieved_to_clock_adjusted() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Ok(Timex::retrieve())); + mock_ntp_adjtime + .expect_adjust_clock() + .once() + .return_once(move |_: Duration, _: Skew| Ok(Timex::retrieve())); + let initial_snapshot_retrieved = InitialSnapshotRetrieved { + instant: tokio::time::Instant::now(), + snapshot: test_clock_snapshot(), + }; + let _ = initial_snapshot_retrieved.transition( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn initial_snapshot_retrieved_to_clock_adjusted_panic_on_failed_retrieve() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Err(NtpAdjTimeError::BadState(1))); + mock_ntp_adjtime + .expect_adjust_clock() + .never() + .return_once(move |_: Duration, _: Skew| Ok(Timex::retrieve())); + let initial_snapshot_retrieved = InitialSnapshotRetrieved { + instant: tokio::time::Instant::now(), + snapshot: test_clock_snapshot(), + }; + let _ = initial_snapshot_retrieved.transition( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn initial_snapshot_retrieved_to_clock_adjusted_panic_on_failed_adjustment() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Ok(Timex::retrieve())); + mock_ntp_adjtime + .expect_adjust_clock() + .once() + .return_once(move |_: Duration, _: Skew| Err(NtpAdjTimeError::BadState(1))); + let initial_snapshot_retrieved = InitialSnapshotRetrieved { + instant: tokio::time::Instant::now(), + snapshot: test_clock_snapshot(), + }; + let _ = initial_snapshot_retrieved.transition( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } + + #[test] + fn clock_adjusted_to_phase_correct_halted() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_apply_phase_correction() + .once() + .return_once(move |_: Duration| Ok(Timex::retrieve())); + let clock_adjusted = ClockAdjusted { + instant: tokio::time::Instant::now(), + }; + let _ = clock_adjusted.transition(&mock_ntp_adjtime); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn clock_adjusted_to_phase_correct_halted_panic_on_failed_phase_correct_halt() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_apply_phase_correction() + .once() + .return_once(move |_: Duration| Err(NtpAdjTimeError::BadState(1))); + let clock_adjusted = ClockAdjusted { + instant: tokio::time::Instant::now(), + }; + let _ = clock_adjusted.transition(&mock_ntp_adjtime); + } + + #[test] + fn phase_correct_halted_to_snapshot_retrieved() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Ok(Timex::retrieve())); + let phase_correct_halted = PhaseCorrectHalted { + instant: tokio::time::Instant::now(), + }; + let _ = phase_correct_halted.transition(&mock_ntp_adjtime); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn phase_correct_halted_to_snapshot_retrieved_panic_on_failed_retrieve() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Err(NtpAdjTimeError::BadState(1))); + let phase_correct_halted = PhaseCorrectHalted { + instant: tokio::time::Instant::now(), + }; + let _ = phase_correct_halted.transition(&mock_ntp_adjtime); + } + + #[test] + fn snapshot_retrieved_to_clock_adjusted() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Ok(Timex::retrieve())); + mock_ntp_adjtime + .expect_adjust_clock() + .once() + .return_once(move |_: Duration, _: Skew| Ok(Timex::retrieve())); + let snapshot_retrieved = SnapshotRetrieved { + instant: tokio::time::Instant::now(), + snapshot: test_clock_snapshot(), + }; + let _ = snapshot_retrieved.transition( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn snapshot_retrieved_to_clock_adjusted_panic_on_failed_retrieve() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Err(NtpAdjTimeError::BadState(1))); + mock_ntp_adjtime + .expect_adjust_clock() + .never() + .return_once(move |_: Duration, _: Skew| Ok(Timex::retrieve())); + let snapshot_retrieved = SnapshotRetrieved { + instant: tokio::time::Instant::now(), + snapshot: test_clock_snapshot(), + }; + let _ = snapshot_retrieved.transition( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } + + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + fn snapshot_retrieved_to_clock_adjusted_panic_on_failed_adjustment() { + let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); + mock_ntp_adjtime + .expect_read_adjtime() + .once() + .return_once(move || Ok(Timex::retrieve())); + mock_ntp_adjtime + .expect_adjust_clock() + .once() + .return_once(move |_: Duration, _: Skew| Err(NtpAdjTimeError::BadState(1))); + let snapshot_retrieved = SnapshotRetrieved { + instant: tokio::time::Instant::now(), + snapshot: test_clock_snapshot(), + }; + let _ = snapshot_retrieved.transition( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } +} diff --git a/clock-bound/src/daemon/event.rs b/clock-bound/src/daemon/event.rs index b907e7e..2d57d58 100644 --- a/clock-bound/src/daemon/event.rs +++ b/clock-bound/src/daemon/event.rs @@ -7,7 +7,8 @@ pub use ntp::{Ntp, NtpData, Stratum, TryFromU8Error, ValidStratumLevel}; mod phc; pub use phc::{Phc, PhcData}; -use crate::daemon::time::{TscCount, TscDiff}; +use crate::daemon::io::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; +use crate::daemon::time::{Clock, TscCount, TscDiff, clocks::RealTime}; /// A time synchronization event handled by ClockBound pub enum Event { @@ -37,7 +38,6 @@ pub trait TscRtt { } /// Struct containing a system clock read and a TSC read -#[cfg(feature = "test-side-by-side")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SystemClockMeasurement { /// The system clock read @@ -46,21 +46,13 @@ pub struct SystemClockMeasurement { pub tsc: TscCount, } -#[cfg(feature = "test-side-by-side")] impl SystemClockMeasurement { /// Create a new [`SystemClockMeasurement`] - #[expect(clippy::missing_panics_doc, reason = "unwrap won't panic")] - #[expect(clippy::cast_possible_wrap)] pub fn now() -> Self { - use crate::daemon::io::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; - use crate::daemon::time::Instant; let pre = read_timestamp_counter_begin(); - let now = std::time::SystemTime::now(); + let system_time = RealTime.get_time(); let post = read_timestamp_counter_end(); - - let now = now.duration_since(std::time::UNIX_EPOCH).unwrap(); let tsc = pre.midpoint(post); - let system_time = Instant::from_nanos(now.as_nanos() as i128); Self { system_time, tsc: TscCount::new(tsc.into()), From 3fdf7006819e93b4ac1f404c3e5cddda18576dfc Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 10 Nov 2025 17:40:38 -0500 Subject: [PATCH 098/177] [io::ntp] clear socket before each sample (#118) This is one half of the solution on io NTP correctness. If the NTP transaction times out for any reason it is possible for the reply to eventually make its way back to the socket at a later attempt. We should clear any messages in the socket before making a fresh request. --- clock-bound/src/daemon/io/link_local.rs | 5 +- clock-bound/src/daemon/io/ntp.rs | 2 + clock-bound/src/daemon/io/ntp/socket_ext.rs | 52 +++++++++++++++++++++ clock-bound/src/daemon/io/ntp_source.rs | 8 +++- 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 clock-bound/src/daemon/io/ntp/socket_ext.rs diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index ae93d8e..7146b80 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -19,7 +19,7 @@ use super::{ClockDisruptionEvent, ControlRequest}; use crate::daemon::{ async_ring_buffer, event::{self, NtpData}, - io::ntp::packet::Timestamp, + io::ntp::{packet::Timestamp, socket_ext::SocketExt}, selected_clock::SelectedClockSource, time::tsc::TscCount, }; @@ -38,6 +38,8 @@ pub enum LinkLocalError { Timeout(#[from] time::error::Elapsed), #[error("TSC order failure. tsc_pre: {pre}. tsc_post: {post}")] TscOrder { pre: u64, post: u64 }, + #[error("IO failure on socket clear")] + SocketClear(#[source] io::Error), } /// Contains the data needed to run the link local runner. @@ -88,6 +90,7 @@ impl LinkLocal { /// collected the NTP sample we construct the `Event` and push that event through /// to the ring buffer. async fn sample(&mut self) -> Result { + self.socket.clear().map_err(LinkLocalError::SocketClear)?; let (refid, stratum) = self.selected_clock.get(); let packet = Packet::builder() .transmit_timestamp(Timestamp::new(0)) diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index 6711846..53ece1d 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -6,6 +6,8 @@ use tokio::time::Duration; pub mod packet; pub use packet::{Fec2V1Value as DaemonInfo, Packet}; +pub mod socket_ext; + use crate::daemon::{async_ring_buffer, event}; pub const LINK_LOCAL_BURST_DURATION: Duration = Duration::from_secs(1); diff --git a/clock-bound/src/daemon/io/ntp/socket_ext.rs b/clock-bound/src/daemon/io/ntp/socket_ext.rs new file mode 100644 index 0000000..bedfcaf --- /dev/null +++ b/clock-bound/src/daemon/io/ntp/socket_ext.rs @@ -0,0 +1,52 @@ +//! Socket command extensions + +pub trait SocketExt { + /// Clear the buffer of the socket + /// + /// # Errors + /// Returns an error if the underlying socket returns any error + /// other than `WouldBlock` + fn clear(&self) -> Result<(), std::io::Error>; +} + +impl SocketExt for tokio::net::UdpSocket { + fn clear(&self) -> Result<(), std::io::Error> { + // size doesn't matter if not actually reading + // udp packets will be truncated and excess dropped + let mut buf = [0u8; 48]; + loop { + match self.try_recv(&mut buf) { + Ok(_) => (), + Err(e) => { + if e.kind() == std::io::ErrorKind::WouldBlock { + break; + } + return Err(e); + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn clear() { + let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let port = socket.local_addr().unwrap().port(); + let tx_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let buffer = [0xFFu8; 48]; + tx_socket + .send_to(&buffer, format!("127.0.0.1:{}", port)) + .await + .unwrap(); + socket.clear().unwrap(); + let mut buf = [0u8; 48]; + let err = socket.try_recv(&mut buf).unwrap_err(); + + assert_eq!(err.kind(), std::io::ErrorKind::WouldBlock); + } +} diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 0d7e034..0dc540b 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -16,7 +16,10 @@ use crate::daemon::{ event::{self, NtpData}, io::{ ClockDisruptionEvent, ControlRequest, DaemonInfo, - ntp::packet::{ExtensionField, Timestamp}, + ntp::{ + packet::{ExtensionField, Timestamp}, + socket_ext::SocketExt, + }, }, selected_clock::SelectedClockSource, time::tsc::TscCount, @@ -37,6 +40,8 @@ pub enum NTPSourceError { Timeout(#[from] time::error::Elapsed), #[error("TSC order failure. tsc_pre: {pre}. tsc_post: {post}")] TscOrder { pre: u64, post: u64 }, + #[error("IO failure on socket clear")] + SocketClear(#[source] io::Error), } /// Contains data used to run `NTPSource` runner. @@ -89,6 +94,7 @@ impl NTPSource { /// collected the NTP sample we construct the `Event` and push that event through /// to the ring buffer. async fn sample(&mut self) -> Result { + self.socket.clear().map_err(NTPSourceError::SocketClear)?; let (refid, stratum) = self.selected_clock.get(); let packet = Packet::builder() .transmit_timestamp(Timestamp::new(0)) From a954f6ae3fad10ff8f1c3b3887a240cb890269fa Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 10 Nov 2025 22:11:01 -0500 Subject: [PATCH 099/177] [selector] Fix bug with selector picking inaccurate source (#121) Root issue was PPB conversion.. --- .../src/daemon/clock_sync_algorithm/source/link_local.rs | 2 +- .../src/daemon/clock_sync_algorithm/source/ntp_source.rs | 2 +- clock-bound/src/daemon/time/tsc.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs index cccc152..20cb491 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs @@ -36,7 +36,7 @@ impl LinkLocal { } /// Feed an event into the link local NTP clock-sync algorithm - #[tracing::instrument(level = "info", skip_all)] + #[tracing::instrument(level = "info", skip_all, fields(source = "link_local"))] pub fn feed(&mut self, event: event::Ntp) -> Option<&ClockParameters> { self.inner.feed(event) } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs index e49b4a3..39a682b 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs @@ -57,7 +57,7 @@ impl NTPSource { } /// Feed an event into the NTP Source clock-sync algorithm - #[tracing::instrument(level = "info", skip_all)] + #[tracing::instrument(level = "info", skip_all, fields(source = %self.source_address))] pub fn feed(&mut self, event: event::Ntp) -> Option<&ClockParameters> { self.inner.feed(event) } diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 6c71a85..59b7cd8 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -271,7 +271,7 @@ impl Skew { /// Construct a new skew from parts per billion (ppb) pub const fn from_ppb(skew: f64) -> Self { - Self(skew * Self::PPM * 1_000.0) + Self(skew * Self::PPM / 1_000.0) } /// Construct a new skew from percentage From 17334b3868dadfb1c94832cca335ca45eebbee14 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 11 Nov 2025 11:49:46 -0500 Subject: [PATCH 100/177] [ff::phc] port ff::Ntp for phc use (#112) * [ff::phc] port ff::Ntp for phc use * Revision: Update doc comments to revisit tsc_post --- .../src/daemon/clock_sync_algorithm/ff.rs | 21 + .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 24 +- .../src/daemon/clock_sync_algorithm/ff/phc.rs | 753 ++++++++++++++++++ clock-bound/src/daemon/event/phc.rs | 271 ++++++- 4 files changed, 1048 insertions(+), 21 deletions(-) create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs index c7dae74..9ec0205 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs @@ -7,5 +7,26 @@ pub mod event_buffer; mod ntp; pub use ntp::Ntp; +mod phc; +pub use phc::Phc; + mod uncorrected_clock; pub use uncorrected_clock::UncorrectedClock; + +use crate::daemon::time::{Duration, tsc::Period}; + +/// Used as the output for `calculate_local_period_and_error` methods +struct LocalPeriodAndError { + /// period calculation + period_local: Period, + /// period error + error: Period, +} + +/// Output from `calculate_theta` methods +struct CalculateThetaOutput { + /// The time correction to be applied + theta: Duration, + /// The worst clock error bound used in calculation + clock_error_bound: Duration, +} diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index db91323..40faee4 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -3,10 +3,11 @@ use std::num::NonZeroUsize; use super::event_buffer; +use super::{CalculateThetaOutput, LocalPeriodAndError, UncorrectedClock}; use crate::daemon::{ clock_parameters::ClockParameters, clock_sync_algorithm::ff::event_buffer::FeedError, - clock_sync_algorithm::{ff::UncorrectedClock, ring_buffer::Quarter}, + clock_sync_algorithm::ring_buffer::Quarter, event::{self, TscRtt}, time::{ Duration, TscDiff, @@ -251,11 +252,10 @@ impl Ntp { // The midpoint creates an "averaging" effect. Since the goal is to minimize round trip, it ends // up creating a "worst of both worlds" scenario. // - // Anecdotally, we have seen some EC2 instances have more consistent link local RTT on the return - // path as opposed to the forward path. - // // A complex strategy we could take on would be to calculate error values on slopes and directly compare // these values in search. That kind of approach is TBD. + // + // TODO explore this a bit more. let ref_clock_diff = newest.data().server_send_time - oldest.data().server_send_time; let tsc_diff = newest.tsc_post() - oldest.tsc_post(); tracing::trace!(?ref_clock_diff, ?tsc_diff, "estimate period inputs"); @@ -423,22 +423,6 @@ impl Ntp { } } -/// Used as the output for [`Ntp::calculate_local_period_and_error`] -struct LocalPeriodAndError { - /// period calculation - period_local: Period, - /// period error - error: Period, -} - -/// Output from [`Ntp::calculate_theta`] -struct CalculateThetaOutput { - /// The time correction to be applied - theta: Duration, - /// The worst clock error bound used in calculation - clock_error_bound: Duration, -} - #[cfg(test)] mod tests { use crate::daemon::{ diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs new file mode 100644 index 0000000..d896dcf --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs @@ -0,0 +1,753 @@ +//! The PHC Feed-forward time synchronization algorithm + +use std::num::NonZeroUsize; + +use super::event_buffer; +use super::{CalculateThetaOutput, LocalPeriodAndError, UncorrectedClock}; +use crate::daemon::{ + clock_parameters::ClockParameters, + clock_sync_algorithm::ff::event_buffer::FeedError, + clock_sync_algorithm::ring_buffer::Quarter, + event::{self, TscRtt}, + time::{ + Duration, TscDiff, + tsc::{Period, Skew}, + }, +}; + +/// Feed forward time synchronization algorithm for a single PHC source +#[derive(Debug, Clone, PartialEq)] +pub struct Phc { + /// Events within the current SKM (within 1024 seconds) + local: event_buffer::Local, + /// Best RTT values of each SKM over the last week + estimate: event_buffer::Estimate, + /// Current calculation of [`ClockParameters`] + clock_parameters: Option, + /// Current uncorrected clock + uncorrected_clock: Option, + /// Max dispersion growth + /// + /// Used to compare clock error bounds with older samples while + /// taking into account CEB growth from dispersion + max_dispersion: Skew, +} + +impl Phc { + /// Create a new feed forward time synchronization algorithm + /// + /// `local_capacity` should be the number of data-points to span an SKM window. + /// For example, if the source is expected to sample once every second, the `local_capacity` + /// should have a max value of 1024. + pub fn new(local_capacity: NonZeroUsize, max_dispersion: Skew) -> Self { + Self { + local: event_buffer::Local::new(local_capacity), + estimate: event_buffer::Estimate::new(), + clock_parameters: None, + uncorrected_clock: None, + max_dispersion, + } + } + + /// Feed an event into this algorithm + /// + /// Returns [`Some`] if the event has improved this source's [`ClockParameters`]. + #[expect( + clippy::missing_panics_doc, + reason = "panics documented and only occur from bugs" + )] + pub fn feed(&mut self, event: event::Phc) -> Option<&ClockParameters> { + let tsc_midpoint = event.tsc_midpoint(); + + // First update the internal local (current SKM) and estimate (long term) + // sample buffers + self.feed_internal_buffers(event) + .inspect_err(|error_msg| match error_msg { + FeedError::Old { event, .. } => { + tracing::warn!(?event, ?error_msg); + } + }) + .ok()?; // early exit only if there was an error with the sample + + // Functionality from this point will fill out the equation + // `C(t) = TSC(t) × p^ + K − θ^(t)` where: + // - `C(t)` is the absolute time. Corrected. This is effectively the output of the clock sync algorithm + // - `TSC(t)` is the tsc reading at a time + // - `p^` is the estimation of the clock period + // - `K` is the "epoch" (the uncorrected time at `TSC(0)`) + // - `θ^(t)` is the time correction + + // Calculate uncorrected clock, aka `p^` and `K` + self.uncorrected_clock = Self::calculate_uncorrected_clock(&self.local, &self.estimate); + + // Then calculate the local period using just the local event buffer + // + // This value is not used in the above equation, but IS reported in the final clock parameters + let Some(local_period) = Self::calculate_local_period_and_error(&self.local) else { + tracing::debug!("Early exit. Calculate local period returned none"); + return None; + }; + + // expect because not having an uncorrected clock is a bug at this point + // + // If we are able to calculate a local period, then uncorrected clock must be available + let uncorrected_clock = self + .uncorrected_clock + .expect("No uncorrected period but we have local period"); + + // Calculate `θ^(t)` + let CalculateThetaOutput { + theta, + clock_error_bound, + } = Self::calculate_theta(&self.local, local_period.period_local, uncorrected_clock); + + // Calculate `C(t)` + // + // `uncorrected_clock.time_at(tsc)` is a function that calculates `TSC(t) × p^ + K` + // So this expands to `time = TSC(t) × p^ + K - θ^(t)` + // + // uses `tsc_midpoint` as that is the value that was used during the `Self::calculate_theta` fn + let time = uncorrected_clock.time_at(tsc_midpoint) - theta; + + let clock_parameters = ClockParameters { + tsc_count: tsc_midpoint, + time, + clock_error_bound, + period: local_period.period_local, + period_max_error: local_period.error, + }; + + match &mut self.clock_parameters { + None => { + // This is the first time we have calculated clock parameters. Set it. + tracing::info!(?clock_parameters, "Clock Parameters initialized"); + self.clock_parameters = Some(clock_parameters); + self.clock_parameters.as_ref() + } + Some(current_clock_parameters) + if clock_parameters + .more_accurate_than(current_clock_parameters, self.max_dispersion) => + { + // We currently have clock_parameters, and the new value is more accurate. Replace it. + tracing::debug!(?clock_parameters, "Clock Parameters updated"); + *current_clock_parameters = clock_parameters; + self.clock_parameters.as_ref() + } + Some(current_clock_parameters) => { + // We currently have clock_parameters, and the new value is NOT more accurate. Ignore and return None. + tracing::debug!(new_clock_parameters = ?clock_parameters, ?current_clock_parameters, "Clock Parameters not updated"); + None + } + } + } + + /// Get the current [`ClockParameters`] + pub fn clock_parameters(&self) -> Option<&ClockParameters> { + self.clock_parameters.as_ref() + } + + /// Feed the internal buffers with a new event + /// + /// This updates the local buffer with the event. If a `period` has been calculated already, + /// then we can use this value to update the `estimate` buffer as well. + #[cfg_attr( + feature = "test-side-by-side", + expect( + clippy::result_large_err, + reason = "returning passed in value is idiomatic" + ) + )] + fn feed_internal_buffers(&mut self, event: event::Phc) -> Result<(), FeedError> { + self.local.feed(event)?; + + if let Some(uc) = self.uncorrected_clock { + self.local.expunge_old_events(uc.p_estimate); + if let Some(new_estimate) = self.estimate.feed(&self.local, uc.p_estimate) { + tracing::info!(?new_estimate, "New value added to estimate buffer"); + } + } + Ok(()) + } + + /// Handle a disruption event + /// + /// Clears all event buffers and prior-calculations. + pub fn handle_disruption(&mut self) { + // Destructure pattern makes handling new fields mandatory + let Self { + local, + estimate, + clock_parameters, + uncorrected_clock, + max_dispersion: _, // value currently does not change + } = self; + + local.handle_disruption(); + estimate.handle_disruption(); + *clock_parameters = None; + *uncorrected_clock = None; + } + + /// Calculate the estimate period and k value based off of the ring buffers + /// + /// Returns `None` if we do not have enough data points to calculate a period. + /// + /// We calculate a period by finding a pair of RTT values that are recent and as far in the past as possible, + /// and using the pair of TSC/reference clock values, + /// + /// ## Steady State + /// While the program is running, it is expected that the `Local` event buffer is completely filled, and the + /// `Estimate` event buffer is partially/completely filled. In this state, the "new" value comes from the min value + /// in the current SKM window (local buffer), and "old" is the oldest datapoint in the estimate buffer. + /// + /// ## Initializing + /// If there are less than 2 values in the estimate buffer, then use the min values in the local buffer + /// for "new" and "old" values. + /// + /// ## Starvation + /// If local buffer is empty but we have multiple values in estimate, we can just use those. + fn calculate_uncorrected_clock( + local: &event_buffer::Local, + estimate: &event_buffer::Estimate, + ) -> Option { + // get the min RTT values in the "old" and "new" time ranges + let (oldest, newest) = if estimate.as_ref().len() < 2 { + // fallback to local only calculation if estimate is small. + // + // rationale for not using "is empty" logic: When there is a single + // value in the estimate buffer, it will end up being the min value + // in the local buff for some time. Best to avoid that. + + // old means it's in the oldest quarter of the local buffer + // new means it's in the newest quarter of the local buffer + if local.as_ref().len() < 2 { + // We need at least 2 data points to start estimating the period + return None; + } + let oldest = local.as_ref().min_rtt_in_quarter(Quarter::Oldest).unwrap(); + let newest = local.as_ref().min_rtt_in_quarter(Quarter::Newest).unwrap(); + (oldest, newest) + } else if local.is_empty() { + // If local is empty but we have values in estimate (can happen if we are starving), + // we can fallback to using the estimate buffer + // unwrap okay. Estimate buffer has at least 2 data points from above if statement + let oldest = estimate.as_ref().tail().unwrap(); + let newest = estimate.as_ref().head().unwrap(); + (oldest, newest) + } else { + // happy case: We have values in the estimate buffer, and values in local + // old is the oldest value in the estimate buffer + // new is the min rtt value of local buffer. + // unwraps okay, if conditions above check that this will never happen + let oldest = estimate.as_ref().tail().unwrap(); + let newest = local.as_ref().min_rtt().unwrap(); + (oldest, newest) + }; + + // calculate using the backward offset (tsc_post and reference clock time) + // + // When it comes to the time period calculation, the most important factor is network path consistency. + // We have 4 options for doing this right now. + // - Using the return path only (what this code does) + // - Using the forward path only + // - using the midpoint + // - something more complex using period_max_error calculations + // + // The first 2 options (forward or return path) are stable and simple approaches. + // + // The midpoint creates an "averaging" effect. Since the goal is to minimize round trip, it ends + // up creating a "worst of both worlds" scenario. + // + // A complex strategy we could take on would be to calculate error values on slopes and directly compare + // these values in search. That kind of approach is TBD. + // + // TODO explore this a bit more. + let p_estimate = + (newest.data().time - oldest.data().time) / (newest.tsc_post() - oldest.tsc_post()); + + let k = p_estimate * TscDiff::new(newest.tsc_post().get()); + let k = newest.data().time - k; + + tracing::debug!( + ?oldest, + ?newest, + ?p_estimate, + ?k, + "Calculated period and k values" + ); + + Some(UncorrectedClock { p_estimate, k }) + } + + /// Calculate the local period and associated error + /// + /// Returns `None` if `local` has less than 2 data points + #[expect( + clippy::cast_precision_loss, + reason = "Needed an escape hatch. Error units weren't lining up" + )] + fn calculate_local_period_and_error( + local: &event_buffer::Local, + ) -> Option { + if local.as_ref().len() < 2 { + return None; + } + // unwrap okay, length of 2 means both quarters and min check will succeed + let old = local.as_ref().min_rtt_in_quarter(Quarter::Oldest).unwrap(); + let new = local.as_ref().min_rtt_in_quarter(Quarter::Newest).unwrap(); + + let period_local = new.calculate_period_backward(old); + + // unwrap okay, local is not empty + let min = local.as_ref().min_rtt().unwrap(); + let min_rtt = min.rtt(); + let new_sample_error = new.rtt() - min_rtt; + let old_sample_error = old.rtt() - min_rtt; + + assert!(new_sample_error.get() >= 0); + assert!(old_sample_error.get() >= 0); + + // We have used this somewhere else, but I don't see the math for this. + // units also come out as GHz... (ticks / nanoseconds) + // seems sus... + // + // Currently this value is not used within the FF algorithm nor client, but we need to address this calculation + // in the future + let error = ((new_sample_error + old_sample_error).get() as f64) + / (new.data().time - old.data().time).as_nanos() as f64; + let error = Period::from_seconds(error); + tracing::debug!( + ?old, + ?new, + min_rtt = ?min, + %period_local, + %error, + "Calculated local period and error" + ); + Some(LocalPeriodAndError { + period_local, + error, + }) + } + + /// Calculate the theta value, which is the time correction to be applied + /// + /// The theta corresponds to the equation below + /// + /// `C(t) = TSC(t) × p^ + K − θ^(t)` where: + /// - `C(t)` is the absolute time. Corrected. + /// - `TSC(t)` is the tsc reading at a time + /// - `p^` is the estimation of the clock period + /// - `K` is the "epoch" (the uncorrected time at `TSC(0)`) + /// - `θ^(t)` is the time correction + /// + /// Calculation requires calculating + /// ```text + /// ∑{ wᵢ × (offsetᵢ + skew × p̂ × (TSCₚₒₛₜ,ₗₐₛₜ − TSCₚₒₛₜ,ᵢ)) + /// θ̂(tₗₐₛₜ) = --------------------------------------------------------- + /// ∑{wᵢ} + /// ``` + /// + /// where: + /// ```text + /// wᵢ = exp(−√(Eᵢ/E)) + /// and + /// Eᵢ = RTTᵢ − min(RTT) + /// ``` + /// + /// + /// # Panics + /// Panics if `Local` is empty + #[expect( + clippy::cast_precision_loss, + reason = "exp and weight require floats. Values are small enough to not lose precision" + )] + fn calculate_theta( + local: &event_buffer::Local, + period_local: Period, + uncorrected_clock: UncorrectedClock, + ) -> CalculateThetaOutput { + // Feed-forward time synchronization algorithm's error normalization factor. + // This constant is used to penalize the feed_forward_samples that have a + // slower reference clock read duration. + const ERROR_NORMALIZATION_FACTOR: f64 = 1e5; + + assert!(!local.is_empty()); + + let now_post = local.as_ref().head().unwrap().tsc_post(); + let skew = Skew::from_ratio(period_local, uncorrected_clock.p_estimate); + let mut numerator = 0.0; + let mut denominator = 0.0; + + let min_event = local.as_ref().min_rtt().unwrap(); + + let mut max_ceb = Duration::from_secs(0); + + for event in local.iter() { + if event.rtt() > (min_event.rtt() * local.rtt_threshold_multiplier()) { + tracing::trace!(?event, ?min_event, "skipping event due to rtt threshold"); + continue; + } + // Use the worst CEB in calculation as the algorithm's CEB + max_ceb = std::cmp::max(max_ceb, event.calculate_clock_error_bound(period_local)); + + // calculate midpoints on client and server side + + let offset = event.calculate_offset(uncorrected_clock); + + // estimate error based off of TSC rtt + let sample_error = event.rtt() - min_event.rtt(); + assert!(sample_error.get() >= 0, "cannot have a negative error"); + + // weight is e^(-sqrt(Error_{i}/E)) + // escaping into f64 to minimize rounding error. Sticking with nanoseconds as the base unit + // NOTE/FIXME: is there a world where we take into account either age of the event or the + // clock_error_bound? + let weight = -((sample_error.get() as f64 / ERROR_NORMALIZATION_FACTOR).sqrt()); + let weight = weight.exp(); + + let offset_nsec = offset.as_seconds_f64() * 1e9; + + let skew_correction_seconds = skew.get() + * uncorrected_clock.p_estimate.get() + * ((now_post - event.tsc_post()).get() as f64); + let skew_correction_nsec = skew_correction_seconds * 1e9; + numerator += weight * (offset_nsec + skew_correction_nsec); + denominator += weight; + } + + let theta_nsec = numerator / denominator; + let theta = Duration::from_seconds_f64(theta_nsec / 1e9); + tracing::debug!(?theta, ?uncorrected_clock, %period_local, "Calculated theta"); + CalculateThetaOutput { + theta, + clock_error_bound: max_ceb, + } + } +} + +#[cfg(test)] +mod tests { + use crate::daemon::{ + clock_sync_algorithm::{ + RingBuffer, + ff::event_buffer::{Estimate, Local}, + }, + event::PhcData, + time::{Duration, Instant, TscCount}, + }; + + use super::*; + + #[test] + fn empty_buffers() { + let local = Local::new(NonZeroUsize::new(1).unwrap()); + let estimate = Estimate::new(); + let result = Phc::calculate_uncorrected_clock(&local, &estimate); + assert!(result.is_none()); + } + + #[test] + fn calculate_local_period_returns_none() { + // return none if local has < 2 events + + let mut local = event_buffer::Local::new(NonZeroUsize::new(2).unwrap()); + let result = Phc::calculate_local_period_and_error(&local); + assert!(result.is_none()); + + local + .feed( + event::Phc::builder() + .tsc_pre(TscCount::new(0)) + .tsc_post(TscCount::new(1_000)) + .data(PhcData { + time: Instant::from_days(1) + Duration::from_nanos(2500), + clock_error_bound: Duration::from_nanos(1500), + }) + .build() + .unwrap(), + ) + .unwrap(); + + let result = Phc::calculate_local_period_and_error(&local); + assert!(result.is_none()); + } + + #[test] + fn calculate_local_period() { + let mut local = event_buffer::Local::new(NonZeroUsize::new(2).unwrap()); + + let old_event = event::Phc::builder() + .tsc_pre(TscCount::new(0)) + .tsc_post(TscCount::new(1_000_000)) + .data(PhcData { + time: Instant::from_days(1) + Duration::from_nanos(2500), + clock_error_bound: Duration::from_nanos(6350), + }) + .build() + .unwrap(); + + // new event is 100 seconds in the future with tsc at 1GHz + let new_event = event::Phc::builder() + .tsc_pre(TscCount::new(100_000_000_000)) + .tsc_post(TscCount::new(100_001_000_000)) + .data(PhcData { + time: Instant::from_days(1) + Duration::from_secs(100) + Duration::from_nanos(2500), + clock_error_bound: Duration::from_nanos(6350), + }) + .build() + .unwrap(); + local.feed(old_event).unwrap(); + local.feed(new_event).unwrap(); + + let result = Phc::calculate_local_period_and_error(&local).unwrap(); + assert_eq!(result.period_local, Period::from_seconds(1.0e-9)); + } + + #[test] + fn calculate_uncorrected_local_has_single_value() { + let mut local = Local::new(NonZeroUsize::new(1).unwrap()); + let event = event::Phc::builder() + .tsc_pre(TscCount::new(100)) + .tsc_post(TscCount::new(110)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_micros(5), + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + local.feed(event.clone()).unwrap(); + let estimate = Estimate::new(); + + // estimate is empty, local has a single value. Cannot calculate a period with a single datapoint + let result = Phc::calculate_uncorrected_clock(&local, &estimate); + assert!(result.is_none()); + } + + #[test] + fn calculate_uncorrected_local_has_two_values() { + // Create 2 points that are 1 second apart and the TSC is roughly 1 GHz + let mut local = Local::new(NonZeroUsize::new(2).unwrap()); + let ref_time = Instant::from_days(1) + Duration::from_nanos(500); + let event1 = event::Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .data(event::PhcData { + time: ref_time, + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + let event2 = event::Phc::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_secs(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + local.feed(event1.clone()).unwrap(); + local.feed(event2.clone()).unwrap(); + let estimate = Estimate::new(); + + let result = Phc::calculate_uncorrected_clock(&local, &estimate).unwrap(); + + approx::assert_abs_diff_eq!(result.p_estimate.get(), 1e-9); + let expected = ref_time - Duration::from_secs(1) - Duration::from_nanos(1000); // account for tsc_post time of 1_000_001_000 at 1GHz period + assert_eq!(result.k, expected); + } + + #[test] + fn calculate_uncorrected_estimate_has_values() { + // Create 2 points that are 1 second apart and the TSC is roughly 1 GHz + let mut local = Local::new(NonZeroUsize::new(2).unwrap()); + let local_event = event::Phc::builder() + .tsc_pre(TscCount::new(2_000_000_000_000)) + .tsc_post(TscCount::new(2_000_000_001_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_secs(1999) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + local.feed(local_event).unwrap(); + + let estimate_event_1 = event::Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + // larger RTT + let estimate_event_2 = event::Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000_000)) + .tsc_post(TscCount::new(1_000_000_002_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_secs(1000) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + let mut estimate_inner = RingBuffer::new(NonZeroUsize::new(5).unwrap()); + estimate_inner.push(estimate_event_1); + estimate_inner.push(estimate_event_2); + + let estimate = Estimate::builder().inner(estimate_inner).build(); + + let result = Phc::calculate_uncorrected_clock(&local, &estimate).unwrap(); + + approx::assert_abs_diff_eq!(result.p_estimate.get(), 1e-9); + assert_eq!( + result.k, + Instant::from_days(1) + Duration::from_nanos(500) + - Duration::from_secs(1) + - Duration::from_micros(1) + ); + } + + #[test] + fn theta_naive() { + // Naive test case, estimate and local periods are the same + // clock rates 1GHz + // epoch is 1 day after 1970 new years + + let uncorrected_clock = UncorrectedClock { + k: Instant::from_days(1), + p_estimate: Period::from_seconds(1e-9), + }; + let local_period = Period::from_seconds(1e-9); + + let mut local = Local::new(NonZeroUsize::new(2).unwrap()); + let event1 = event::Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_secs(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + let event2 = event::Phc::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_secs(2) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + local.feed(event1.clone()).unwrap(); + local.feed(event2.clone()).unwrap(); + + let CalculateThetaOutput { + theta, + clock_error_bound, + } = Phc::calculate_theta(&local, local_period, uncorrected_clock); + + assert_eq!(theta, Duration::from_secs(0)); + assert_eq!( + clock_error_bound, + event1.calculate_clock_error_bound(local_period) + ); + } + + #[test] + fn theta() { + // Less naive test case, estimate and local periods are 10 PPM off + // estimate clock rates 1GHz + // epoch is 1 day after 1970 new years + + let uncorrected_clock = UncorrectedClock { + k: Instant::from_days(1), + p_estimate: Period::from_seconds(1e-9), + }; + let local_period = Period::from_seconds(uncorrected_clock.p_estimate.get() * 1.000_010); + + let mut local = Local::new(NonZeroUsize::new(2).unwrap()); + let event1 = event::Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_secs(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(42), + }) + .build() + .unwrap(); + + let event2 = event::Phc::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_secs(2) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + local.feed(event1.clone()).unwrap(); + local.feed(event2.clone()).unwrap(); + + let CalculateThetaOutput { + theta, + clock_error_bound, + } = Phc::calculate_theta(&local, local_period, uncorrected_clock); + + assert_eq!(theta, Duration::from_micros(-5)); + assert_eq!( + clock_error_bound, + event1.calculate_clock_error_bound(local_period) + ); + } + + #[test] + fn feed_two_events() { + let mut ff = Phc::new(NonZeroUsize::new(5).unwrap(), Skew::from_ppm(15.0)); + + let event1 = event::Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_secs(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(42), + }) + .build() + .unwrap(); + + let event2 = event::Phc::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_001_000)) + .data(event::PhcData { + time: Instant::from_days(1) + Duration::from_secs(2) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(32), + }) + .build() + .unwrap(); + + let result = ff.feed(event1.clone()); + assert!(result.is_none()); + + let result = ff.feed(event2.clone()); + let clock_params = result.unwrap(); + + assert_eq!(clock_params.time, event2.data().time); + + // events are symmetric RTT and at 1GHz + approx::assert_abs_diff_eq!(clock_params.period.get(), 1e-9); + + // clock_error_bound should be max value + let expected_ceb = event1.calculate_clock_error_bound(clock_params.period); + assert_eq!(clock_params.clock_error_bound, expected_ceb); + } +} diff --git a/clock-bound/src/daemon/event/phc.rs b/clock-bound/src/daemon/event/phc.rs index ef6d712..4ebc63d 100644 --- a/clock-bound/src/daemon/event/phc.rs +++ b/clock-bound/src/daemon/event/phc.rs @@ -1,6 +1,9 @@ //! PHC Time synchronization events -use crate::daemon::time::{Duration, Instant, TscCount}; +use crate::daemon::{ + clock_sync_algorithm::ff::UncorrectedClock, + time::{Duration, Instant, TscCount, tsc::Period}, +}; use super::TscRtt; @@ -69,6 +72,47 @@ impl Phc { pub fn system_clock(&self) -> Option<&super::SystemClockMeasurement> { self.system_clock.as_ref() } + + /// Calculate a period by using 2 PHC events using the return path + /// + /// PHC reads are characterized in ClockBound with each read having + /// - `tsc_pre`: The TSC reading before reading the PHC device + /// - `time`: The PHC time reading + /// - `tsc_post`: The TSC reading after reading the PHC device + /// + /// The "backward" path here means using the `tsc_post` and `time` from the events + /// + /// # Panics + /// Panics if the events are the same (specifically if the `tsc_post` values are equal) + pub fn calculate_period_backward(&self, other: &Self) -> Period { + (self.data().time - other.data().time) / (self.tsc_post() - other.tsc_post()) + } + + /// Calculate the clock error bound of this event at the time of the event + /// + /// This is different from the clock error bound that would be reported to a user outside of hte daemon. + /// + /// First because this is a sans-IO input, there is not concept of reading this "after" the event comes in. + /// Because of this, there is no additional value added to the root-dispersion. + /// + /// Second, the round trip time needs a calculations of hte period to be able to convert hte TSC rtt into a + /// duration of time. + /// + /// Third, there is no "system clock offset" value. That is a parameter exclusive to modifying the system clock, which this component does not do. + pub fn calculate_clock_error_bound(&self, period_local: Period) -> Duration { + let rtt = self.rtt() * period_local; + self.data().clock_error_bound + (rtt / 2) + } + + /// Calculate offset using the uncorrected clock + /// + /// Offset is positive if the client is ahead of the reference clock + pub fn calculate_offset(&self, uncorrected_clock: UncorrectedClock) -> Duration { + let client_midpoint = self.tsc_midpoint(); + let client_midpoint = uncorrected_clock.time_at(client_midpoint); + + client_midpoint - self.data().time + } } impl TscRtt for Phc { @@ -93,6 +137,7 @@ pub struct PhcData { #[cfg(test)] mod tests { use super::*; + use rstest::rstest; #[test] fn phc_invalid() { @@ -120,4 +165,228 @@ mod tests { .build() .unwrap(); } + + #[rstest] + #[case::minimal_delays( + Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_002_000)) + .data(PhcData { + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(11), + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Duration::from_micros(12) + )] + #[case::larger_rtt( + Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_010_000)) + .data(PhcData { + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(30), + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Duration::from_micros(35) + )] + #[case::period_scaling( + Phc::builder() + .tsc_pre(TscCount::new(2_000_000_000)) + .tsc_post(TscCount::new(2_000_002_000)) + .data(PhcData { + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_micros(23), + }) + .build() + .unwrap(), + Period::from_seconds(2e-9), // Different period scaling + Duration::from_nanos(25_000), + )] + fn calculate_clock_error_bound( + #[case] event: Phc, + #[case] period: Period, + #[case] expected: Duration, + ) { + let result = event.calculate_clock_error_bound(period); + approx::assert_abs_diff_eq!( + result.as_seconds_f64(), + expected.as_seconds_f64(), + epsilon = 1e-9 + ); + } + + fn create_phc_event(pre: TscCount, post: TscCount, server_time: Instant) -> Phc { + Phc::builder() + .tsc_pre(pre) + .tsc_post(post) + .data(PhcData { + time: server_time, + clock_error_bound: Duration::from_nanos(0), // Not used in calculation // Not used in calculation + }) + .build() + .unwrap() + } + + #[rstest] + #[case( + // First event + (TscCount::new(100), TscCount::new(200), Instant::from_days(1000)), + // Second event + (TscCount::new(300), TscCount::new(400), Instant::from_days(1000) + Duration::from_secs(1)), + Period::from_seconds(0.005), + )] + #[case( + // First event + (TscCount::new(1000), TscCount::new(2000), Instant::from_days(0)), + // Second event + (TscCount::new(3000), TscCount::new(4000), Instant::from_millis(500)), + Period::from_seconds(0.00025), + )] + #[case( + // First event with larger values + (TscCount::new(10000), TscCount::new(20000), Instant::from_secs(100000)), + // Second event + (TscCount::new(30000), TscCount::new(40000), Instant::from_secs(200000)), + // Expected period (server_time_diff / tsc_diff = (200000-100000)/(40000-20000) = 5) + Period::from_seconds(5.0), + )] + fn test_calculate_period_backward( + #[case] (first_pre, first_post, first_send): (TscCount, TscCount, Instant), + #[case] (second_pre, second_post, second_send): (TscCount, TscCount, Instant), + #[case] expected_period: Period, + ) { + let event1 = create_phc_event(first_pre, first_post, first_send); + let event2 = create_phc_event(second_pre, second_post, second_send); + + let period = event1.calculate_period_backward(&event2); + approx::assert_abs_diff_eq!(period.get(), expected_period.get()); + } + + #[rstest] + #[case( + // Zero root delay and dispersion + Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_002_000)) + .data(PhcData { + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_nanos(0), + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Duration::from_micros(1) + )] + #[case( + // Large root delay and dispersion + Phc::builder() + .tsc_pre(TscCount::new(1_000_000_000)) + .tsc_post(TscCount::new(1_000_001_000)) + .data(PhcData { + time: Instant::from_days(1) + Duration::from_nanos(500), + clock_error_bound: Duration::from_millis(2), + }) + .build() + .unwrap(), + Period::from_seconds(1e-9), + Duration::from_nanos(2_000_500), + )] + fn calculate_clock_error_bound_edge_cases( + #[case] event: Phc, + #[case] period: Period, + #[case] expected: Duration, + ) { + let result = event.calculate_clock_error_bound(period); + approx::assert_abs_diff_eq!( + result.as_seconds_f64(), + expected.as_seconds_f64(), + epsilon = 1e-9 + ); + } + + // Helper function to create an UncorrectedClock with specific parameters + fn create_uncorrected_clock(k: Instant, p_estimate: Period) -> UncorrectedClock { + UncorrectedClock { k, p_estimate } + } + + #[rstest] + #[case::client_ahead( + // Test case where client is ahead of server + Phc::builder() + .tsc_pre(TscCount::new(1000)) + .tsc_post(TscCount::new(2000)) + .data(PhcData { + time: Instant::from_millis(10500), + clock_error_bound: Duration::from_secs(0), + }) + .build() + .unwrap(), + create_uncorrected_clock( + Instant::from_secs(0), + Period::from_seconds(0.02) // 20ms per tick + ), + Duration::from_seconds_f64(19.5) // Expected positive offset + )] + #[case::client_behind( + // Test case where client is behind server + Phc::builder() + .tsc_pre(TscCount::new(1000)) + .tsc_post(TscCount::new(2000)) + .data(PhcData { + time: Instant::from_millis(50500), + clock_error_bound: Duration::from_secs(0), + }) + .build() + .unwrap(), + create_uncorrected_clock( + Instant::from_secs(0), + Period::from_seconds(0.02) // 20ms per tick + ), + Duration::from_seconds_f64(-20.5) // Expected negative offset + )] + #[case::zero_offset( + // Test case where client and server are synchronized + Phc::builder() + .tsc_pre(TscCount::new(1000)) + .tsc_post(TscCount::new(2000)) + .data(PhcData { + time: Instant::from_millis(25000), + clock_error_bound: Duration::from_secs(0), + }) + .build() + .unwrap(), + create_uncorrected_clock( + Instant::from_secs(10), + Period::from_seconds(0.01) // 10ms per tick + ), + Duration::from_secs(0) // Expected zero offset + )] + fn calculate_offset( + #[case] ntp_event: Phc, + #[case] uncorrected_clock: UncorrectedClock, + #[case] expected_offset: Duration, + ) { + let client_midpoint = ntp_event.tsc_pre.midpoint(ntp_event.tsc_post); + println!( + "tsc_pre: {:?}", + uncorrected_clock.time_at(ntp_event.tsc_pre) + ); + println!( + "tsc_post: {:?}", + uncorrected_clock.time_at(ntp_event.tsc_post) + ); + let client_midpoint = uncorrected_clock.time_at(client_midpoint); + println!("client_midpoint: {client_midpoint:?}"); + let offset = ntp_event.calculate_offset(uncorrected_clock); + + approx::assert_abs_diff_eq!( + offset.as_seconds_f64(), + expected_offset.as_seconds_f64(), + epsilon = 1e-9 + ); + } } From 21db8764607049d14ca53223943c2cfec81c0c6c Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 11 Nov 2025 11:57:31 -0500 Subject: [PATCH 101/177] [io::ntp] Check for origin timestamp mismatch before accepting (#119) * [io::ntp] Check for origin timestamp mismatch before accepting This is the other half of the NTP io packet fixes. Before this it was possible for packets from previous timeouts to remain in the socket. This gives us a positive confirmation that the reply came from this paired request and not from a previous rogue request. * Revision: derp * Revision: Heavy refactor to allow for retry logic in a loop --- clock-bound/src/daemon/io/link_local.rs | 85 +++++------ clock-bound/src/daemon/io/ntp.rs | 135 +++++++++++++++++- .../src/daemon/io/ntp/packet/timestamp.rs | 5 + clock-bound/src/daemon/io/ntp_source.rs | 71 ++++----- 4 files changed, 199 insertions(+), 97 deletions(-) diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index 7146b80..bc28984 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -1,12 +1,12 @@ //! Link Local IO Source -use std::sync::Arc; +use std::{num::Wrapping, sync::Arc}; use thiserror::Error; use tokio::{ io, net::UdpSocket, sync::{mpsc, watch}, - time::{self, Instant, Interval, MissedTickBehavior, interval, timeout}, + time::{self, Instant, Interval, MissedTickBehavior, interval}, }; use tracing::{debug, info}; @@ -14,14 +14,12 @@ use super::ntp::{ LINK_LOCAL_ADDRESS, LINK_LOCAL_BURST_DURATION, LINK_LOCAL_BURST_INTERVAL_DURATION, LINK_LOCAL_INTERVAL_DURATION, LINK_LOCAL_TIMEOUT, packet, }; -use super::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; use super::{ClockDisruptionEvent, ControlRequest}; use crate::daemon::{ async_ring_buffer, - event::{self, NtpData}, - io::ntp::{packet::Timestamp, socket_ext::SocketExt}, + event::{self}, + io::ntp::{self, SamplePacketError, packet::Timestamp}, selected_clock::SelectedClockSource, - time::tsc::TscCount, }; use packet::Packet; @@ -32,8 +30,8 @@ pub enum LinkLocalError { Io(#[from] io::Error), #[error("Failed to parse NTP packet.")] PacketParsing(String), - #[error("Send NtpEvent message failed.")] - SendEventMessage(#[from] mpsc::error::SendError), + #[error("Mismatched origin. Expected {expected}, got {received}")] + OriginMismatch { expected: u64, received: u64 }, #[error("Operation timed out.")] Timeout(#[from] time::error::Elapsed), #[error("TSC order failure. tsc_pre: {pre}. tsc_post: {post}")] @@ -42,6 +40,17 @@ pub enum LinkLocalError { SocketClear(#[source] io::Error), } +impl From for LinkLocalError { + fn from(value: SamplePacketError) -> Self { + match value { + SamplePacketError::Io(e) => LinkLocalError::Io(e), + SamplePacketError::Timeout(e) => LinkLocalError::Timeout(e), + SamplePacketError::TscOrder { pre, post } => LinkLocalError::TscOrder { pre, post }, + SamplePacketError::SocketClear(e) => LinkLocalError::SocketClear(e), + } + } +} + /// Contains the data needed to run the link local runner. #[derive(Debug)] pub struct LinkLocal { @@ -53,6 +62,7 @@ pub struct LinkLocal { interval: Interval, mode: Mode, selected_clock: Arc, + transmit_counter: Wrapping, } impl LinkLocal { @@ -79,6 +89,7 @@ impl LinkLocal { interval: link_local_interval, mode: Mode::burst(), selected_clock, + transmit_counter: Wrapping(0), } } @@ -90,59 +101,25 @@ impl LinkLocal { /// collected the NTP sample we construct the `Event` and push that event through /// to the ring buffer. async fn sample(&mut self) -> Result { - self.socket.clear().map_err(LinkLocalError::SocketClear)?; let (refid, stratum) = self.selected_clock.get(); + let counter = self.transmit_counter.0; + self.transmit_counter += 1; let packet = Packet::builder() - .transmit_timestamp(Timestamp::new(0)) + .transmit_timestamp(Timestamp::new(counter)) .stratum(stratum.incremented().into()) .reference_id(refid.into()) .build(); packet.emit_bytes(&mut self.ntp_buffer); - let sent_timestamp = read_timestamp_counter_begin(); - - // Request and Receive NTP sample. - let recv_packet_result = timeout(LINK_LOCAL_TIMEOUT, { - self.socket - .send_to(&self.ntp_buffer, LINK_LOCAL_ADDRESS) - .await?; - self.socket.recv_from(&mut self.ntp_buffer) - }) + let ntp_event = ntp::sample_packet( + &self.socket, + LINK_LOCAL_ADDRESS.into(), + &mut self.ntp_buffer, + LINK_LOCAL_TIMEOUT, + counter, + ) .await?; - - let received_timestamp = read_timestamp_counter_end(); - - #[cfg(all(not(test), feature = "test-side-by-side"))] - let system_clock_reading = crate::daemon::event::SystemClockMeasurement::now(); - - let (_, ntp_packet) = Packet::parse_from_bytes(&self.ntp_buffer) - .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; - - let ntp_data = NtpData::try_from(ntp_packet) - .map_err(|e| LinkLocalError::PacketParsing(e.to_string()))?; - - let builder = event::Ntp::builder() - .tsc_pre(TscCount::new(sent_timestamp.into())) - .tsc_post(TscCount::new(received_timestamp.into())) - .ntp_data(ntp_data); - - let ntp_event = { - #[cfg(all(not(test), feature = "test-side-by-side"))] - { - builder.system_clock(system_clock_reading).build() - } - #[cfg(any(test, not(feature = "test-side-by-side")))] - { - builder.build() - } - }; - - let ntp_event = ntp_event.ok_or(LinkLocalError::TscOrder { - pre: sent_timestamp, - post: received_timestamp, - })?; - - debug!(?recv_packet_result, "Received packet."); + debug!(?ntp_event, "Received packet."); Ok(ntp_event) } @@ -213,6 +190,7 @@ impl LinkLocal { interval: ll_interval, mode, selected_clock: _selected_clock, + transmit_counter: _, } = self; *mode = Mode::burst(); @@ -233,6 +211,7 @@ impl LinkLocal { interval: ll_interval, mode, selected_clock: _selected_clock, + transmit_counter: _, } = self; *mode = Mode::Normal; diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index 53ece1d..4b96d9b 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -1,14 +1,25 @@ //! Ntp IO Source constants -use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; -use tokio::time::Duration; +use std::{ + io, + net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}, +}; +use tokio::{net::UdpSocket, time, time::Duration}; pub mod packet; pub use packet::{Fec2V1Value as DaemonInfo, Packet}; pub mod socket_ext; -use crate::daemon::{async_ring_buffer, event}; +use crate::daemon::{ + async_ring_buffer, + event::{self, NtpData}, + io::{ + ntp::socket_ext::SocketExt, + tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}, + }, + time::TscCount, +}; pub const LINK_LOCAL_BURST_DURATION: Duration = Duration::from_secs(1); /// The amount of time between source polls when in burst mode. @@ -30,3 +41,121 @@ pub const NTP_SOURCE_TIMEOUT: Duration = Duration::from_millis(100); pub type NTPSourceSender = (SocketAddr, async_ring_buffer::Sender); /// Tuple to hold both the `SocketAddr` and ring buffer `Receiver` for an IO `NTPSource` pub type NTPSourceReceiver = (SocketAddr, async_ring_buffer::Receiver); + +/// Sample an NTP event +/// +/// Send an NTP request and return the response +/// +/// This function will effectively loop over: +/// - receiving a packet +/// - parsing it +/// - ensuring the origin timestamp matches the one that was sent +/// - converting the packet into an [`event::Ntp`] +/// +/// # Errors +/// Returns an error if: +/// - the socket has an IO error +/// - the transaction times out +pub async fn sample_packet( + socket: &UdpSocket, + addr: SocketAddr, + send_recv_buffer: &mut [u8], + timeout: std::time::Duration, + expected_counter: u64, +) -> Result { + socket.clear().map_err(SamplePacketError::SocketClear)?; + let fut = tokio::time::timeout( + timeout, + inner_timeout(socket, addr, send_recv_buffer, expected_counter), + ); + + let (send_timestamp, ntp_data, received_timestamp) = fut.await??; + + #[cfg(all(not(test), feature = "test-side-by-side"))] + let system_clock_reading = crate::daemon::event::SystemClockMeasurement::now(); + + let builder = event::Ntp::builder() + .tsc_pre(TscCount::new(send_timestamp.into())) + .tsc_post(TscCount::new(received_timestamp.into())) + .ntp_data(ntp_data); + + let ntp_event = { + #[cfg(all(not(test), feature = "test-side-by-side"))] + { + builder.system_clock(system_clock_reading).build() + } + #[cfg(any(test, not(feature = "test-side-by-side")))] + { + builder.build() + } + }; + + let ntp_event = ntp_event.ok_or(SamplePacketError::TscOrder { + pre: send_timestamp, + post: received_timestamp, + })?; + + Ok(ntp_event) +} + +// private inner function which loops indefinitely. Meant to be wrapped in a timeout +// +// Returns the tx tsc, NTP data, and the rx tsc +async fn inner_timeout( + socket: &UdpSocket, + addr: SocketAddr, + send_recv_buffer: &mut [u8], + expected_counter: u64, +) -> Result<(u64, NtpData, u64), io::Error> { + let send_timestamp = read_timestamp_counter_begin(); + socket.send_to(send_recv_buffer, addr).await?; + loop { + let (len, recv_addr) = socket.recv_from(send_recv_buffer).await?; + let received_timestamp = read_timestamp_counter_end(); + if recv_addr != addr { + continue; + } + let Ok((_, ntp_packet)) = Packet::parse_from_bytes(&send_recv_buffer[..len]) + .inspect_err(|e| tracing::trace!(parse_error = ?e.to_string(), "Parsing error")) + else { + continue; + }; + + if ntp_packet.origin_timestamp.get() != expected_counter { + tracing::trace!(error = ?InnerSamplePacketError::OriginMismatch { + expected: expected_counter, + received: ntp_packet.origin_timestamp.get(), + }); + continue; + } + + let Ok(ntp_data) = NtpData::try_from(ntp_packet) + .inspect_err(|e| tracing::trace!(error = ?InnerSamplePacketError::PacketParsing(e.to_string()), "NtpData try from error")) else { + continue; + }; + + return Ok((send_timestamp, ntp_data, received_timestamp)); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SamplePacketError { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Timeout(#[from] time::error::Elapsed), + #[error("TSC order failure. tsc_pre: {pre}. tsc_post: {post}")] + TscOrder { pre: u64, post: u64 }, + #[error("IO failure on socket clear")] + SocketClear(#[source] io::Error), +} + +#[derive(Debug, thiserror::Error)] +enum InnerSamplePacketError { + #[error("IO failure.")] + Io(#[from] io::Error), + #[error("Failed to parse NTP packet.")] + PacketParsing(String), + #[error("Mismatched origin. Expected {expected}, got {received}")] + OriginMismatch { expected: u64, received: u64 }, +} diff --git a/clock-bound/src/daemon/io/ntp/packet/timestamp.rs b/clock-bound/src/daemon/io/ntp/packet/timestamp.rs index a0aafb0..f550604 100644 --- a/clock-bound/src/daemon/io/ntp/packet/timestamp.rs +++ b/clock-bound/src/daemon/io/ntp/packet/timestamp.rs @@ -33,6 +33,11 @@ impl Timestamp { secs + subsecs } + /// Get the inner value + pub const fn get(self) -> u64 { + self.inner + } + // From https://stackoverflow.com/questions/29112071/how-to-convert-ntp-time-to-unix-epoch-time-in-c-language-linux // // Unix uses an epoch located at 1/1/1970-00:00h (UTC) and NTP uses 1/1/1900-00:00h. diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 0dc540b..18ab36d 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -1,28 +1,26 @@ //! NTP Server IO Source -use std::{net::SocketAddr, sync::Arc}; +use std::{net::SocketAddr, num::Wrapping, sync::Arc}; use thiserror::Error; use tokio::{ io, net::UdpSocket, sync::{mpsc, watch}, - time::{self, Interval, MissedTickBehavior, interval, timeout}, + time::{self, Interval, MissedTickBehavior, interval}, }; use tracing::{debug, info}; -use super::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; use crate::daemon::{ async_ring_buffer, - event::{self, NtpData}, + event::{self}, io::{ ClockDisruptionEvent, ControlRequest, DaemonInfo, ntp::{ + self, SamplePacketError, packet::{ExtensionField, Timestamp}, - socket_ext::SocketExt, }, }, selected_clock::SelectedClockSource, - time::tsc::TscCount, }; use super::ntp::{NTP_SOURCE_INTERVAL_DURATION, NTP_SOURCE_TIMEOUT, packet}; @@ -34,8 +32,8 @@ pub enum NTPSourceError { Io(#[from] io::Error), #[error("Failed to parse NTP packet.")] PacketParsing(String), - #[error("Send NtpEvent message failed.")] - SendEventMessage(#[from] mpsc::error::SendError), + #[error("Mismatched origin. Expected {expected}, got {received}")] + OriginMismatch { expected: u64, received: u64 }, #[error("Operation timed out.")] Timeout(#[from] time::error::Elapsed), #[error("TSC order failure. tsc_pre: {pre}. tsc_post: {post}")] @@ -44,6 +42,17 @@ pub enum NTPSourceError { SocketClear(#[source] io::Error), } +impl From for NTPSourceError { + fn from(value: SamplePacketError) -> Self { + match value { + SamplePacketError::Io(e) => NTPSourceError::Io(e), + SamplePacketError::Timeout(e) => NTPSourceError::Timeout(e), + SamplePacketError::TscOrder { pre, post } => NTPSourceError::TscOrder { pre, post }, + SamplePacketError::SocketClear(e) => NTPSourceError::SocketClear(e), + } + } +} + /// Contains data used to run `NTPSource` runner. /// Notably, the IP address passed to this struct should be associated /// with an NTP host. @@ -58,6 +67,7 @@ pub struct NTPSource { interval: Interval, selected_clock: Arc, daemon_info: DaemonInfo, + transmit_counter: Wrapping, } impl NTPSource { @@ -83,6 +93,7 @@ impl NTPSource { interval: ntp_source_interval, selected_clock, daemon_info, + transmit_counter: Wrapping(0), } } @@ -94,47 +105,25 @@ impl NTPSource { /// collected the NTP sample we construct the `Event` and push that event through /// to the ring buffer. async fn sample(&mut self) -> Result { - self.socket.clear().map_err(NTPSourceError::SocketClear)?; let (refid, stratum) = self.selected_clock.get(); + let counter = self.transmit_counter.0; + self.transmit_counter += 1; let packet = Packet::builder() - .transmit_timestamp(Timestamp::new(0)) + .transmit_timestamp(Timestamp::new(counter)) .stratum(stratum.incremented().into()) .reference_id(refid.into()) .extensions(vec![ExtensionField::Fec2V1(self.daemon_info.clone())]) .build(); packet.emit_bytes(&mut self.ntp_buffer); - - let sent_timestamp = read_timestamp_counter_begin(); - - // Request and Receive NTP sample. - let recv_packet_result = timeout(NTP_SOURCE_TIMEOUT, { - let packet_size = packet.total_size(); - self.socket - .send_to(&self.ntp_buffer[..packet_size], self.address) - .await?; - self.socket.recv_from(&mut self.ntp_buffer) - }) + let ntp_event = ntp::sample_packet( + &self.socket, + self.address, + &mut self.ntp_buffer, + NTP_SOURCE_TIMEOUT, + counter, + ) .await?; - - let received_timestamp = read_timestamp_counter_end(); - - let (_, ntp_packet) = Packet::parse_from_bytes(&self.ntp_buffer) - .map_err(|e| NTPSourceError::PacketParsing(e.to_string()))?; - - let ntp_data = NtpData::try_from(ntp_packet) - .map_err(|e| NTPSourceError::PacketParsing(e.to_string()))?; - - let ntp_event = event::Ntp::builder() - .tsc_pre(TscCount::new(sent_timestamp.into())) - .tsc_post(TscCount::new(received_timestamp.into())) - .ntp_data(ntp_data) - .build() - .ok_or(NTPSourceError::TscOrder { - pre: sent_timestamp, - post: received_timestamp, - })?; - - debug!(?recv_packet_result, "Received packet."); + debug!(?ntp_event, "Received packet."); Ok(ntp_event) } From 8b3023fb0ef9d522b4ce6dd7f72a82942a3320c3 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Tue, 11 Nov 2025 11:59:56 -0500 Subject: [PATCH 102/177] Integrate state machine into ClockAdjuster (#110) ClockAdjuster is to be reimplemented in the context of running within an async actor on a regular ticking interval - where each tick, if clock params are available, we call in and see if there is work to do for ClockAdjuster. This is accomplished by integrating the state machine for ClockAdjuster, in a somewhat sans-io approach, rather than as its own async set of functions. The handle_event function takes the current state and the timestamp at the tick event, along with clock parameters, and performs the proper state transition if needed. This commit leaves things in a somewhat unfinished state, where the ClockState must actually be transformed into an async actor to get things working as expected. --- clock-bound/Cargo.toml | 10 + clock-bound/src/daemon/clock_state.rs | 67 ++-- .../src/daemon/clock_state/clock_adjust.rs | 374 +++++++++++++----- .../clock_state/clock_adjust/ntp_adjtime.rs | 1 + .../src/adjust_clock_test.rs | 4 +- .../src/adjust_clock.rs | 4 +- .../src/step_clock.rs | 4 +- 7 files changed, 310 insertions(+), 154 deletions(-) diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index cd021fc..be90684 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -55,6 +55,16 @@ mockall = "0.13.1" mockall_double = "0.3.1" rstest = "0.26" tempfile = "3.13" +tokio = { version = "1.47.1", features = [ + "fs", + "net", + "macros", + "rt", + "rt-multi-thread", + "sync", + "time", + "test-util", +] } tracing-test = "0.2.5" [features] diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 1ca77b2..5db9e0d 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -2,9 +2,7 @@ pub mod clock_adjust; pub mod clock_state_writer; -use tracing::error; - -use crate::daemon::clock_state::clock_adjust::{ClockAdjust, KAPIClockAdjuster, NtpAdjTimeError}; +use crate::daemon::clock_state::clock_adjust::{ClockAdjust, KAPIClockAdjuster}; use crate::daemon::clock_state::clock_state_writer::{ClockStateWrite, SafeShmWriter}; use crate::daemon::clock_state::{ clock_adjust::ClockAdjuster, clock_state_writer::ClockStateWriter, @@ -15,20 +13,19 @@ use crate::daemon::{ clock_parameters::ClockParameters, time::{ClockExt, clocks::ClockBound}, }; -use crate::shm::ShmWriter; - -const CLOCKBOUND_SHM_DEFAULT_PATH: &str = "/var/run/clockbound/shm0"; +use crate::shm::{CLOCKBOUND_SHM_DEFAULT_PATH, ShmWriter}; /// The whole `ClockState` component struct. /// This encompasses both `ClockAdjust` component which interfaces /// with the `CLOCK_REALTIME` kernel clock to synchronize it with `ClockBound` estimate /// of UTC (`ClockBound` clock), and `ClockStateWriter` which manages writing /// the `ClockErrorBound` to SHM segment for the client to read. -pub struct ClockState { +pub(crate) struct ClockState { clock_state_writer: S, clock_adjuster: A, } +#[cfg_attr(not(test), expect(unused))] impl ClockState { pub fn new(clock_state_writer: S, clock_adjust: A) -> Self { Self { @@ -49,20 +46,12 @@ impl ClockState { ) { let clockbound_clock = ClockBound::new(clock_parameters.clone(), ReadTscImpl); let clock_realtime_offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); - match self - .clock_adjuster - .handle_clock_parameters(clock_parameters, clock_realtime_offset_and_rtt) - { - failed_adjtime @ Err(NtpAdjTimeError::Failure(_)) => { - failed_adjtime.unwrap(); - } - Err(unexpected_adjtime_status) => { - error!("Unexpected adjtime result: {unexpected_adjtime_status}"); - } - Ok(_) => self - .clock_state_writer - .handle_clock_parameters(clock_parameters, clock_realtime_offset_and_rtt), - } + // FIXME: this implementation is incorrect as is and will be fixed in upcoming commit + // to integrate with the Daemon. + self.clock_adjuster + .handle_clock_parameters(tokio::time::Instant::now(), clock_parameters); + self.clock_state_writer + .handle_clock_parameters(clock_parameters, clock_realtime_offset_and_rtt); } /// Handle a clock disruption event @@ -86,7 +75,7 @@ impl ClockState { } impl ClockState, ClockStateWriter> { - #[expect(clippy::missing_panics_doc, reason = "unwrap")] + #[cfg_attr(feature = "test-side-by-side", expect(unused))] pub fn construct() -> Self { let shm_writer = ShmWriter::new(std::path::Path::new(CLOCKBOUND_SHM_DEFAULT_PATH)).unwrap(); let safe_shm_writer = SafeShmWriter::new(shm_writer); @@ -108,13 +97,7 @@ mod tests { use crate::daemon::{ clock_state::{clock_adjust::MockClockAdjust, clock_state_writer::MockClockStateWrite}, - time::{ - Duration, Instant, TscCount, - inner::ClockOffsetAndRtt, - instant::Utc, - timex::Timex, - tsc::{Period, Skew}, - }, + time::{Duration, Instant, TscCount, inner::ClockOffsetAndRtt, instant::Utc, tsc::Period}, }; use super::*; @@ -136,18 +119,13 @@ mod tests { let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() - .times(1) + .once() .withf( - move |clock_params: &ClockParameters, _offset_and_rtt: &ClockOffsetAndRtt| { + move |_now: &tokio::time::Instant, clock_params: &ClockParameters| { *clock_params == clock_parameters_clone }, ) - .return_once(|_, _| { - Ok(Timex::clock_adjustment() - .phase_correction(Duration::from(0)) - .skew(Skew::from_ppm(0.0)) - .call()) - }); + .return_const(()); let clock_parameters_clone = clock_parameters.clone(); let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); @@ -173,13 +151,13 @@ mod tests { let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() - .times(1) + .once() .withf( - move |clock_params: &ClockParameters, _offset_and_rtt: &ClockOffsetAndRtt| { + move |_now: &tokio::time::Instant, clock_params: &ClockParameters| { *clock_params == clock_parameters_clone }, ) - .return_once(|_, _| Err(NtpAdjTimeError::Failure(errno::errno()))); + .return_const(()); let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer @@ -197,18 +175,19 @@ mod tests { let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() - .times(1) + .once() .withf( - move |clock_params: &ClockParameters, _offset_and_rtt: &ClockOffsetAndRtt| { + move |_now: &tokio::time::Instant, clock_params: &ClockParameters| { *clock_params == clock_parameters_clone }, ) - .return_once(|_, _| Err(NtpAdjTimeError::BadState(0))); + .return_const(()); let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer .expect_handle_clock_parameters() - .never(); + .once() + .return_const(()); let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); clock_state.handle_clock_parameters(&clock_parameters); diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index 623449b..ab6cb83 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -1,76 +1,73 @@ -//! Adjust system clock -use libc::{TIME_DEL, TIME_ERROR, TIME_INS, TIME_OK, TIME_OOP, TIME_WAIT}; +//! Adjust system clock. +//! +//! `ClockBound` tries to make `CLOCK_REALTIME` follow UTC as closely as possible. +//! It has its own notion of a "best internal clock" output by the `ClockSyncAlgorithm` and selector, +//! which is expressed as a set of `ClockParameters`, and reading that clock is done via +//! using those `ClockParameters` + corresponding TSC reads, similar to `clock_gettime` VDSO implementation. +//! To make `CLOCK_REALTIME` follow UTC, we make it follow `ClockBound` internal clock by steering it via +//! frequency and phase corrections. +//! +//! Frequency corrections are applied via `ntp_adjtime` - the frequency to apply is calculated by comparing +//! two `ClockSnapshot`s with timestamps of `CLOCK_REALTIME` along with `ClockBound` internal clock, calculating the relative +//! frequency, and applying the relative frequency change to the currently used `CLOCK_REALTIME` frequency (gathered +//! via `ntp_adjtime` as part of the `ClockSnapshot`. +//! +//! Phase corrections are applied via `ntp_adjtime` as well, and are a simpler task - we simply use the old trick +//! of interleaved reads to compare the offset of two clocks (offset of `ClockBound` w.r.t `CLOCK_REALTIME`) and apply +//! that as the phase correction to `CLOCK_REALTIME`, using the PLL slewing method in kernel. An alternative approach +//! to fixing phase corrections could involve temporary slewing of the clock frequency, but can risk overshooting, or +//! leaving the system in a bad state if in the middle of a slew and the daemon is terminated. use tracing::{debug, error, info}; use crate::daemon::{ clock_parameters::ClockParameters, - time::{Duration, inner::ClockOffsetAndRtt, instant::Utc, timex::Timex, tsc::Skew}, + time::{Duration, tsc::Skew}, }; mod ntp_adjtime; -pub use ntp_adjtime::{ - KAPIClockAdjuster, NoopClockAdjuster, NtpAdjTime, NtpAdjTimeError, NtpAdjTimeExt, -}; -#[expect(unused)] mod state_machine; - -pub struct ClockAdjuster { - ntp_adjtime: T, - should_step: bool, -} +pub use ntp_adjtime::{KAPIClockAdjuster, NtpAdjTimeError, NtpAdjTimeExt}; #[cfg_attr(test, mockall::automock)] -#[expect(clippy::missing_errors_doc, reason = "tphan to update")] -pub trait ClockAdjust { +pub(crate) trait ClockAdjust: Send + Sync { fn handle_clock_parameters( &mut self, + now: tokio::time::Instant, clock_parameters: &ClockParameters, - clock_realtime_offset_and_rtt: ClockOffsetAndRtt, - ) -> Result; + ); fn handle_disruption(&mut self, new_disruption_marker: u64); } -impl ClockAdjust for ClockAdjuster { - /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. - /// - /// # Errors - /// This method returns [`NtpAdjTimeError`] if the call has failed or has an unexpected return code. +pub struct ClockAdjuster { + state: State, + ntp_adjtime: T, +} + +impl ClockAdjust for ClockAdjuster { fn handle_clock_parameters( &mut self, - // This is needed to tell ClockAdjust what frequency to use. - _clock_parameters: &ClockParameters, - // This is needed to tell ClockAdjust what phase offset to use. - clock_realtime_offset_and_rtt: ClockOffsetAndRtt, - ) -> Result { - if self.should_step { - self.step_clock(clock_realtime_offset_and_rtt.offset()) - } else { - self.adjust_clock(clock_realtime_offset_and_rtt.offset(), Skew::from_ppm(0.0)) - } + now: tokio::time::Instant, + clock_parameters: &ClockParameters, + ) { + self.handle_event(now, clock_parameters); } - /// Handle a clock disruption event - /// - /// Call this function after the system detects a VMClock disruption event. - /// - /// It will go through and clear the state (like startup). - /// - /// # Panics - /// If `adjust_clock` fails, e.g. an invalid value was supplied to `ntp_adjtime` (unlikely), or - /// insufficient permissions to adjust the clock. fn handle_disruption(&mut self, _new_disruption_marker: u64) { // Use the destructure pattern to get a mutable reference to each item. // // This makes it a compilation error if we add a new field to Self without handling it here let Self { ntp_adjtime: _, - should_step: _, + state, } = self; // At least stop any ongoing phase correction slew or frequency correction, if the clock is disrupted. // Notably, phase correction slew is recalculated at the top of a second, so we still might end up having some moderate slew // of the clock happening til that time. info!("Resetting ntp_adjtime parameters to zero any phase or frequency corrections"); - match self.adjust_clock(Duration::from_secs(0), Skew::from_ppm(0.0)) { + match self + .ntp_adjtime + .adjust_clock(Duration::from_secs(0), Skew::from_ppm(0.0)) + { failed_adjtime @ Err(NtpAdjTimeError::Failure(_)) => { failed_adjtime.unwrap(); } @@ -79,93 +76,137 @@ impl ClockAdjust for ClockAdjuster { } _ => {} } + *state = State::Disrupted(Disrupted); // TODO: We may want to reset `should_step` if we think it is acceptable to step the clock on next adjustment // for faster recovery.. - tracing::info!("Handled clock disruption event"); + info!("Handled clock disruption event"); } } -impl ClockAdjuster { +/// Duration for which to wait for phase correction halting to complete. +/// Since PLL adjustment starts at the top of a second, this should take at least one second. +/// It's possible the clock used for calculation of `Duration` in our async runtime +/// runs a bit slower than this though, so we use 2 seconds to be safe. +const PHASE_CORRECTION_HALT_DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(2); +/// Duration which we should stay in `State::InitialSnapshotA`. We calculate relative frequency adjustment to apply +/// to have `CLOCK_REALTIME` match `ClockBound` clock rate over this duration, by grabbing two snapshots A and B. +const INITIAL_SNAPSHOT_RETRIEVED_DURATION: tokio::time::Duration = + tokio::time::Duration::from_secs(1); +/// Duration which we should let the PLL phase correction run for. +/// The amount of phase offset corrected via PLL slewing can be approximated based on PLL, +/// to be `(1 - 0.75^n)` where `n` = seconds since PLL start. +/// With 8 seconds, we slew approximately 90% of the offset we intended to. +const PHASE_CORRECTING_DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(8); +/// Duration which we should stay in `State::InitialSnapshotA`. We calculate relative frequency adjustment to apply +/// to have `CLOCK_REALTIME` match `ClockBound` clock rate over this duration, by grabbing two snapshots A and B. +const SNAPSHOT_RETRIEVED_DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(10); + +use state_machine::{ + ClockAdjusted, Disrupted, InitialPhaseCorrectHalted, InitialSnapshotRetrieved, Initialized, + PhaseCorrectHalted, SnapshotRetrieved, State, +}; + +impl ClockAdjuster { pub fn new(ntp_adjtime: T) -> Self { - // Should step on first adjustment. - let should_step = true; Self { + state: State::Initialized(Initialized), ntp_adjtime, - should_step, } } - /// Performs an adjustment of the clock, to apply the given phase correction - /// and skew values, in a single system call. + /// Central event handler for `ClockAdjuster`. The state machine mostly consists of transitions + /// based on time elapsed since last state. Handling of these deadlines, and determining whether to + /// transition, is done centrally here. /// - /// # Errors - /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno - /// `NtpAdjTimeError::BadState` if some state other than `TIME_OK` is returned from `ntp_adjtime` - /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` - pub fn adjust_clock( - &self, - phase_correction: Duration, - skew: Skew, - ) -> Result { - let mut tx = Timex::clock_adjustment() - .phase_correction(phase_correction) - .skew(skew) - .call(); - - debug!( - "calling ntp_adjtime to adjust clock with phase_correction {phase_correction:?} and skew {skew:?}" - ); - match self.ntp_adjtime.ntp_adjtime(&mut tx) { - TIME_OK => Ok(tx), - cs @ (TIME_ERROR | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { - Err(NtpAdjTimeError::BadState(cs)) + /// If a state matches and its criteria to transition (i.e. duration of state has passed), then + /// we mutate our internal state. + pub fn handle_event(&mut self, now: tokio::time::Instant, clock_params: &ClockParameters) { + match &self.state { + State::Disrupted(inner) => { + self.state = State::InitialPhaseCorrectHalted(inner.transition(&self.ntp_adjtime)); } - -1 => Err(NtpAdjTimeError::Failure(errno::errno())), - unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), - } - } - - /// Applies an instantaneous step of `CLOCK_REALTIME` based on the passed `phase_correction` value. - /// - /// # Errors - /// `NtpAdjTimeError::Failure` if `ntp_adjtime` returns -1, meaning the system call failed, along with errno - /// `NtpAdjTimeError::BadState` if some state other than `TIME_ERROR` is returned from `ntp_adjtime` - /// `NtpAdjTimeError::InvalidState` if some invalid or not well-documented state is returned from `ntp_adjtime` - pub fn step_clock(&mut self, phase_correction: Duration) -> Result { - let mut tx = Timex::clock_step() - .phase_correction(phase_correction) - .call(); - - debug!( - "calling ntp_adjtime to step clock with phase_correction {phase_correction:?} {:?}", - tx - ); - // NOTE: we actually expect TIME_ERROR if the clock adjustment succeeds, since - // that indicates the clock is now "unsynchronized" (expected after we step the clock - // discontinuously) - match self.ntp_adjtime.ntp_adjtime(&mut tx) { - TIME_ERROR => { - // Step was successful, so we should not step again. - self.should_step = false; - Ok(tx) + State::Initialized(inner) => { + self.state = State::InitialPhaseCorrectHalted( + inner.transition(&self.ntp_adjtime, clock_params), + ); + } + State::InitialPhaseCorrectHalted(inner @ InitialPhaseCorrectHalted { instant }) + if now.duration_since(*instant) >= PHASE_CORRECTION_HALT_DURATION => + { + self.state = State::InitialSnapshotRetrieved(inner.transition(&self.ntp_adjtime)); + } + State::InitialSnapshotRetrieved( + inner @ InitialSnapshotRetrieved { instant, snapshot }, + ) if now.duration_since(*instant) >= INITIAL_SNAPSHOT_RETRIEVED_DURATION => { + self.state = State::ClockAdjusted(inner.transition( + &self.ntp_adjtime, + clock_params, + snapshot, + )); + } + State::ClockAdjusted(inner @ ClockAdjusted { instant }) + if now.duration_since(*instant) >= PHASE_CORRECTING_DURATION => + { + self.state = State::PhaseCorrectHalted(inner.transition(&self.ntp_adjtime)); } - cs @ (TIME_OK | TIME_INS | TIME_DEL | TIME_OOP | TIME_WAIT) => { - Err(NtpAdjTimeError::BadState(cs)) + State::PhaseCorrectHalted(inner @ PhaseCorrectHalted { instant }) + if now.duration_since(*instant) >= PHASE_CORRECTION_HALT_DURATION => + { + self.state = State::SnapshotRetrieved(inner.transition(&self.ntp_adjtime)); + } + State::SnapshotRetrieved(inner @ SnapshotRetrieved { instant, snapshot }) + if now.duration_since(*instant) >= SNAPSHOT_RETRIEVED_DURATION => + { + self.state = State::ClockAdjusted(inner.transition( + &self.ntp_adjtime, + clock_params, + snapshot, + )); + } + _ => { + debug!("No state transition expected"); } - -1 => Err(NtpAdjTimeError::Failure(errno::errno())), - unexpected => Err(NtpAdjTimeError::InvalidState(unexpected)), } } } #[cfg(test)] mod test { + use crate::daemon::{ + clock_state::clock_adjust::{ntp_adjtime::MockNtpAdjTime, state_machine::ClockSnapshot}, + event::SystemClockMeasurement, + time::{ + Instant, TscCount, + timex::Timex, + tsc::{Frequency, Period}, + }, + }; + use libc::TIME_ERROR; use mockall::predicate::eq; - - use crate::daemon::clock_state::clock_adjust::ntp_adjtime::MockNtpAdjTime; + use rstest::rstest; use super::*; + fn test_clock_parameters() -> ClockParameters { + ClockParameters { + tsc_count: TscCount::new(0), + period: Period::from_frequency(Frequency::from_hz(1_000_000.0)), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period_max_error: Period::from_seconds(0.0), + } + } + + fn test_clock_snapshot() -> ClockSnapshot { + ClockSnapshot { + system_clock: SystemClockMeasurement { + system_time: Instant::from_secs(0), + tsc: TscCount::new(0), + }, + kernel_state: Timex::retrieve(), + } + } + #[test] fn handle_disruption() { let disruption_marker = 123; @@ -202,4 +243,129 @@ mod test { let mut clock_adjuster = ClockAdjuster::new(mock_ntp_adj_time); clock_adjuster.handle_disruption(disruption_marker); } + + #[rstest] + #[case::initialized_to_initial_phase_correct_halted( + State::Initialized(Initialized), + State::InitialPhaseCorrectHalted(InitialPhaseCorrectHalted { instant: tokio::time::Instant::now() }), + tokio::time::Instant::now(), + 0, // not used + )] + #[case::disrupted_to_initial_phase_correct_halted( + State::Disrupted(Disrupted), + State::InitialPhaseCorrectHalted(InitialPhaseCorrectHalted { instant: tokio::time::Instant::now() }), + tokio::time::Instant::now(), + 1, + )] + #[case::initial_phase_correct_halted_no_change_if_too_early( + State::InitialPhaseCorrectHalted(InitialPhaseCorrectHalted { instant: tokio::time::Instant::now() }), + State::InitialPhaseCorrectHalted(InitialPhaseCorrectHalted { instant: tokio::time::Instant::now() }), + tokio::time::Instant::now(), + 0, + )] + #[case::initial_phase_correct_halted_to_initial_snapshot_retrieved( + State::InitialPhaseCorrectHalted(InitialPhaseCorrectHalted { instant: tokio::time::Instant::now() }), + State::InitialSnapshotRetrieved(InitialSnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), + tokio::time::Instant::now() + PHASE_CORRECTION_HALT_DURATION, + 1, + )] + #[case::initial_snapshot_retrieved_no_change_if_too_early( + State::InitialSnapshotRetrieved(InitialSnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), + State::InitialSnapshotRetrieved(InitialSnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), + tokio::time::Instant::now(), + 0, + )] + #[case::initial_snapshot_retrieved_to_clock_adjusted( + State::InitialSnapshotRetrieved(InitialSnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), + State::ClockAdjusted(ClockAdjusted { instant: tokio::time::Instant::now() }), + tokio::time::Instant::now() + INITIAL_SNAPSHOT_RETRIEVED_DURATION, + 2, + )] + #[case::clock_adjusted_no_change_if_too_early( + State::ClockAdjusted(ClockAdjusted { instant: tokio::time::Instant::now() }), + State::ClockAdjusted(ClockAdjusted { instant: tokio::time::Instant::now() }), + tokio::time::Instant::now(), + 0, + )] + #[case::clock_adjusted_to_phase_correct_halted( + State::ClockAdjusted(ClockAdjusted { instant: tokio::time::Instant::now() }), + State::PhaseCorrectHalted(PhaseCorrectHalted { instant: tokio::time::Instant::now() }), + tokio::time::Instant::now() + PHASE_CORRECTING_DURATION, + 1, + )] + #[case::phase_correct_halted_no_change_if_too_early( + State::PhaseCorrectHalted(PhaseCorrectHalted { instant: tokio::time::Instant::now() }), + State::PhaseCorrectHalted(PhaseCorrectHalted { instant: tokio::time::Instant::now() }), + tokio::time::Instant::now(), + 0, + )] + #[case::initial_phase_correct_halted_to_snapshot_retrieved( + State::PhaseCorrectHalted(PhaseCorrectHalted { instant: tokio::time::Instant::now() }), + State::SnapshotRetrieved(SnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), + tokio::time::Instant::now() + PHASE_CORRECTION_HALT_DURATION, + 1, + )] + #[case::snapshot_retrieved_no_change_if_too_early( + State::SnapshotRetrieved(SnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), + State::SnapshotRetrieved(SnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), + tokio::time::Instant::now(), + 0, + )] + #[case::snapshot_retrieved_to_clock_adjusted( + State::SnapshotRetrieved(SnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), + State::ClockAdjusted(ClockAdjusted { instant: tokio::time::Instant::now() }), + tokio::time::Instant::now() + SNAPSHOT_RETRIEVED_DURATION, + 2, + )] + #[tokio::test(start_paused = true)] + async fn handle_event( + #[case] initial_state: State, + #[case] final_state: State, + #[case] instant: tokio::time::Instant, + #[case] ntp_adjtime_call_count: usize, + ) { + let mut mock_ntp_adj_time = MockNtpAdjTime::new(); + // Silly thing, but the only case we ever expect to not return TIME_OK (0) is + // when initializing, and stepping the clock (expect TIME_ERROR in that case). + if let State::Initialized(_) = initial_state { + let ntp_adjtime_returns = [0, TIME_ERROR]; + let mut ntp_adjtime_call_count = 0; + mock_ntp_adj_time.expect_ntp_adjtime().returning(move |_| { + let ret = ntp_adjtime_returns[ntp_adjtime_call_count]; + ntp_adjtime_call_count += 1; + ret + }); + } else { + mock_ntp_adj_time + .expect_ntp_adjtime() + .times(ntp_adjtime_call_count) + .return_const(0); + } + let mut clock_adjuster = ClockAdjuster { + state: initial_state, + ntp_adjtime: mock_ntp_adj_time, + }; + clock_adjuster.handle_event(instant, &test_clock_parameters()); + match final_state { + State::Disrupted(_) => assert!(matches!(clock_adjuster.state, State::Disrupted(_))), + State::Initialized(_) => assert!(matches!(clock_adjuster.state, State::Initialized(_))), + State::InitialPhaseCorrectHalted(_) => assert!(matches!( + clock_adjuster.state, + State::InitialPhaseCorrectHalted(_) + )), + State::InitialSnapshotRetrieved(_) => assert!(matches!( + clock_adjuster.state, + State::InitialSnapshotRetrieved(_) + )), + State::ClockAdjusted(_) => { + assert!(matches!(clock_adjuster.state, State::ClockAdjusted(_))) + } + State::PhaseCorrectHalted(_) => { + assert!(matches!(clock_adjuster.state, State::PhaseCorrectHalted(_))) + } + State::SnapshotRetrieved(_) => { + assert!(matches!(clock_adjuster.state, State::SnapshotRetrieved(_))) + } + } + } } diff --git a/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs b/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs index 7773fea..180801f 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs @@ -32,6 +32,7 @@ impl NtpAdjTimeExt for KAPIClockAdjuster {} /// Noop Clock Adjuster, which doesn't actually adjust the clock parameters but just /// returns `TIME_OK`. +#[expect(unused)] pub struct NoopClockAdjuster; impl NtpAdjTime for NoopClockAdjuster { fn ntp_adjtime(&self, _tx: &mut Timex) -> i32 { diff --git a/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs b/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs index 8cfeb06..02eba2c 100644 --- a/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs +++ b/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs @@ -82,7 +82,7 @@ //! the `CLOCK_REALTIME` adjustment is steered precisely to where we would expect it to be. #![allow(clippy::doc_comment_double_space_linebreaks, reason = "hooray ascii")] use clock_bound::daemon::{ - clock_state::clock_adjust::{ClockAdjuster, KAPIClockAdjuster, NtpAdjTimeError}, + clock_state::clock_adjust::{KAPIClockAdjuster, NtpAdjTimeError, NtpAdjTimeExt}, time::{Duration, Instant, tsc::Skew}, }; use tracing::info; @@ -244,7 +244,7 @@ impl ClockAdjustTestParameters { #[allow(dead_code)] async fn reset_clock() -> Result<(), NtpAdjTimeError> { info!("Resetting clock parameters.."); - let clock_adjuster = ClockAdjuster::new(KAPIClockAdjuster); + let clock_adjuster = KAPIClockAdjuster; // Reset the kernel NTP parameters. let phase_correction = Duration::from_millis(0); let skew = Skew::from_ppm(0.0); diff --git a/test/clock-bound-adjust-clock/src/adjust_clock.rs b/test/clock-bound-adjust-clock/src/adjust_clock.rs index 88fea82..224662e 100644 --- a/test/clock-bound-adjust-clock/src/adjust_clock.rs +++ b/test/clock-bound-adjust-clock/src/adjust_clock.rs @@ -2,7 +2,7 @@ //! the timekeeping utilities internal to ClockBound. use clap::Parser; use clock_bound::daemon::{ - clock_state::clock_adjust::{ClockAdjuster, KAPIClockAdjuster}, + clock_state::clock_adjust::{KAPIClockAdjuster, NtpAdjTimeExt}, time::{Duration, tsc::Skew}, }; @@ -31,7 +31,7 @@ fn main() -> anyhow::Result<()> { let phase_correction = Duration::from_seconds_f64(args.phase_correction_seconds); let skew = Skew::from_ppm(args.skew_ppb as f64 * 1e-3); - let clock_adjuster = ClockAdjuster::new(KAPIClockAdjuster); + let clock_adjuster = KAPIClockAdjuster; clock_adjuster .adjust_clock(phase_correction, skew) .map_err(|e| anyhow::anyhow!(e))?; diff --git a/test/clock-bound-adjust-clock/src/step_clock.rs b/test/clock-bound-adjust-clock/src/step_clock.rs index 8060d8c..dfbcbd5 100644 --- a/test/clock-bound-adjust-clock/src/step_clock.rs +++ b/test/clock-bound-adjust-clock/src/step_clock.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use clap::Parser; use clock_bound::daemon::{ - clock_state::clock_adjust::{ClockAdjuster, KAPIClockAdjuster}, + clock_state::clock_adjust::{KAPIClockAdjuster, NtpAdjTimeExt}, time::Duration, }; @@ -25,7 +25,7 @@ fn main() -> anyhow::Result<()> { let initial_time: DateTime = Utc::now(); println!("Initial time is {initial_time:?}"); - let mut clock_adjuster = ClockAdjuster::new(KAPIClockAdjuster); + let clock_adjuster = KAPIClockAdjuster; clock_adjuster .step_clock(phase_correction) .map_err(|e| anyhow::anyhow!(e))?; From d5a7024ae0464b4b8bd6d59cdd659e44d1c8a5f4 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Tue, 11 Nov 2025 12:03:26 -0500 Subject: [PATCH 103/177] Fix timex freq conversion (#123) Timex freq conversion was trying to right-shift the PPM value, an integer, 16x which ends up truncating the fractional part. This means a PPM value of 1.5 is not treated well, nor is 1.9, and is converted to 1.0 when we actually read it from the timex freq. This makes our frequency conversion bad. --- clock-bound/src/daemon/time/tsc.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 59b7cd8..7eaddeb 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -302,11 +302,11 @@ impl Skew { /// This function constructs a `Skew` value from a given kernel value. pub fn from_timex_freq(timex_freq: i64) -> Self { if timex_freq >= 0 { - Self::from_ppm((timex_freq >> 16) as f64) + Self::from_ppm(timex_freq as f64 * 2.0_f64.powi(-16)) } else { // i64::MAX = -i64::MIN - 1, prefer to `saturating_neg` rather // than overflow and wrap - -Self::from_ppm((timex_freq.saturating_neg() >> 16) as f64) + -Self::from_ppm(timex_freq.saturating_neg() as f64 * 2.0_f64.powi(-16)) } } @@ -567,6 +567,8 @@ mod tests { assert_eq!(skew.to_timex_freq(), 131072); let skew = Skew::from_ppm(-1.0); assert_eq!(skew.to_timex_freq(), -65536); + let skew = Skew::from_ppm(-1.5); + assert_eq!(skew.to_timex_freq(), -98304); let skew = Skew::from_ppm(0.0); assert_eq!(skew.to_timex_freq(), 0); let skew = Skew::from_ppm(f64::MAX); @@ -581,6 +583,10 @@ mod tests { assert_abs_diff_eq!(skew.get(), 1.0 * 1.0e-6); let skew = Skew::from_timex_freq(-65536); assert_abs_diff_eq!(skew.get(), -1.0 * 1.0e-6); + let skew = Skew::from_timex_freq(98304); + assert_abs_diff_eq!(skew.get(), 1.5 * 1.0e-6); + let skew = Skew::from_timex_freq(-98304); + assert_abs_diff_eq!(skew.get(), -1.5 * 1.0e-6); let skew = Skew::from_timex_freq(0); assert_abs_diff_eq!(skew.get(), 0.0); let skew = Skew::from_timex_freq(i64::MAX); From 153c2c06de5d62941a59e288189e0b01e9539509 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Tue, 11 Nov 2025 12:05:25 -0500 Subject: [PATCH 104/177] Convert ClockState to an async actor (#111) This commit updates ClockState to be an async actor running on 100ms interval, which it will always write to SHM on, and will transition through several states for clock adjustments on at different time periods. The daemon should be able to reasonably synchronize the system clock with the ClockAdjust component handling phase corrections and frequency corrections. --- clock-bound/src/daemon.rs | 27 +- clock-bound/src/daemon/clock_state.rs | 280 +++++++++++------- .../src/daemon/clock_state/clock_adjust.rs | 28 +- .../daemon/clock_state/clock_state_writer.rs | 51 ++-- 4 files changed, 241 insertions(+), 145 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index b142edd..46ba8b8 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -31,10 +31,8 @@ use crate::daemon::{ }; #[cfg(not(feature = "test-side-by-side"))] -use crate::daemon::clock_state::{ - ClockState, - clock_adjust::{ClockAdjuster, KAPIClockAdjuster}, - clock_state_writer::{ClockStateWriter, SafeShmWriter}, +use crate::daemon::{ + async_ring_buffer::Sender, clock_parameters::ClockParameters, clock_state::ClockState, }; use rand::{RngCore, rng}; @@ -47,16 +45,16 @@ use rand::{RngCore, rng}; /// updated measurement the clock error bound will increase by 15 microseconds. /// /// This number is based on CPU spec sheet error tolerances -const MAX_DISPERSION_GROWTH_PBB: u32 = 15_000; +pub(crate) const MAX_DISPERSION_GROWTH_PPB: u32 = 15_000; -const MAX_DISPERSION_GROWTH: Skew = Skew::from_ppb(MAX_DISPERSION_GROWTH_PBB as f64); +const MAX_DISPERSION_GROWTH: Skew = Skew::from_ppb(MAX_DISPERSION_GROWTH_PPB as f64); pub struct Daemon { _io_front_end: io::SourceIO, clock_sync_algorithm: ClockSyncAlgorithm, receiver_stream: ReceiverStream, #[cfg(not(feature = "test-side-by-side"))] - clock_state: ClockState, ClockStateWriter>, + clock_state_tx: Sender, } impl Daemon { @@ -69,7 +67,11 @@ impl Daemon { startup_id: rng().next_u64(), }; #[cfg(not(feature = "test-side-by-side"))] - let clock_state = ClockState::construct(); + let (clock_state_tx, mut clock_state) = { + let (tx, rx) = async_ring_buffer::create(1); + let clock_state = ClockState::construct(rx); + (tx, clock_state) + }; let selected_clock = Arc::new(SelectedClockSource::default()); let clock_sync_algorithm = ClockSyncAlgorithm::builder() @@ -115,13 +117,16 @@ impl Daemon { // Start IO polling io_front_end.spawn_all(); - + #[cfg(not(feature = "test-side-by-side"))] + tokio::spawn(async move { + clock_state.run().await; + }); Self { _io_front_end: io_front_end, clock_sync_algorithm, receiver_stream, #[cfg(not(feature = "test-side-by-side"))] - clock_state, + clock_state_tx, } } @@ -140,7 +145,7 @@ impl Daemon { fn handle_event(&mut self, routable_event: RoutableEvent) { #[cfg(not(feature = "test-side-by-side"))] if let Some(params) = self.clock_sync_algorithm.feed(routable_event) { - self.clock_state.handle_clock_parameters(params); + self.clock_state_tx.send(params.clone()).unwrap(); } #[cfg(feature = "test-side-by-side")] let _ = self.clock_sync_algorithm.feed(routable_event); diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 5db9e0d..739bd89 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -2,17 +2,17 @@ pub mod clock_adjust; pub mod clock_state_writer; -use crate::daemon::clock_state::clock_adjust::{ClockAdjust, KAPIClockAdjuster}; +use tracing::info; + +use crate::daemon::MAX_DISPERSION_GROWTH_PPB; +use crate::daemon::async_ring_buffer::Receiver; +use crate::daemon::clock_parameters::ClockParameters; +use crate::daemon::clock_state::clock_adjust::{ClockAdjust, ClockAdjuster, KAPIClockAdjuster}; +use crate::daemon::clock_state::clock_state_writer::ClockStateWriter; use crate::daemon::clock_state::clock_state_writer::{ClockStateWrite, SafeShmWriter}; -use crate::daemon::clock_state::{ - clock_adjust::ClockAdjuster, clock_state_writer::ClockStateWriter, -}; use crate::daemon::io::tsc::ReadTscImpl; -use crate::daemon::time::clocks::RealTime; -use crate::daemon::{ - clock_parameters::ClockParameters, - time::{ClockExt, clocks::ClockBound}, -}; +use crate::daemon::time::ClockExt; +use crate::daemon::time::clocks::{ClockBound, RealTime}; use crate::shm::{CLOCKBOUND_SHM_DEFAULT_PATH, ShmWriter}; /// The whole `ClockState` component struct. @@ -20,17 +20,78 @@ use crate::shm::{CLOCKBOUND_SHM_DEFAULT_PATH, ShmWriter}; /// with the `CLOCK_REALTIME` kernel clock to synchronize it with `ClockBound` estimate /// of UTC (`ClockBound` clock), and `ClockStateWriter` which manages writing /// the `ClockErrorBound` to SHM segment for the client to read. -pub(crate) struct ClockState { - clock_state_writer: S, - clock_adjuster: A, +pub(crate) struct ClockState { + state_writer: Box, + clock_adjuster: Box, + clock_parameters: Option, + interval: tokio::time::Interval, + clock_params_receiver: Receiver, } -#[cfg_attr(not(test), expect(unused))] -impl ClockState { - pub fn new(clock_state_writer: S, clock_adjust: A) -> Self { +#[cfg_attr(feature = "test-side-by-side", expect(unused))] +impl ClockState { + pub fn new( + clock_state_writer: Box, + clock_adjuster: Box, + clock_params_receiver: Receiver, + ) -> Self { + let interval = tokio::time::interval(tokio::time::Duration::from_millis(100)); Self { - clock_state_writer, - clock_adjuster: clock_adjust, + state_writer: clock_state_writer, + clock_adjuster, + interval, + clock_params_receiver, + clock_parameters: None, + } + } + + pub fn construct(clock_params_receiver: Receiver) -> Self { + let shm_writer = ShmWriter::new(std::path::Path::new(CLOCKBOUND_SHM_DEFAULT_PATH)).unwrap(); + let safe_shm_writer = SafeShmWriter::new(shm_writer); + let clock_state_writer: ClockStateWriter = ClockStateWriter::builder() + .clock_disruption_support_enabled(true) + .shm_writer(safe_shm_writer) + .max_drift_ppb(MAX_DISPERSION_GROWTH_PPB) + .disruption_marker(0) + .build(); + let clock_adjuster: ClockAdjuster = + ClockAdjuster::new(KAPIClockAdjuster); + Self::new( + Box::new(clock_state_writer), + Box::new(clock_adjuster), + clock_params_receiver, + ) + } + + pub async fn run(&mut self) { + info!("Starting run for ClockState"); + loop { + tokio::select! { + now = self.interval.tick() => { + self.handle_tick(now); + }, + params = self.clock_params_receiver.recv() => { + self.handle_clock_parameters(params.unwrap()); // todo fixme + } + } + } + } + + fn handle_tick(&mut self, now: tokio::time::Instant) { + if let Some(parameters) = &self.clock_parameters { + self.clock_adjuster.handle_clock_parameters(now, parameters); + let clock_status = self.clock_adjuster.get_clock_realtime_status(); + // FIXME: Initializing behavior of ClockStateWriter should have us write + // the initial clock status as unknown to SHM segment. Else, the clock might be adjusted + // by ClockAdjuster on its initial state, while the SHM isn't updated with that info.. + let clockbound_clock = ClockBound::new(parameters.clone(), ReadTscImpl); + // TODO: implement multiple attempts in case of latency increase + let clock_realtime_offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); + self.state_writer.handle_clock_parameters( + parameters, + clock_status, + clock_realtime_offset_and_rtt, + ); } } @@ -39,19 +100,12 @@ impl ClockState { /// # Panics /// If the `ClockAdjuster` fails, e.g. an invalid value was supplied to `ntp_adjtime`, or /// insufficient permissions to adjust the clock. - pub fn handle_clock_parameters( + pub(crate) fn handle_clock_parameters( &mut self, // This is needed to tell ClockAdjust what frequency to use. - clock_parameters: &ClockParameters, + clock_parameters: ClockParameters, ) { - let clockbound_clock = ClockBound::new(clock_parameters.clone(), ReadTscImpl); - let clock_realtime_offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); - // FIXME: this implementation is incorrect as is and will be fixed in upcoming commit - // to integrate with the Daemon. - self.clock_adjuster - .handle_clock_parameters(tokio::time::Instant::now(), clock_parameters); - self.clock_state_writer - .handle_clock_parameters(clock_parameters, clock_realtime_offset_and_rtt); + self.clock_parameters = Some(clock_parameters); } /// Handle a clock disruption event @@ -59,45 +113,37 @@ impl ClockState { /// Call this function after the system detects a VMClock disruption event. /// /// It will go through and clear the state (like startup). + #[cfg_attr(not(test), expect(unused))] pub fn handle_disruption(&mut self, new_disruption_marker: u64) { // Use the destructure pattern to get a mutable reference to each item. // // This makes it a compilation error if we add a new field this Self without handling it here let Self { clock_adjuster, - clock_state_writer, + state_writer: clock_state_writer, + clock_params_receiver, + interval: _, + clock_parameters, } = self; - + *clock_parameters = None; + clock_params_receiver.handle_disruption(); clock_adjuster.handle_disruption(new_disruption_marker); clock_state_writer.handle_disruption(new_disruption_marker); tracing::info!("Handled clock disruption event"); } } -impl ClockState, ClockStateWriter> { - #[cfg_attr(feature = "test-side-by-side", expect(unused))] - pub fn construct() -> Self { - let shm_writer = ShmWriter::new(std::path::Path::new(CLOCKBOUND_SHM_DEFAULT_PATH)).unwrap(); - let safe_shm_writer = SafeShmWriter::new(shm_writer); - let clock_state_writer: ClockStateWriter = ClockStateWriter::builder() - .clock_disruption_support_enabled(true) - .shm_writer(safe_shm_writer) - .max_drift_ppb(15_000) - .disruption_marker(0) - .build(); - let clock_adjuster: ClockAdjuster = - ClockAdjuster::new(KAPIClockAdjuster); - Self::new(clock_state_writer, clock_adjuster) - } -} - #[cfg(test)] mod tests { use mockall::predicate::eq; - use crate::daemon::{ - clock_state::{clock_adjust::MockClockAdjust, clock_state_writer::MockClockStateWrite}, - time::{Duration, Instant, TscCount, inner::ClockOffsetAndRtt, instant::Utc, tsc::Period}, + use crate::{ + daemon::{ + async_ring_buffer, + clock_state::{clock_adjust::MockClockAdjust, clock_state_writer::MockClockStateWrite}, + time::{Duration, Instant, TscCount, tsc::Period}, + }, + shm::ClockStatus, }; use super::*; @@ -112,103 +158,125 @@ mod tests { } } - #[test] - fn handle_clock_parameters() { + #[tokio::test] + async fn handle_clock_parameters() { let clock_parameters = get_sample_clock_parameters(); - let clock_parameters_clone = clock_parameters.clone(); let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() - .once() - .withf( - move |_now: &tokio::time::Instant, clock_params: &ClockParameters| { - *clock_params == clock_parameters_clone - }, - ) + .never() .return_const(()); - let clock_parameters_clone = clock_parameters.clone(); let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer .expect_handle_clock_parameters() - .once() - .withf( - move |clock_params: &ClockParameters, _offset_and_rtt: &ClockOffsetAndRtt| { - *clock_params == clock_parameters_clone - }, - ) + .never() .return_const(()); - let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); - clock_state.handle_clock_parameters(&clock_parameters); + let (_tx, rx) = async_ring_buffer::create(1); + let mut clock_state = ClockState::new( + Box::new(mock_clock_state_writer), + Box::new(mock_clock_adjuster), + rx, + ); + assert_eq!(clock_state.clock_parameters, None); + clock_state.handle_clock_parameters(clock_parameters.clone()); + assert_eq!(clock_state.clock_parameters, Some(clock_parameters)); } - #[test] - #[should_panic] - fn handle_clock_parameters_clock_adjust_hard_failure() { - let clock_parameters = get_sample_clock_parameters(); - let clock_parameters_clone = clock_parameters.clone(); + #[tokio::test] + async fn handle_disruption() { + let disruption_marker = 123; let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster - .expect_handle_clock_parameters() + .expect_handle_disruption() .once() - .withf( - move |_now: &tokio::time::Instant, clock_params: &ClockParameters| { - *clock_params == clock_parameters_clone - }, - ) + .with(eq(disruption_marker)) .return_const(()); - let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer - .expect_handle_clock_parameters() - .never(); - - let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); - clock_state.handle_clock_parameters(&clock_parameters); + .expect_handle_disruption() + .once() + .with(eq(disruption_marker)) + .return_const(()); + let (_tx, rx) = async_ring_buffer::create(1); + let mut clock_state = ClockState::new( + Box::new(mock_clock_state_writer), + Box::new(mock_clock_adjuster), + rx, + ); + clock_state.handle_disruption(disruption_marker); } - #[test] - fn handle_clock_parameters_clock_adjust_soft_failure() { - let clock_parameters = get_sample_clock_parameters(); - let clock_parameters_clone = clock_parameters.clone(); + #[tokio::test] + async fn handle_tick_no_parameters() { let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() - .once() - .withf( - move |_now: &tokio::time::Instant, clock_params: &ClockParameters| { - *clock_params == clock_parameters_clone - }, - ) + .never() .return_const(()); let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer .expect_handle_clock_parameters() - .once() + .never() .return_const(()); - let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); - clock_state.handle_clock_parameters(&clock_parameters); + let (_tx, rx) = async_ring_buffer::create(1); + let mut clock_state = ClockState::new( + Box::new(mock_clock_state_writer), + Box::new(mock_clock_adjuster), + rx, + ); + clock_state.clock_parameters = None; + clock_state.handle_tick(tokio::time::Instant::now()); } - #[test] - fn handle_disruption() { - let disruption_marker = 123; + #[tokio::test(start_paused = true)] + async fn handle_tick_with_parameters() { + let mut sequence = mockall::Sequence::new(); + let expected_clock_status = ClockStatus::Synchronized; + let expected_clock_params = get_sample_clock_parameters(); + let expected_instant = tokio::time::Instant::now(); let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); + + let expected_clock_params_clone = expected_clock_params.clone(); mock_clock_adjuster - .expect_handle_disruption() + .expect_handle_clock_parameters() .once() - .with(eq(disruption_marker)) + .withf(move |actual_instant, actual_clock_params| { + *actual_instant == expected_instant + && *actual_clock_params == expected_clock_params_clone + }) + .in_sequence(&mut sequence) .return_const(()); + mock_clock_adjuster + .expect_get_clock_realtime_status() + .once() + .in_sequence(&mut sequence) + .return_const(expected_clock_status); + let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); + let expected_clock_params_clone = expected_clock_params.clone(); mock_clock_state_writer - .expect_handle_disruption() + .expect_handle_clock_parameters() .once() - .with(eq(disruption_marker)) + .withf( + move |actual_clock_params, actual_clock_status, _offset_and_rtt| { + *actual_clock_params == expected_clock_params_clone + && *actual_clock_status == expected_clock_status + }, + ) + .in_sequence(&mut sequence) .return_const(()); - let mut clock_state = ClockState::new(mock_clock_state_writer, mock_clock_adjuster); - clock_state.handle_disruption(disruption_marker); + + let (_tx, rx) = async_ring_buffer::create(1); + let mut clock_state = ClockState::new( + Box::new(mock_clock_state_writer), + Box::new(mock_clock_adjuster), + rx, + ); + clock_state.clock_parameters = Some(expected_clock_params); + clock_state.handle_tick(expected_instant); } } diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index ab6cb83..d7db441 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -19,9 +19,12 @@ //! leaving the system in a bad state if in the middle of a slew and the daemon is terminated. use tracing::{debug, error, info}; -use crate::daemon::{ - clock_parameters::ClockParameters, - time::{Duration, tsc::Skew}, +use crate::{ + daemon::{ + clock_parameters::ClockParameters, + time::{Duration, tsc::Skew}, + }, + shm::ClockStatus, }; mod ntp_adjtime; @@ -36,6 +39,10 @@ pub(crate) trait ClockAdjust: Send + Sync { clock_parameters: &ClockParameters, ); fn handle_disruption(&mut self, new_disruption_marker: u64); + /// Helper to find out if `CLOCK_REALTIME` is now reliable... + /// Any initial states can be considered unreliable, since we have not + /// corrected or been able to measure the clock at all. + fn get_clock_realtime_status(&self) -> ClockStatus; } pub struct ClockAdjuster { @@ -81,6 +88,21 @@ impl ClockAdjust for ClockAdjuster { // for faster recovery.. info!("Handled clock disruption event"); } + + /// Helper to find out if `CLOCK_REALTIME` is now reliable... + /// Any initial states can be considered unreliable, since we have not + /// corrected or been able to measure the clock at all. + fn get_clock_realtime_status(&self) -> ClockStatus { + match &self.state { + State::Disrupted(_) => ClockStatus::Disrupted, + State::Initialized(_) + | State::InitialPhaseCorrectHalted { .. } + | State::InitialSnapshotRetrieved { .. } => ClockStatus::Unknown, + State::ClockAdjusted { .. } + | State::PhaseCorrectHalted { .. } + | State::SnapshotRetrieved { .. } => ClockStatus::Synchronized, + } + } } /// Duration for which to wait for phase correction halting to complete. diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index 6da4041..fe4a053 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -39,7 +39,6 @@ unsafe impl Send for SafeShmWriter {} unsafe impl Sync for SafeShmWriter {} pub struct ClockStateWriter { - clock_status: ClockStatus, clock_disruption_support_enabled: bool, shm_writer: T, max_drift_ppb: u32, @@ -47,30 +46,27 @@ pub struct ClockStateWriter { } #[cfg_attr(test, mockall::automock)] -pub trait ClockStateWrite { +pub trait ClockStateWrite: Send + Sync { fn handle_clock_parameters( &mut self, clock_parameters: &ClockParameters, + clock_status: ClockStatus, clock_realtime_offset_and_rtt: ClockOffsetAndRtt, ); fn handle_disruption(&mut self, new_disruption_marker: u64); } -impl ClockStateWrite for ClockStateWriter { +impl ClockStateWrite for ClockStateWriter { /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. /// /// # Panics /// Panics if error bound calculated exceeds `i64::MAX` fn handle_clock_parameters( &mut self, - // This is needed to tell ClockAdjust what frequency to use. clock_parameters: &ClockParameters, - // This is needed to tell ClockAdjust what phase offset to use. + clock_status: ClockStatus, clock_realtime_offset_and_rtt: ClockOffsetAndRtt, ) { - // If we've received `ClockParameters`, it means we were able to construct a valid `ClockBound` clock. - // Thus, mark ourselves as synchronized. - self.clock_status = ClockStatus::Synchronized; let bound = get_bound(clock_parameters, clock_realtime_offset_and_rtt); // Unwrap safety: sane error bound should be less than `i64::MAX` let bound_nsec = i64::try_from(bound.as_nanos()).unwrap(); @@ -82,7 +78,7 @@ impl ClockStateWrite for ClockStateWriter { // For the sake of backwards compatibility, we will have our initial/alpha release continue to work // using `CLOCK_MONOTONIC_COARSE`. let as_of = MonotonicCoarse.get_time(); - self.write_shm(as_of, bound_nsec); + self.write_shm(as_of, bound_nsec, clock_status); } /// Handle a clock disruption event @@ -95,17 +91,18 @@ impl ClockStateWrite for ClockStateWriter { // // This makes it a compilation error if we add a new field to Self without handling it here let Self { - clock_status, clock_disruption_support_enabled: _, shm_writer: _, max_drift_ppb: _, disruption_marker, } = self; - *clock_status = ClockStatus::Disrupted; *disruption_marker = new_disruption_marker; let as_of = MonotonicCoarse.get_time(); - info!("Writing `ClockStatus::Disrupted` to SHM with 0 `bound_nsec`"); - self.write_shm(as_of, 0); // We're writing that we're disrupted anyways, so the `bound_nsec` value should be useless here, 0 is ok + info!( + "Writing `ClockStatus::Disrupted` to SHM with 0 `bound_nsec` and `ClockStatus::Disrupted`" + ); + // We're writing that we're disrupted anyways, so the `bound_nsec` value should be useless here, 0 is ok + self.write_shm(as_of, 0, ClockStatus::Disrupted); tracing::info!("Handled clock disruption event"); } } @@ -120,7 +117,6 @@ impl ClockStateWriter { disruption_marker: u64, ) -> Self { Self { - clock_status: ClockStatus::Unknown, clock_disruption_support_enabled, shm_writer, max_drift_ppb, @@ -128,7 +124,7 @@ impl ClockStateWriter { } } - fn write_shm(&mut self, as_of: Instant, bound_nsec: i64) { + fn write_shm(&mut self, as_of: Instant, bound_nsec: i64, clock_status: ClockStatus) { let void_after = as_of + Duration::from_secs(1000); // TODO: It may be worthwhile to add to this max drift ppb base the following components: // - any slew rate for phase correction, since kernel clocks are used on client side @@ -144,7 +140,7 @@ impl ClockStateWriter { bound_nsec, self.disruption_marker, max_drift_ppb, - self.clock_status, + clock_status, self.clock_disruption_support_enabled, ); self.shm_writer.write(&ceb); @@ -165,7 +161,7 @@ fn get_bound( let realtime_to_clockbound_measured_offset = clock_realtime_offset_and_rtt.offset(); let measurement_rtt = clock_realtime_offset_and_rtt.rtt(); let bound_between_realtime_and_clockbound = - realtime_to_clockbound_measured_offset + measurement_rtt / 2; + realtime_to_clockbound_measured_offset.abs() + measurement_rtt / 2; clock_parameters.clock_error_bound + bound_between_realtime_and_clockbound } @@ -245,8 +241,7 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer.clock_status = clock_status; - clock_state_writer.write_shm(as_of, bound_nsec); + clock_state_writer.write_shm(as_of, bound_nsec, clock_status); } #[test] @@ -263,6 +258,7 @@ mod tests { let clock_disruption_support_enabled = false; let max_drift_ppb = 0; let disruption_marker = 0; + let clock_status = ClockStatus::Synchronized; let mut shm_writer = MockShmWriter::new(); shm_writer .expect_write() @@ -272,7 +268,7 @@ mod tests { && ceb.bound_nsec() == 2250 && ceb.disruption_marker() == disruption_marker && ceb.max_drift_ppb() == max_drift_ppb - && ceb.clock_status() == ClockStatus::Synchronized // If we're getting clock parameters, we're "Synchronized" + && ceb.clock_status() == clock_status && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled }) .times(1) @@ -283,8 +279,11 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer - .handle_clock_parameters(&clock_parameters, clock_realtime_offset_and_rtt); + clock_state_writer.handle_clock_parameters( + &clock_parameters, + clock_status, + clock_realtime_offset_and_rtt, + ); } #[test] @@ -310,8 +309,11 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer - .handle_clock_parameters(&clock_parameters, clock_realtime_offset_and_rtt); + clock_state_writer.handle_clock_parameters( + &clock_parameters, + ClockStatus::Synchronized, + clock_realtime_offset_and_rtt, + ); } #[rstest] @@ -374,7 +376,6 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(initial_disruption_marker) .build(); - assert_eq!(clock_state_writer.clock_status, ClockStatus::Unknown); assert_eq!( clock_state_writer.disruption_marker, initial_disruption_marker From 05e1f8f2e72998f9c8372bf3e2ee18fcc20d7534 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 11 Nov 2025 15:55:31 -0500 Subject: [PATCH 105/177] [ff::Ntp] early exit if the fed in event does not meet the threshold (#116) * [ff::Ntp] early exit if the fed in event does not meet the threshold If the fed in event does not meet the threshold, then it cannot improve the clock error bound. In addition to being an optimization of algorithm work, it is load bearing. If the value cannot improve, we should not be using it for clock-error-bound calculations. We could make another change to make this more explicit. tbd. * Revision: naming and doc comments * Revision: same logic on phc side --- .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 31 ++++++++++++++----- .../src/daemon/clock_sync_algorithm/ff/phc.rs | 31 ++++++++++++++----- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 40faee4..6f7d216 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -52,22 +52,27 @@ impl Ntp { /// Feed an event into this algorithm /// /// Returns [`Some`] if the event has improved this source's [`ClockParameters`]. - #[expect( - clippy::missing_panics_doc, - reason = "panics documented and only occur from bugs" - )] pub fn feed(&mut self, event: event::Ntp) -> Option<&ClockParameters> { let tsc_midpoint = event.tsc_midpoint(); // First update the internal local (current SKM) and estimate (long term) // sample buffers - self.feed_internal_buffers(event) + let within_threshold = self + .feed_internal_buffers(event) .inspect_err(|error_msg| match error_msg { FeedError::Old { event, .. } => { tracing::warn!(?event, ?error_msg); } }) - .ok()?; // early exit only if there was an error with the sample + .ok()?; // early exit if there was an error with the sample + + if !within_threshold { + // At this point, if the input does not meet our expectations on the rtt threshold, + // there is no more processing to do. The end calculation will not be more accurate than + // the previous value (if we have one) + tracing::trace!("Early exit. Event not within threshold"); + return None; + } // Functionality from this point will fill out the equation // `C(t) = TSC(t) × p^ + K − θ^(t)` where: @@ -91,6 +96,7 @@ impl Ntp { // expect because not having an uncorrected clock is a bug at this point // // If we are able to calculate a local period, then uncorrected clock must be available + #[expect(clippy::missing_panics_doc, reason = "comment above")] let uncorrected_clock = self .uncorrected_clock .expect("No uncorrected period but we have local period"); @@ -148,10 +154,15 @@ impl Ntp { /// Feed the internal buffers with a new event /// + /// Returns `Ok(true)` if the value is within the local buffer's min rtt threshold + /// + /// Returns `Ok(false)` if the inserted value exceeds the local buffer's min rtt threshold + /// /// This updates the local buffer with the event. If a `period` has been calculated already, /// then we can use this value to update the `estimate` buffer as well. #[expect(clippy::result_large_err, reason = "value moved on err is idiomatic")] - fn feed_internal_buffers(&mut self, event: event::Ntp) -> Result<(), FeedError> { + fn feed_internal_buffers(&mut self, event: event::Ntp) -> Result> { + let event_rtt = event.rtt(); self.local.feed(event)?; if let Some(uc) = self.uncorrected_clock { @@ -160,7 +171,11 @@ impl Ntp { tracing::info!(?new_estimate, "New value added to estimate buffer"); } } - Ok(()) + // unwrap okay. Above line ensures there is at least one value in the buffer + let min_rtt_event = self.local.as_ref().min_rtt().unwrap(); + let within_threshold = + event_rtt <= (min_rtt_event.rtt() * self.local.rtt_threshold_multiplier()); + Ok(within_threshold) } /// Handle a disruption event diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs index d896dcf..8b4eb93 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs @@ -52,22 +52,27 @@ impl Phc { /// Feed an event into this algorithm /// /// Returns [`Some`] if the event has improved this source's [`ClockParameters`]. - #[expect( - clippy::missing_panics_doc, - reason = "panics documented and only occur from bugs" - )] pub fn feed(&mut self, event: event::Phc) -> Option<&ClockParameters> { let tsc_midpoint = event.tsc_midpoint(); // First update the internal local (current SKM) and estimate (long term) // sample buffers - self.feed_internal_buffers(event) + let within_threshold = self + .feed_internal_buffers(event) .inspect_err(|error_msg| match error_msg { FeedError::Old { event, .. } => { tracing::warn!(?event, ?error_msg); } }) - .ok()?; // early exit only if there was an error with the sample + .ok()?; // early exit if there was an error with the sample + + if !within_threshold { + // At this point, if the input does not meet our expectations on the rtt threshold, + // there is no more processing to do. The end calculation will not be more accurate than + // the previous value (if we have one) + tracing::trace!("Early exit. Event not within threshold"); + return None; + } // Functionality from this point will fill out the equation // `C(t) = TSC(t) × p^ + K − θ^(t)` where: @@ -91,6 +96,7 @@ impl Phc { // expect because not having an uncorrected clock is a bug at this point // // If we are able to calculate a local period, then uncorrected clock must be available + #[expect(clippy::missing_panics_doc, reason = "comment above")] let uncorrected_clock = self .uncorrected_clock .expect("No uncorrected period but we have local period"); @@ -148,6 +154,10 @@ impl Phc { /// Feed the internal buffers with a new event /// + /// Returns `Ok(true)` if the value is within the local buffer's min rtt threshold + /// + /// Returns `Ok(false)` if the inserted value exceeds the local buffer's min rtt threshold + /// /// This updates the local buffer with the event. If a `period` has been calculated already, /// then we can use this value to update the `estimate` buffer as well. #[cfg_attr( @@ -157,7 +167,8 @@ impl Phc { reason = "returning passed in value is idiomatic" ) )] - fn feed_internal_buffers(&mut self, event: event::Phc) -> Result<(), FeedError> { + fn feed_internal_buffers(&mut self, event: event::Phc) -> Result> { + let event_rtt = event.rtt(); self.local.feed(event)?; if let Some(uc) = self.uncorrected_clock { @@ -166,7 +177,11 @@ impl Phc { tracing::info!(?new_estimate, "New value added to estimate buffer"); } } - Ok(()) + // unwrap okay. Above line ensures there is at least one value in the buffer + let min_rtt_event = self.local.as_ref().min_rtt().unwrap(); + let within_threshold = + event_rtt <= (min_rtt_event.rtt() * self.local.rtt_threshold_multiplier()); + Ok(within_threshold) } /// Handle a disruption event From b6ad1372e09e957f1f3ab1a27a3d5788a2f2f024 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 11 Nov 2025 16:56:35 -0500 Subject: [PATCH 106/177] [ff] Minor fixups (#126) * Minor fixups hte -> the dispersion explanation comment construct ff with polling period instead of capacity * Revision: minor const fixup --- clock-bound/src/daemon.rs | 5 +++-- clock-bound/src/daemon/async_ring_buffer.rs | 2 +- .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 17 +++++++++++++++-- .../src/daemon/clock_sync_algorithm/ff/phc.rs | 18 ++++++++++++++++-- .../clock_sync_algorithm/source/link_local.rs | 15 ++------------- .../clock_sync_algorithm/source/ntp_source.rs | 17 +++-------------- clock-bound/src/daemon/event/phc.rs | 10 +++++----- clock-bound/src/daemon/time/inner.rs | 3 +++ 8 files changed, 48 insertions(+), 39 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 46ba8b8..7cf33d2 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -38,8 +38,9 @@ use rand::{RngCore, rng}; /// The maximum dispersion growth every second /// -/// Whenever a clock error bound measurement is made, that value increases by this value -/// for every second that the measurement becomes stale. +/// In between updates, the clock error bound continuously grows to take into account the worse +/// case drift of the underlying oscillator. The maximum dispersion is the rate of growth applied +/// to the last clock error bound update. /// /// If the value is 15,000 parts per billion, for example, then every second we go without an /// updated measurement the clock error bound will increase by 15 microseconds. diff --git a/clock-bound/src/daemon/async_ring_buffer.rs b/clock-bound/src/daemon/async_ring_buffer.rs index a16e204..dd24c48 100644 --- a/clock-bound/src/daemon/async_ring_buffer.rs +++ b/clock-bound/src/daemon/async_ring_buffer.rs @@ -147,7 +147,7 @@ impl Receiver { /// /// # Errors /// This method returns [`BufferClosedError`] if the paired [`Sender`] has dropped (destructed). - /// This can be used as a signal to clean up paired resources on this side of hte channel. + /// This can be used as a signal to clean up paired resources on this side of the channel. /// /// # Cancel safety /// This method is cancel safe. diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 6f7d216..b97b54f 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -39,7 +39,20 @@ impl Ntp { /// `local_capacity` should be the number of data-points to span an SKM window. /// For example, if the source is expected to sample once every second, the `local_capacity` /// should have a max value of 1024. - pub fn new(local_capacity: NonZeroUsize, max_dispersion: Skew) -> Self { + /// + /// # Panics + /// Panics if poll duration is zero or greater than or equal to 512 seconds + pub fn new(poll_period: Duration, max_dispersion: Skew) -> Self { + assert!(poll_period > Duration::ZERO, "poll period must be positive"); + assert!( + poll_period < event_buffer::Local::::SKM_WINDOW / 2, + "Must be able to get at least 2 samples in local buffer" + ); + let local_capacity = + event_buffer::Local::::SKM_WINDOW.get() / poll_period.get(); + + // unwrap: input is bound and numerator is never 0 + let local_capacity = NonZeroUsize::new(local_capacity.try_into().unwrap()).unwrap(); Self { local: event_buffer::Local::new(local_capacity), estimate: event_buffer::Estimate::new(), @@ -794,7 +807,7 @@ mod tests { #[test] fn feed_two_events() { - let mut ff = Ntp::new(NonZeroUsize::new(5).unwrap(), Skew::from_ppm(15.0)); + let mut ff = Ntp::new(Duration::from_secs(50), Skew::from_ppm(15.0)); let event1 = event::Ntp::builder() .tsc_pre(TscCount::new(1_000_000_000)) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs index 8b4eb93..c6bfdd4 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs @@ -39,7 +39,21 @@ impl Phc { /// `local_capacity` should be the number of data-points to span an SKM window. /// For example, if the source is expected to sample once every second, the `local_capacity` /// should have a max value of 1024. - pub fn new(local_capacity: NonZeroUsize, max_dispersion: Skew) -> Self { + /// + /// # Panics + /// Panics if poll duration is zero or greater than or equal to 512 seconds + pub fn new(poll_period: Duration, max_dispersion: Skew) -> Self { + assert!(poll_period > Duration::ZERO, "poll period must be positive"); + assert!( + poll_period < event_buffer::Local::::SKM_WINDOW / 2, + "Must be able to get at least 2 samples in local buffer" + ); + let local_capacity = + event_buffer::Local::::SKM_WINDOW.get() / poll_period.get(); + + // unwrap: input is bound and numerator is never 0 + let local_capacity = NonZeroUsize::new(local_capacity.try_into().unwrap()).unwrap(); + Self { local: event_buffer::Local::new(local_capacity), estimate: event_buffer::Estimate::new(), @@ -728,7 +742,7 @@ mod tests { #[test] fn feed_two_events() { - let mut ff = Phc::new(NonZeroUsize::new(5).unwrap(), Skew::from_ppm(15.0)); + let mut ff = Phc::new(Duration::from_secs(50), Skew::from_ppm(15.0)); let event1 = event::Phc::builder() .tsc_pre(TscCount::new(1_000_000_000)) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs index 20cb491..b150086 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs @@ -1,9 +1,7 @@ //! Link local source -use std::num::NonZeroUsize; - use crate::daemon::clock_parameters::ClockParameters; -use crate::daemon::clock_sync_algorithm::ff::{self, event_buffer}; +use crate::daemon::clock_sync_algorithm::ff; use crate::daemon::event; use crate::daemon::time::Duration; use crate::daemon::time::tsc::Skew; @@ -19,19 +17,10 @@ pub struct LinkLocal { impl LinkLocal { const POLL_INTERVAL: Duration = Duration::from_secs(2); - // Poll every 2 seconds. Capacity is 1024 / 2 = 512 - const CAPACITY: NonZeroUsize = { - let capacity = - event_buffer::Local::<()>::SKM_WINDOW.as_seconds() / Self::POLL_INTERVAL.as_seconds(); - assert!(capacity > 0); - #[expect(clippy::cast_sign_loss)] - NonZeroUsize::new(capacity as usize).unwrap() - }; - /// Create a new Link Local reference clock source pub fn new(max_dispersion: Skew) -> Self { Self { - inner: ff::Ntp::new(Self::CAPACITY, max_dispersion), + inner: ff::Ntp::new(Self::POLL_INTERVAL, max_dispersion), } } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs index 39a682b..eaf891b 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs @@ -1,10 +1,9 @@ //! NTP Source source use std::net::SocketAddr; -use std::num::NonZeroUsize; use crate::daemon::clock_parameters::ClockParameters; -use crate::daemon::clock_sync_algorithm::ff::{self, event_buffer}; +use crate::daemon::clock_sync_algorithm::ff; use crate::daemon::event; use crate::daemon::io::ntp::AWS_TEMP_PUBLIC_TIME_ADDRESSES; use crate::daemon::time::Duration; @@ -22,21 +21,11 @@ pub struct NTPSource { impl NTPSource { const POLL_INTERVAL: Duration = Duration::from_secs(16); - // Poll every 16 seconds. Capacity is 1024 / 16 = 64 - const CAPACITY: NonZeroUsize = { - let capacity = - event_buffer::Local::<()>::SKM_WINDOW.as_seconds() / Self::POLL_INTERVAL.as_seconds(); - // Check on this >>> - assert!(capacity > 0); - #[expect(clippy::cast_sign_loss)] - NonZeroUsize::new(capacity as usize).unwrap() - }; - /// Create a new NTP Source reference clock source pub fn new(source_address: SocketAddr, max_dispersion: Skew) -> Self { Self { source_address, - inner: ff::Ntp::new(Self::CAPACITY, max_dispersion), + inner: ff::Ntp::new(Self::POLL_INTERVAL, max_dispersion), } } @@ -46,7 +35,7 @@ impl NTPSource { for address in AWS_TEMP_PUBLIC_TIME_ADDRESSES { sources.push(Self { source_address: address, - inner: ff::Ntp::new(Self::CAPACITY, max_dispersion), + inner: ff::Ntp::new(Self::POLL_INTERVAL, max_dispersion), }); } sources diff --git a/clock-bound/src/daemon/event/phc.rs b/clock-bound/src/daemon/event/phc.rs index 4ebc63d..0088dd4 100644 --- a/clock-bound/src/daemon/event/phc.rs +++ b/clock-bound/src/daemon/event/phc.rs @@ -366,22 +366,22 @@ mod tests { Duration::from_secs(0) // Expected zero offset )] fn calculate_offset( - #[case] ntp_event: Phc, + #[case] phc_event: Phc, #[case] uncorrected_clock: UncorrectedClock, #[case] expected_offset: Duration, ) { - let client_midpoint = ntp_event.tsc_pre.midpoint(ntp_event.tsc_post); + let client_midpoint = phc_event.tsc_pre.midpoint(phc_event.tsc_post); println!( "tsc_pre: {:?}", - uncorrected_clock.time_at(ntp_event.tsc_pre) + uncorrected_clock.time_at(phc_event.tsc_pre) ); println!( "tsc_post: {:?}", - uncorrected_clock.time_at(ntp_event.tsc_post) + uncorrected_clock.time_at(phc_event.tsc_post) ); let client_midpoint = uncorrected_clock.time_at(client_midpoint); println!("client_midpoint: {client_midpoint:?}"); - let offset = ntp_event.calculate_offset(uncorrected_clock); + let offset = phc_event.calculate_offset(uncorrected_clock); approx::assert_abs_diff_eq!( offset.as_seconds_f64(), diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index cccfcf2..88312e2 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -449,6 +449,9 @@ impl Neg for Diff { } impl Diff { + /// zero-valued diff + pub const ZERO: Self = Self::new(0); + /// Create a new [`Diff`] from the number of seconds pub const fn from_secs(secs: i128) -> Self { Self::new(secs * FEMTOS_PER_SEC) From cdf80dcb9790cbca5daa3eb04dcb3e2e25e5adff Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Tue, 11 Nov 2025 14:21:58 -0800 Subject: [PATCH 107/177] ffi: allocate error message storage on the C side (breaking change) (#114) * ffi: allocate error message storage on the C side (breaking change) This patch changes the FFI interface for C clients. Before this change, the client was not allocating memory to back error struct returned when the library encounters a problem. The memory was allocated on the context by the Rust side of the interface. This worked ok for simple types that can be copied, but had a few problems: - Given a single error is stored on the context, only the last error encountered can be retrieved. This is not great ergonomics for the client. - Any error that occured before the context is created has no backing storage to store a description of the problem to help understand the problem. This forced the code to rely on static string to avoid dangling pointers. - These static strings have to be null terminated, forcing to use CString deeper in the Rust code. - Other errors have no backing storage that would persist the Rust to C exchange, hence given error message of little use. This patch pushes the memory allocation on the C client. This let the caller decides of the lifetime of the error struct(s) and addresses the problems above. This change, however, did force to change the signature of functions exposed in C, making this a breaking change from earlier versions. --- clock-bound-ffi/include/clockbound.h | 63 ++-- clock-bound-ffi/src/lib.rs | 297 +++++++++++++----- clock-bound/src/client.rs | 16 +- clock-bound/src/shm.rs | 3 + .../client/c/src/clockbound_loop_forever.c | 93 +++--- examples/client/c/src/clockbound_now.c | 103 +++--- 6 files changed, 371 insertions(+), 204 deletions(-) diff --git a/clock-bound-ffi/include/clockbound.h b/clock-bound-ffi/include/clockbound.h index 54c2d69..fe01fd0 100644 --- a/clock-bound-ffi/include/clockbound.h +++ b/clock-bound-ffi/include/clockbound.h @@ -4,6 +4,7 @@ #include +#define CLOCKBOUND_ERROR_DETAIL_SIZE 128 #define CLOCKBOUND_SHM_DEFAULT_PATH "/var/run/clockbound/shm0" #define VMCLOCK_SHM_DEFAULT_PATH "/dev/vmclock0" @@ -19,30 +20,34 @@ typedef struct clockbound_ctx clockbound_ctx; * Enumeration of error codes. */ typedef enum clockbound_err_kind { - /* No error. */ - CLOCKBOUND_ERR_NONE, - /* Error returned by a syscall. */ - CLOCKBOUND_ERR_SYSCALL, - /* A shared memory segment has not been initialized. */ - CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED, - /* A shared memory segment is initialized but malformed. */ - CLOCKBOUND_ERR_SEGMENT_MALFORMED, - /* The system clock and shared memory segment reads do match expected order. */ - CLOCKBOUND_ERR_CAUSALITY_BREACH, - /* A shared memory segment has a version format that is not supported. */ - CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED, + /* No error. */ + CLOCKBOUND_ERR_NONE, + /* Error returned by a syscall. */ + CLOCKBOUND_ERR_SYSCALL, + /* A shared memory segment has not been initialized. */ + CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED, + /* A shared memory segment is initialized but malformed. */ + CLOCKBOUND_ERR_SEGMENT_MALFORMED, + /* The system clock and shared memory segment reads do match expected order. */ + CLOCKBOUND_ERR_CAUSALITY_BREACH, + /* A shared memory segment has a version format that is not supported. */ + CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED, } clockbound_err_kind; /* * Error type structure. */ typedef struct clockbound_err { - /* The type of error which occurred. */ - clockbound_err_kind kind; - /* For CLOCKBOUND_ERR_SYSCALL, the errno which was returned by the system. */ - int sys_errno; - /* For CLOCKBOUND_ERR_SYSCALL, the name of the syscall which errored. May be NULL. */ - const char* detail; + /* The type of error which occurred. */ + clockbound_err_kind kind; + /* + * For CLOCKBOUND_ERR_SYSCALL, the errno which was returned by the underlying + * system call. See documentation for the interpretation of errno for other + * clockbound_err_kind variants. + */ + int errno; + /* Human readable context about the error, if available. */ + const char detail[CLOCKBOUND_ERROR_DETAIL_SIZE]; } clockbound_err; /* @@ -72,7 +77,7 @@ typedef struct clockbound_now_result { * Open a new context using the ClockBound daemon-client segment at `clockbound_shm_path` * and the VMClock segment at the default VMClock segment path. * - * Returns a newly-allocated context on success, and NULL on failure. If err is + * Returns a newly-allocated context on success, and NULL on failure. If `err` is * non-null, fills `*err` with error details. */ clockbound_ctx* clockbound_open(char const* clockbound_shm_path, clockbound_err *err); @@ -81,29 +86,35 @@ clockbound_ctx* clockbound_open(char const* clockbound_shm_path, clockbound_err * Open a new context using the ClockBound daemon-client segment at `clockbound_shm_path` * and the VMClock segment at `vmclock_shm_path`. * - * Returns a newly-allocated context on success, and NULL on failure. If err is + * Returns a newly-allocated context on success, and NULL on failure. If `err` is * non-null, fills `*err` with error details. */ -clockbound_ctx* clockbound_vmclock_open(char const* clockbound_shm_path, char const* vmclock_shm_path, clockbound_err *err); +clockbound_ctx* clockbound_vmclock_open(char const* clockbound_shm_path, + char const* vmclock_shm_path, clockbound_err *err); /* * Close and deallocates the context. * - * Returns NULL on success, or a pointer to error details on failure. + * Returns NULL on success, or a pointer to `err` on failure. If err is non-null, fills + * `*err` with error details. * */ -clockbound_err const* clockbound_close(clockbound_ctx *ctx); +clockbound_err* clockbound_close(clockbound_ctx *ctx, clockbound_err *err); /* - * Return the Clock Error Bound interval. + * Retrieve the Clock Error Bound interval. * * This function is the equivalent of `clock_gettime(CLOCK_REALTIME)` but in the context of * ClockBound. It reads the current time from the system clock (C(t)), and calculate the CEB at this * instant. This allows to return a pair of timespec structures that define the interval * [(C(t) - CEB), (C(t) + CEB)] - * in which true time exists. The call also populate an enum capturing the underlying clock status. + * in which true time exists. * + * The call also populate `res` with the underlying clock status. * The clock status MUST be checked to ensure the bound on clock error is trustworthy. + * + * Returns NULL on success, or a pointer to `err` on failure. If err is non-null, fills + * `*err` with error details. */ -clockbound_err const* clockbound_now(clockbound_ctx *ctx, clockbound_now_result *res); +clockbound_err* clockbound_now(clockbound_ctx *ctx, clockbound_now_result *res, clockbound_err *err); #endif diff --git a/clock-bound-ffi/src/lib.rs b/clock-bound-ffi/src/lib.rs index 9c3951a..152eb18 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -12,12 +12,17 @@ use clock_bound::shm::ClockStatus; use clock_bound::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use core::ptr; use errno::Errno; -use std::ffi::{CStr, CString, c_char}; +use std::ffi::{CStr, CString, c_char, c_int}; + +/// The size of the `c_char` array passed by the C caller to populate with error information. +/// FIXME: for now this is hard-coded to match the C header definition. +const CLOCKBOUND_ERROR_DETAIL_SIZE: usize = 128; /// Error kind exposed over the FFI. /// /// These have to match the C header definition. #[repr(C)] +#[derive(Debug, PartialEq)] pub enum clockbound_err_kind { CLOCKBOUND_ERR_NONE, CLOCKBOUND_ERR_SYSCALL, @@ -27,30 +32,10 @@ pub enum clockbound_err_kind { CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED, } -/// Error struct exposed over the FFI. -/// -/// The definition has to match the C header definition. -#[repr(C)] -pub struct clockbound_err { - pub kind: clockbound_err_kind, - pub errno: i32, - pub detail: *const c_char, -} - -impl Default for clockbound_err { - fn default() -> Self { - clockbound_err { - kind: clockbound_err_kind::CLOCKBOUND_ERR_NONE, - errno: 0, - detail: ptr::null(), - } - } -} - -impl From for clockbound_err { - fn from(value: ClockBoundError) -> Self { - let kind = match value.kind { - ClockBoundErrorKind::Syscall(_) => clockbound_err_kind::CLOCKBOUND_ERR_SYSCALL, +impl From for clockbound_err_kind { + fn from(value: ClockBoundErrorKind) -> Self { + match value { + ClockBoundErrorKind::Syscall => clockbound_err_kind::CLOCKBOUND_ERR_SYSCALL, ClockBoundErrorKind::SegmentNotInitialized => { clockbound_err_kind::CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED } @@ -63,22 +48,56 @@ impl From for clockbound_err { ClockBoundErrorKind::SegmentVersionNotSupported => { clockbound_err_kind::CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED } - }; + } + } +} - let errno = match value.kind { - ClockBoundErrorKind::Syscall(_) => value.errno.0, - _ => 0, - }; +/// Error struct exposed over the FFI. +/// +/// The definition has to match the C header definition. +#[repr(C)] +pub struct clockbound_err { + pub kind: clockbound_err_kind, + pub errno: c_int, + pub detail: [c_char; CLOCKBOUND_ERROR_DETAIL_SIZE], +} - let detail = match value.kind { - ClockBoundErrorKind::Syscall(detail) => detail.as_ptr(), - _ => ptr::null(), +impl clockbound_err { + /// Write over the `clockbound_err` memory. + /// + /// This function is the interface between Rust `ClockBoundError` and its C version. The C + /// caller allocates memory and pass a pointer to the `clockbound_err`, which is updated here. + fn write_error(&mut self, error: ClockBoundError) { + // Kind and errno values are set + self.kind = clockbound_err_kind::from(error.kind); + self.errno = error.errno.0; + + // The detail String has to be converted to a CString, ensuring it is null terminated. + let c_string = match CString::new(error.detail) { + Ok(c_string) => c_string, + Err(_) => { + // Hopefully, we never insert null terminators in the middle of an error message, + // and should never hit that branch. + CString::new("No detail available").unwrap() + } }; - clockbound_err { - kind, - errno, - detail, + // Copy bytes including null terminator if it fits, and truncate if required. + // Possible cast from u8 (bytes) into i8 (c_char) but is platform dependent + let src_ptr = c_string.as_ptr(); + let dst_ptr = self.detail.as_mut_ptr(); + let copy_len = c_string + .as_bytes_with_nul() + .len() + .min(CLOCKBOUND_ERROR_DETAIL_SIZE); + // Safety: rely on the user passing a valid pointer + unsafe { + ptr::copy_nonoverlapping(src_ptr, dst_ptr, copy_len); + } + + // Ensure null termination if we had to truncate. + if copy_len >= CLOCKBOUND_ERROR_DETAIL_SIZE { + self.detail[CLOCKBOUND_ERROR_DETAIL_SIZE - 1] = 0; } } } @@ -89,7 +108,6 @@ impl From for clockbound_err { /// meant to rely on the content of this structure, only pass it back to flex the clockbound API. /// This allow to extend the context with extra information if needed. pub struct clockbound_ctx { - err: clockbound_err, clockbound_client: ClockBoundClient, } @@ -195,14 +213,16 @@ pub unsafe extern "C" fn clockbound_vmclock_open( let clockbound_shm_path = match clockbound_shm_path_cstr.to_str() { Ok(path) => path, Err(e) => { - if !err.is_null() { - let cb_err = ClockBoundError { - kind: ClockBoundErrorKind::SegmentNotInitialized, - errno: Errno(22_i32), // EINVAL, Invalid argument. - detail: format!("Failed to convert ClockBound shared memory path to str: {e}"), - }; + let cb_err = ClockBoundError { + kind: ClockBoundErrorKind::SegmentNotInitialized, + errno: Errno(22_i32), // EINVAL, Invalid argument. + detail: format!("Failed to convert ClockBound shared memory path to str: {e}"), + }; + unsafe { // Safety: rely on caller to pass valid pointers - unsafe { err.write(cb_err.into()) } + if let Some(rust_err) = err.as_mut() { + rust_err.write_error(cb_err); + } } return ptr::null_mut(); } @@ -213,14 +233,17 @@ pub unsafe extern "C" fn clockbound_vmclock_open( let vmclock_shm_path = match vmclock_shm_path_cstr.to_str() { Ok(path) => path, Err(e) => { - if !err.is_null() { - let cb_err = ClockBoundError { - kind: ClockBoundErrorKind::SegmentNotInitialized, - errno: Errno(22_i32), // EINVAL, Invalid argument. - detail: format!("Failed to convert VMClock shared memory path to str: {e}"), - }; + let cb_err = ClockBoundError { + kind: ClockBoundErrorKind::SegmentNotInitialized, + errno: Errno(22_i32), // EINVAL, Invalid argument. + detail: format!("Failed to convert VMClock shared memory path to str: {e}"), + }; + + unsafe { // Safety: rely on caller to pass valid pointers - unsafe { err.write(cb_err.into()) } + if let Some(rust_err) = err.as_mut() { + rust_err.write_error(cb_err); + } } return ptr::null_mut(); } @@ -229,10 +252,12 @@ pub unsafe extern "C" fn clockbound_vmclock_open( let clockbound_client = match ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path) { Ok(client) => client, - Err(e) => { - if !err.is_null() { + Err(cb_err) => { + unsafe { // Safety: rely on caller to pass valid pointers - unsafe { err.write(e.into()) } + if let Some(rust_err) = err.as_mut() { + rust_err.write_error(cb_err); + } } return ptr::null_mut(); } @@ -240,10 +265,7 @@ pub unsafe extern "C" fn clockbound_vmclock_open( // Return the clockbound_ctx. The caller is responsible for calling clockbound_close() with // this context which will perform memory clean-up. - let ctx = clockbound_ctx { - err: clockbound_err::default(), - clockbound_client, - }; + let ctx = clockbound_ctx { clockbound_client }; Box::leak(Box::new(ctx)) } @@ -255,7 +277,27 @@ pub unsafe extern "C" fn clockbound_vmclock_open( /// /// Rely on the caller to pass valid pointers. #[unsafe(no_mangle)] -pub unsafe extern "C" fn clockbound_close(ctx: *mut clockbound_ctx) -> *const clockbound_err { +pub unsafe extern "C" fn clockbound_close( + ctx: *mut clockbound_ctx, + err: *mut clockbound_err, +) -> *const clockbound_err { + if ctx.is_null() { + let cb_err = ClockBoundError { + kind: ClockBoundErrorKind::SegmentNotInitialized, + errno: Errno(22_i32), // EINVAL, Invalid argument. + detail: "Cannot close a NULL context".to_string(), + }; + + // Safety: rely on caller to pass valid pointers + unsafe { + if let Some(rust_err) = err.as_mut() { + rust_err.write_error(cb_err); + return err; + } + } + return ptr::null(); + } + // Safety: Rely on caller to pass valid pointers std::mem::drop(unsafe { Box::from_raw(ctx) }); ptr::null() @@ -274,6 +316,7 @@ pub unsafe extern "C" fn clockbound_close(ctx: *mut clockbound_ctx) -> *const cl pub unsafe extern "C" fn clockbound_now( ctx: *mut clockbound_ctx, output: *mut clockbound_now_result, + err: *mut clockbound_err, ) -> *const clockbound_err { // Safety: Rely on caller to pass valid pointers let ctx = unsafe { &mut *ctx }; @@ -281,9 +324,14 @@ pub unsafe extern "C" fn clockbound_now( // Get earliest and latest timestamps, as well as the clock status let cb_now = match ctx.now() { Ok(now) => now, - Err(e) => { - ctx.err = e.into(); - return &raw const ctx.err; + Err(cb_err) => { + unsafe { + // Safety: rely on caller to pass valid pointers + if let Some(rust_err) = err.as_mut() { + rust_err.write_error(cb_err); + } + } + return err; } }; @@ -380,6 +428,78 @@ mod t_ffi { }; } + /// Assert that the clockbound_err is updated with the correct values. + #[test] + fn test_clockbound_err_write_from() { + let cb_error = ClockBoundError { + kind: ClockBoundErrorKind::Syscall, + errno: Errno(42), + detail: String::from("Something weird happened"), + }; + let mut err: clockbound_err = unsafe { std::mem::zeroed() }; + let raw_ptr = &mut err; + + // Write the ClockBoundError onto the clockbound_err + raw_ptr.write_error(cb_error); + + assert_eq!(err.kind, clockbound_err_kind::CLOCKBOUND_ERR_SYSCALL); + assert_eq!(err.errno, 42 as c_int); + + let c_char_ptr: *const c_char = err.detail.as_ptr(); + let c_str: &CStr = unsafe { CStr::from_ptr(c_char_ptr) }; + let c_string: CString = CString::from(c_str); + assert_eq!(c_string, CString::new("Something weird happened").unwrap()); + } + + /// Assert that the clockbound_err is updated with the correct values, but truncate message + #[test] + fn test_clockbound_err_write_from_truncated() { + let cb_error = ClockBoundError { + kind: ClockBoundErrorKind::SegmentMalformed, + errno: Errno(42), + detail: "a".repeat(2 * CLOCKBOUND_ERROR_DETAIL_SIZE), + }; + let mut err: clockbound_err = unsafe { std::mem::zeroed() }; + let raw_ptr = &mut err; + + // Write the ClockBoundError onto the clockbound_err + raw_ptr.write_error(cb_error); + + assert_eq!( + err.kind, + clockbound_err_kind::CLOCKBOUND_ERR_SEGMENT_MALFORMED + ); + assert_eq!(err.errno, 42 as c_int); + + for c in 1..(CLOCKBOUND_ERROR_DETAIL_SIZE - 1) { + assert_eq!(err.detail[c], 'a' as c_char); + } + assert_eq!(err.detail[CLOCKBOUND_ERROR_DETAIL_SIZE - 1], 0); + } + + /// Assert that the clock status is converted correctly between representations. This is a bit + /// of a "useless" unit test since it mimics the code closely. However, this is a core + /// property we give to the callers, so may as well. + #[test] + fn test_clock_status_conversion() { + assert_eq!( + clockbound_clock_status::from(ClockStatus::Unknown), + clockbound_clock_status::CLOCKBOUND_STA_UNKNOWN + ); + assert_eq!( + clockbound_clock_status::from(ClockStatus::Synchronized), + clockbound_clock_status::CLOCKBOUND_STA_SYNCHRONIZED + ); + assert_eq!( + clockbound_clock_status::from(ClockStatus::FreeRunning), + clockbound_clock_status::CLOCKBOUND_STA_FREE_RUNNING + ); + assert_eq!( + clockbound_clock_status::from(ClockStatus::Disrupted), + clockbound_clock_status::CLOCKBOUND_STA_DISRUPTED + ); + } + /// Assert that the shared memory segment can be open, read and and closed. Only a sanity test. #[test] fn test_clockbound_vmclock_open_sanity_check() { @@ -421,8 +541,9 @@ mod t_ffi { .as_bytes(), ) .unwrap(); + unsafe { - let mut err: clockbound_err = Default::default(); + let mut err: clockbound_err = std::mem::zeroed(); let mut now_result: clockbound_now_result = std::mem::zeroed(); let ctx = clockbound_vmclock_open( @@ -432,38 +553,42 @@ mod t_ffi { ); assert!(!ctx.is_null()); - let errptr = clockbound_now(ctx, &mut now_result); + let errptr = clockbound_now(ctx, &mut now_result, &mut err); assert!(errptr.is_null()); assert_eq!( now_result.clock_status, clockbound_clock_status::CLOCKBOUND_STA_DISRUPTED ); - let errptr = clockbound_close(ctx); + let errptr = clockbound_close(ctx, &mut err); assert!(errptr.is_null()); } } - /// Assert that the clock status is converted correctly between representations. This is a bit - /// of a "useless" unit test since it mimics the code closely. However, this is a core - /// property we give to the callers, so may as well. + /// Assert that closing the context survives being passed a NULL pointer. #[test] - fn test_clock_status_conversion() { - assert_eq!( - clockbound_clock_status::from(ClockStatus::Unknown), - clockbound_clock_status::CLOCKBOUND_STA_UNKNOWN - ); - assert_eq!( - clockbound_clock_status::from(ClockStatus::Synchronized), - clockbound_clock_status::CLOCKBOUND_STA_SYNCHRONIZED - ); - assert_eq!( - clockbound_clock_status::from(ClockStatus::FreeRunning), - clockbound_clock_status::CLOCKBOUND_STA_FREE_RUNNING - ); - assert_eq!( - clockbound_clock_status::from(ClockStatus::Disrupted), - clockbound_clock_status::CLOCKBOUND_STA_DISRUPTED - ); + fn test_clockbound_close() { + // Should not crash on everything being NULL + unsafe { + assert_eq!( + ptr::null(), + clockbound_close(ptr::null_mut(), ptr::null_mut()) + ); + } + + // ... and should get some info if an error is provided + unsafe { + let mut err: clockbound_err = std::mem::zeroed(); + let raw_err_ptr: *const clockbound_err = &err; + assert_eq!(raw_err_ptr, clockbound_close(ptr::null_mut(), &mut err)); + + let c_char_ptr: *const c_char = err.detail.as_ptr(); + let c_str: &CStr = CStr::from_ptr(c_char_ptr); + let c_string: CString = CString::from(c_str); + assert_eq!( + c_string, + CString::new("Cannot close a NULL context").unwrap() + ); + } } } diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index 3464b91..38c5f15 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -8,7 +8,7 @@ pub use crate::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use crate::vmclock::shm_reader::VMClockShmReader; use errno::Errno; use nix::sys::time::TimeSpec; -use std::ffi::{CStr, CString}; +use std::ffi::CString; use std::path::Path; /// The `ClockBoundClient` @@ -240,7 +240,7 @@ pub struct ClockBoundError { impl From for ClockBoundError { fn from(value: ShmError) -> Self { let kind = match value { - ShmError::SyscallError(_, detail) => ClockBoundErrorKind::Syscall(detail), + ShmError::SyscallError(_, _) => ClockBoundErrorKind::Syscall, ShmError::SegmentNotInitialized => ClockBoundErrorKind::SegmentNotInitialized, ShmError::SegmentMalformed => ClockBoundErrorKind::SegmentMalformed, ShmError::CausalityBreach => ClockBoundErrorKind::CausalityBreach, @@ -257,7 +257,7 @@ impl From for ClockBoundError { .to_str() .expect("Failed to convert CStr to str") .to_owned(), - _ => String::new(), + _ => String::from("No detail available"), }; ClockBoundError { @@ -272,7 +272,7 @@ impl From for ClockBoundError { pub enum ClockBoundErrorKind { // FIXME: the `detail` static CString is referenced on the Syscall variant. This is a temporary // implementation until the FFI to C is changed to have the caller allocate memory for it. - Syscall(&'static CStr), + Syscall, SegmentNotInitialized, SegmentMalformed, CausalityBreach, @@ -758,7 +758,7 @@ mod lib_tests { let shm_error = ShmError::SyscallError(errno, detail); // Perform the conversion. let clockbounderror = ClockBoundError::from(shm_error); - assert_eq!(ClockBoundErrorKind::Syscall(detail), clockbounderror.kind); + assert_eq!(ClockBoundErrorKind::Syscall, clockbounderror.kind); assert_eq!(errno, clockbounderror.errno); assert_eq!(detail_string, clockbounderror.detail); } @@ -773,7 +773,7 @@ mod lib_tests { clockbounderror.kind ); assert_eq!(Errno(0), clockbounderror.errno); - assert_eq!(String::new(), clockbounderror.detail); + assert_eq!(String::from("No detail available"), clockbounderror.detail); } #[test] @@ -783,7 +783,7 @@ mod lib_tests { let clockbounderror = ClockBoundError::from(shm_error); assert_eq!(ClockBoundErrorKind::SegmentMalformed, clockbounderror.kind); assert_eq!(Errno(0), clockbounderror.errno); - assert_eq!(String::new(), clockbounderror.detail); + assert_eq!(String::from("No detail available"), clockbounderror.detail); } #[test] @@ -793,6 +793,6 @@ mod lib_tests { let clockbounderror = ClockBoundError::from(shm_error); assert_eq!(ClockBoundErrorKind::CausalityBreach, clockbounderror.kind); assert_eq!(Errno(0), clockbounderror.errno); - assert_eq!(String::new(), clockbounderror.detail); + assert_eq!(String::from("No detail available"), clockbounderror.detail); } } diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 0939cd0..3974228 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -42,6 +42,9 @@ macro_rules! syserror { } /// Error condition returned by all low-level ClockBound APIs. +/// +// FIXME: the `detail` static CString referenced on the Syscall variant can be changed. The C +// caller now pass memory over the FFI interface. #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ShmError { /// A system call failed. diff --git a/examples/client/c/src/clockbound_loop_forever.c b/examples/client/c/src/clockbound_loop_forever.c index ca2e103..fa06d32 100644 --- a/examples/client/c/src/clockbound_loop_forever.c +++ b/examples/client/c/src/clockbound_loop_forever.c @@ -8,38 +8,6 @@ #include "clockbound.h" -/* - * Helper function to print out errors returned by libclockbound. - */ -void print_clockbound_err(char const* detail, const clockbound_err *err) { - fprintf(stderr, "%s: ", detail); - switch (err->kind) { - case CLOCKBOUND_ERR_NONE: - fprintf(stderr, "Success\n"); - break; - case CLOCKBOUND_ERR_SYSCALL: - if (err->detail) { - fprintf(stderr, "%s: %s\n", err->detail, strerror(err->sys_errno)); - } else { - fprintf(stderr, "%s\n", strerror(err->sys_errno)); - } - break; - case CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED: - fprintf(stderr, "Segment not initialized\n"); - break; - case CLOCKBOUND_ERR_SEGMENT_MALFORMED: - fprintf(stderr, "Segment malformed\n"); - break; - case CLOCKBOUND_ERR_CAUSALITY_BREACH: - fprintf(stderr, "Segment and clock reads out of order\n"); - break; - case CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED: - fprintf(stderr, "Segment version not supported\n"); - break; - default: - fprintf(stderr, "Unexpected error\n"); - } -} /* * Helper function to convert clock status codes into a human readable version. @@ -59,11 +27,58 @@ char * format_clock_status(clockbound_clock_status status) { } } +/* + * Helper function to convert clockbound error kind codes into a human readable version. + */ +char * format_err_kind(clockbound_err_kind kind) { + switch (kind) { + case CLOCKBOUND_ERR_NONE: + return "No error"; + case CLOCKBOUND_ERR_SYSCALL: + return "Syscall error"; + case CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED: + return "Segment not initialized error"; + case CLOCKBOUND_ERR_SEGMENT_MALFORMED: + return "Segment malformed error"; + case CLOCKBOUND_ERR_CAUSALITY_BREACH: + return "Causality breach error"; + case CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED: + return "Segment version not supported error"; + default: + return "BAD ERROR KIND"; + } +} + +/* + * Helper function to print out errors returned by libclockbound. + */ +void print_clockbound_err(const clockbound_err *err) { + if (err == NULL) { + return; + } + switch (err->kind) { + case CLOCKBOUND_ERR_SYSCALL: + fprintf(stderr, "%s(%d): %s: %s\n", format_err_kind(err->kind), + err->errno, strerror(err->errno), err->detail); + break; + case CLOCKBOUND_ERR_NONE: + case CLOCKBOUND_ERR_SEGMENT_MALFORMED: + case CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED: + case CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED: + case CLOCKBOUND_ERR_CAUSALITY_BREACH: + fprintf(stderr, "%s(%d): %s\n", format_err_kind(err->kind), + err->errno, err->detail); + break; + default: + fprintf(stderr, "Unexpected error\n"); + } +} + int main(int argc, char *argv[]) { char const* clockbound_shm_path = CLOCKBOUND_SHM_DEFAULT_PATH; char const* vmclock_shm_path = VMCLOCK_SHM_DEFAULT_PATH; clockbound_ctx *ctx; - clockbound_err open_err; + clockbound_err cb_err; clockbound_err const* err; clockbound_now_result first; clockbound_now_result last; @@ -71,18 +86,18 @@ int main(int argc, char *argv[]) { int i; // Open clockbound and retrieve a context on success. - ctx = clockbound_vmclock_open(clockbound_shm_path, vmclock_shm_path, &open_err); + ctx = clockbound_vmclock_open(clockbound_shm_path, vmclock_shm_path, &cb_err); if (ctx == NULL) { - print_clockbound_err("clockbound_open", &open_err); + print_clockbound_err(&cb_err); return 1; } while (1) { // Read the current time reported by the system clock, but as a time interval within which // true time exists. - err = clockbound_now(ctx, &first); + err = clockbound_now(ctx, &first, &cb_err); if (err) { - print_clockbound_err("clockbound_now", err); + print_clockbound_err(err); return 1; } @@ -98,9 +113,9 @@ int main(int argc, char *argv[]) { } // Finally, close clockbound. - err = clockbound_close(ctx); + err = clockbound_close(ctx, &cb_err); if (err) { - print_clockbound_err("clockbound_close", err); + print_clockbound_err(err); return 1; } diff --git a/examples/client/c/src/clockbound_now.c b/examples/client/c/src/clockbound_now.c index 1d5435d..012d8ae 100644 --- a/examples/client/c/src/clockbound_now.c +++ b/examples/client/c/src/clockbound_now.c @@ -4,44 +4,12 @@ #include #include #include +#include #include "clockbound.h" int CALL_COUNT = 100000000; -/* - * Helper function to print out errors returned by libclockbound. - */ -void print_clockbound_err(char const* detail, const clockbound_err *err) { - fprintf(stderr, "%s: ", detail); - switch (err->kind) { - case CLOCKBOUND_ERR_NONE: - fprintf(stderr, "Success\n"); - break; - case CLOCKBOUND_ERR_SYSCALL: - if (err->detail) { - fprintf(stderr, "%s: %s\n", err->detail, strerror(err->sys_errno)); - } else { - fprintf(stderr, "%s\n", strerror(err->sys_errno)); - } - break; - case CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED: - fprintf(stderr, "Segment not initialized\n"); - break; - case CLOCKBOUND_ERR_SEGMENT_MALFORMED: - fprintf(stderr, "Segment malformed\n"); - break; - case CLOCKBOUND_ERR_CAUSALITY_BREACH: - fprintf(stderr, "Segment and clock reads out of order\n"); - break; - case CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED: - fprintf(stderr, "Segment version not supported\n"); - break; - default: - fprintf(stderr, "Unexpected error\n"); - } -} - /* * Helper function to convert clock status codes into a human readable version. */ @@ -60,6 +28,52 @@ char * format_clock_status(clockbound_clock_status status) { } } +/* + * Helper function to convert clockbound error kind codes into a human readable version. + */ +char * format_err_kind(clockbound_err_kind kind) { + switch (kind) { + case CLOCKBOUND_ERR_NONE: + return "No error"; + case CLOCKBOUND_ERR_SYSCALL: + return "Syscall error"; + case CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED: + return "Segment not initialized error"; + case CLOCKBOUND_ERR_SEGMENT_MALFORMED: + return "Segment malformed error"; + case CLOCKBOUND_ERR_CAUSALITY_BREACH: + return "Causality breach error"; + case CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED: + return "Segment version not supported error"; + default: + return "BAD ERROR KIND"; + } +} + +/* + * Helper function to print out errors returned by libclockbound. + */ +void print_clockbound_err(const clockbound_err *err) { + if (err == NULL) { + return; + } + switch (err->kind) { + case CLOCKBOUND_ERR_SYSCALL: + fprintf(stderr, "%s(%d): %s: %s\n", format_err_kind(err->kind), + err->errno, strerror(err->errno), err->detail); + break; + case CLOCKBOUND_ERR_NONE: + case CLOCKBOUND_ERR_SEGMENT_MALFORMED: + case CLOCKBOUND_ERR_SEGMENT_NOT_INITIALIZED: + case CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED: + case CLOCKBOUND_ERR_CAUSALITY_BREACH: + fprintf(stderr, "%s(%d): %s\n", format_err_kind(err->kind), + err->errno, err->detail); + break; + default: + fprintf(stderr, "Unexpected error\n"); + } +} /* * Helper function to calculate a time interval between two timestamps held in a struct timespec. @@ -80,25 +94,25 @@ int main(int argc, char *argv[]) { char const* clockbound_shm_path = CLOCKBOUND_SHM_DEFAULT_PATH; char const* vmclock_shm_path = VMCLOCK_SHM_DEFAULT_PATH; clockbound_ctx *ctx; - clockbound_err open_err; - clockbound_err const* err; + clockbound_err cb_err; + clockbound_err *err; clockbound_now_result first; clockbound_now_result last; double dur; int i; // Open clockbound and retrieve a context on success. - ctx = clockbound_vmclock_open(clockbound_shm_path, vmclock_shm_path, &open_err); + ctx = clockbound_vmclock_open(clockbound_shm_path, vmclock_shm_path, &cb_err); if (ctx == NULL) { - print_clockbound_err("clockbound_open", &open_err); + print_clockbound_err(&cb_err); return 1; } // Read the current time reported by the system clock, but as a time interval within which // true time exists. - err = clockbound_now(ctx, &first); + err = clockbound_now(ctx, &first, &cb_err); if (err) { - print_clockbound_err("clockbound_now", err); + print_clockbound_err(err); return 1; } @@ -112,23 +126,22 @@ int main(int argc, char *argv[]) { // Very naive performance benchmark. This is VERY naive, your mileage may vary. i = CALL_COUNT; while (i > 0) { - err = clockbound_now(ctx, &last); + err = clockbound_now(ctx, &last, &cb_err); if (err) { - print_clockbound_err("clockbound_now", err); + print_clockbound_err(err); return 1; } i--; } dur = duration(first.earliest, last.earliest); - printf("It took %.9lf seconds to call clock bound %d times (%d tps))", + printf("It took %.9lf seconds to call clock bound %d times (%d tps))\n", dur, CALL_COUNT, (int) (CALL_COUNT / dur)); - // Finally, close clockbound. - err = clockbound_close(ctx); + err = clockbound_close(ctx, &cb_err); if (err) { - print_clockbound_err("clockbound_close", err); + print_clockbound_err(err); return 1; } From dd7c993f2d57c067c58243fb1eb9bea31a2da1d0 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Wed, 12 Nov 2025 09:05:11 -0800 Subject: [PATCH 108/177] [client] Fix logic bug in determining disruption status (#127) This patch fixes a logic bug that glossed over the case where the daemon explicitly signalled it is ignoring the support for clock disruption (that is, not using the VMClock). --- clock-bound-ffi/src/lib.rs | 2 +- clock-bound/src/client.rs | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/clock-bound-ffi/src/lib.rs b/clock-bound-ffi/src/lib.rs index 152eb18..34a1778 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -557,7 +557,7 @@ mod t_ffi { assert!(errptr.is_null()); assert_eq!( now_result.clock_status, - clockbound_clock_status::CLOCKBOUND_STA_DISRUPTED + clockbound_clock_status::CLOCKBOUND_STA_UNKNOWN ); let errptr = clockbound_close(ctx, &mut err); diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index 38c5f15..a707766 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -111,14 +111,16 @@ impl ClockBoundClient { // VMClock shared memory is successfully read, this compares the value of the disruption // marker between the clockbound daemon and the VMClock. If these disagree, a disruption // has occured, and the clockbound daemon has not recovered from it yet. - let is_not_disrupted = - self.vmclock_shm.disruption_marker()? == Some(cb_snap.disruption_marker); + let is_disrupted = match self.vmclock_shm.disruption_marker()? { + Some(marker) => marker != cb_snap.disruption_marker, + None => false, + }; // If the clock is disrupted, overwrite the status - let result_status = if is_not_disrupted { - clock_status - } else { + let result_status = if is_disrupted { ClockStatus::Disrupted + } else { + clock_status }; Ok(ClockBoundNowResult { From dbcee52e812b461d2fdb9404fa95fb821b1734cf Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Wed, 12 Nov 2025 13:51:51 -0500 Subject: [PATCH 109/177] Added imds support and helper function to identify bare metal hosts. (#130) * Added imds support and helper function to identify bare metal hosts. To identify if the daemon is run on a instance that supports clock disruption identification the instances meta data can be used. This implementation utilizes IMDS to gather instance meta data and the `is_metal` function will be used to determine if the daemon is being run on a bare metal instance which do not undergo live migration. * updated doc-strings, removed unused dependencies and made some dependencies optional --- Cargo.lock | 521 ++++++++++++++++++++++++++++++ clock-bound/Cargo.toml | 2 + clock-bound/src/daemon/io.rs | 2 + clock-bound/src/daemon/io/imds.rs | 107 ++++++ 4 files changed, 632 insertions(+) create mode 100644 clock-bound/src/daemon/io/imds.rs diff --git a/Cargo.lock b/Cargo.lock index 88fce53..0767475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -121,6 +127,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -271,6 +283,7 @@ dependencies = [ "nix", "nom", "rand 0.9.2", + "reqwest", "rstest 0.26.1", "serde", "serde_json", @@ -433,6 +446,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "distrs" version = "0.2.2" @@ -479,6 +503,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fragile" version = "2.0.1" @@ -633,6 +666,91 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -657,12 +775,114 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.12.0" @@ -684,6 +904,22 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -752,6 +988,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "log" version = "0.4.28" @@ -1009,6 +1251,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1021,6 +1269,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1215,6 +1472,38 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rstest" version = "0.25.0" @@ -1372,6 +1661,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1422,6 +1723,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "statrs" version = "0.18.0" @@ -1451,6 +1758,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -1571,6 +1898,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.47.1" @@ -1629,6 +1966,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.41" @@ -1736,6 +2118,12 @@ dependencies = [ "syn", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -1748,6 +2136,24 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1787,6 +2193,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1829,6 +2244,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.104" @@ -1861,6 +2289,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wide" version = "0.7.33" @@ -2110,6 +2548,35 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -2129,3 +2596,57 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index be90684..b57e890 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -25,6 +25,7 @@ libc = { version = "0.2", default-features = false, features = [ md5 = "0.8.0" nix = { version = "0.26", features = ["feature", "time"] } nom = { version = "8", optional = true } +reqwest = { version = "0.12.24", default-features = false, optional = true } serde = { version = "1.0", features = ["derive"], optional = true } serde_json = "1.0.145" thiserror = { version = "2.0", optional = true } @@ -80,6 +81,7 @@ daemon = [ "dep:thiserror", "dep:tracing-appender", "tracing-subscriber/env-filter", + "dep:reqwest", ] test-side-by-side = [ ] # run without changing system clock. And compare against system clock diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index ea68792..7cbe567 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -18,6 +18,8 @@ use crate::daemon::io::ntp::DaemonInfo; use crate::daemon::selected_clock::SelectedClockSource; use crate::daemon::{async_ring_buffer, event}; +mod imds; + mod link_local; use link_local::LinkLocal; diff --git a/clock-bound/src/daemon/io/imds.rs b/clock-bound/src/daemon/io/imds.rs new file mode 100644 index 0000000..16653a3 --- /dev/null +++ b/clock-bound/src/daemon/io/imds.rs @@ -0,0 +1,107 @@ +//! Struct holding instance type meta data +use core::str; + +use std::time::Duration; +use thiserror::Error; +use tokio::time::timeout; + +#[derive(Debug, Error)] +pub enum InstanceTypeError { + #[error("Failed to parse into json.")] + Serde(#[from] serde_json::Error), + #[error("Reqwest error")] + Reqwest(#[from] reqwest::Error), + #[error("Failed to parse bytes as utf8 string.")] + Utf8(#[from] std::str::Utf8Error), + #[error("Failed to get token.")] + ReceiveToken, + #[error("Failed to get instance meta data.")] + ReceiveImds, + #[error("IMDS request timed out.")] + Timeout(#[from] tokio::time::error::Elapsed), +} + +/// Instance type meta data used to determine instance capabilities. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstanceType(String); + +impl InstanceType { + // Instance metadata service + const IMDS_ORIGIN: &'static str = "http://169.254.169.254"; + const IMDS_TIMEOUT_DURATION: Duration = Duration::from_secs(1); + + /// Request and parse info using the [IMDSv2 API] + /// + /// [IMDSv2 API]: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html + pub async fn get_from_imds() -> Result { + let client = reqwest::Client::new(); + + // get session token + let response = timeout( + Self::IMDS_TIMEOUT_DURATION, + client + .put(format!("{}/latest/api/token", Self::IMDS_ORIGIN)) + .header("X-aws-ec2-metadata-token-ttl-seconds", "21600") + .send(), + ) + .await??; + + if !response.status().is_success() { + return Err(InstanceTypeError::ReceiveToken); + } + let token = response.bytes().await?; + + // get actual metadata + let response = timeout( + Self::IMDS_TIMEOUT_DURATION, + client + .get(format!( + "{}/latest/meta-data/instance-type", + Self::IMDS_ORIGIN + )) + .header("X-aws-ec2-metadata-token", &*token) + .send(), + ) + .await??; + + if !response.status().is_success() { + return Err(InstanceTypeError::ReceiveImds); + } + let bytes = response.bytes().await?; + + let contents = str::from_utf8(&bytes)?; + + Ok(Self(contents.to_string())) + } + + // Uses instance metadata to determine if instance is bare metal. + pub fn is_metal(&self) -> bool { + self.0.contains("metal") + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[ignore = "works only on ec2. Run manually if desired"] + #[tokio::test] + async fn from_imds() { + let instance_type = InstanceType::get_from_imds().await.unwrap(); + println!("{:?}", instance_type); + } + + #[test] + fn metal_instance() { + let instance_type = InstanceType("m7i.metal-24xlarge ".to_string()); + + assert!(instance_type.is_metal()) + } + + #[test] + fn nonmetal_instance() { + let instance_type = InstanceType("r5a.large".to_string()); + + assert!(!instance_type.is_metal()) + } +} From 424f09ebc29bfb2ffb672d83b2cd67bea5258dcf Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 12 Nov 2025 16:46:51 -0500 Subject: [PATCH 110/177] Handle disruption event throughout the app (#122) * Handle disruption event throughout the app The daemon actor and each IO actor race to handle disruption events. * Revision: integrate the clock state actor * Revision: minor doc addition --- clock-bound/src/bin/clockbound.rs | 3 +- clock-bound/src/daemon.rs | 93 +++++++++++++++++++---- clock-bound/src/daemon/clock_state.rs | 1 - clock-bound/src/daemon/io.rs | 4 +- clock-bound/src/daemon/io/link_local.rs | 80 ++++++++++++------- clock-bound/src/daemon/io/ntp_source.rs | 62 +++++++++++---- clock-bound/src/daemon/receiver_stream.rs | 20 +++++ 7 files changed, 205 insertions(+), 58 deletions(-) diff --git a/clock-bound/src/bin/clockbound.rs b/clock-bound/src/bin/clockbound.rs index 51fdd0a..5f13506 100644 --- a/clock-bound/src/bin/clockbound.rs +++ b/clock-bound/src/bin/clockbound.rs @@ -14,6 +14,7 @@ struct Args { async fn main() { let args = Args::parse(); subscriber::init(&args.log_dir); - let mut d = Daemon::construct().await; + let d = Daemon::construct().await; + let d = Box::new(d); tokio::spawn(async move { d.run().await }).await.unwrap(); } diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 7cf33d2..03b4779 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -24,7 +24,10 @@ use std::sync::Arc; use crate::daemon::{ clock_sync_algorithm::{ClockSyncAlgorithm, Selector, source::NTPSource}, - io::ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, + io::{ + ClockDisruptionEvent, + ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, + }, receiver_stream::{ReceiverStream, RoutableEvent}, selected_clock::SelectedClockSource, time::tsc::Skew, @@ -35,6 +38,7 @@ use crate::daemon::{ async_ring_buffer::Sender, clock_parameters::ClockParameters, clock_state::ClockState, }; use rand::{RngCore, rng}; +use tokio::sync::watch; /// The maximum dispersion growth every second /// @@ -51,11 +55,14 @@ pub(crate) const MAX_DISPERSION_GROWTH_PPB: u32 = 15_000; const MAX_DISPERSION_GROWTH: Skew = Skew::from_ppb(MAX_DISPERSION_GROWTH_PPB as f64); pub struct Daemon { - _io_front_end: io::SourceIO, + io_front_end: io::SourceIO, clock_sync_algorithm: ClockSyncAlgorithm, receiver_stream: ReceiverStream, + clock_disruption_receiver: watch::Receiver, #[cfg(not(feature = "test-side-by-side"))] clock_state_tx: Sender, + #[cfg(not(feature = "test-side-by-side"))] + clock_state: Option, } impl Daemon { @@ -68,7 +75,7 @@ impl Daemon { startup_id: rng().next_u64(), }; #[cfg(not(feature = "test-side-by-side"))] - let (clock_state_tx, mut clock_state) = { + let (clock_state_tx, clock_state) = { let (tx, rx) = async_ring_buffer::create(1); let clock_state = ClockState::construct(rx); (tx, clock_state) @@ -100,6 +107,7 @@ impl Daemon { .build(); let mut io_front_end = io::SourceIO::construct(selected_clock.clone(), daemon_info); + let clock_disruption_receiver = io_front_end.clock_disruption_receiver(); // FIXME, we are basically starting the application in the constructor // We should be able to construct the link local and spawn it when `run` is called // @@ -116,29 +124,42 @@ impl Daemon { io_front_end.create_ntp_source(source).await; } - // Start IO polling - io_front_end.spawn_all(); - #[cfg(not(feature = "test-side-by-side"))] - tokio::spawn(async move { - clock_state.run().await; - }); Self { - _io_front_end: io_front_end, + io_front_end, clock_sync_algorithm, receiver_stream, + clock_disruption_receiver, #[cfg(not(feature = "test-side-by-side"))] clock_state_tx, + #[cfg(not(feature = "test-side-by-side"))] + clock_state: Some(clock_state), } } /// Run the daemon - pub async fn run(&mut self) { + pub async fn run(mut self: Box) { + // Start IO polling + self.io_front_end.spawn_all(); + #[cfg(not(feature = "test-side-by-side"))] + tokio::spawn({ + #[expect( + clippy::missing_panics_doc, + reason = "struct always initialized with Some" + )] + let mut clock_state = self.clock_state.take().unwrap(); + async move { + clock_state.run().await; + } + }); loop { - // TODO: add live migration watch and statements tokio::select! { + biased; // biased to ensure disruption is handled first when this happens + Ok(()) = self.clock_disruption_receiver.changed() => { + self.handle_disruption(); + } Some(routable_event) = self.receiver_stream.recv() => { self.handle_event(routable_event); - } + }, } } } @@ -146,12 +167,56 @@ impl Daemon { fn handle_event(&mut self, routable_event: RoutableEvent) { #[cfg(not(feature = "test-side-by-side"))] if let Some(params) = self.clock_sync_algorithm.feed(routable_event) { - self.clock_state_tx.send(params.clone()).unwrap(); + use crate::daemon::async_ring_buffer::SendError; + + match self.clock_state_tx.send(params.clone()) { + Ok(()) => (), + Err(SendError::Disrupted(clock_parameters)) => { + // don't handle_disruption. It will be handled on the next call of tokio::select + tracing::info!( + ?clock_parameters, + "Trying to send a value when there was a disruption event. dropping." + ); + } + Err(SendError::BufferClosed(e)) => { + tracing::error!( + ?e, + "Trying to send a value when the buffer is closed. Panicking." + ); + panic!("Unable to communicate with clock state. {e:?}"); + } + } } #[cfg(feature = "test-side-by-side")] let _ = self.clock_sync_algorithm.feed(routable_event); } + /// Handle a clock disruption event + fn handle_disruption(&mut self) { + let Self { + io_front_end: _, + clock_sync_algorithm, + receiver_stream, + #[cfg(not(feature = "test-side-by-side"))] + clock_state, // TODO clear the buffer when the actor pattern comes in + clock_disruption_receiver, + #[cfg(not(feature = "test-side-by-side"))] + clock_state_tx, + } = self; + let val = clock_disruption_receiver.borrow_and_update().clone(); + #[cfg_attr(feature = "test-side-by-side", expect(unused))] + if let Some(disruption_marker) = val.disruption_marker { + #[cfg(not(feature = "test-side-by-side"))] + if let Some(clock_state) = clock_state { + clock_state.handle_disruption(disruption_marker); + } + #[cfg(not(feature = "test-side-by-side"))] + clock_state_tx.handle_disruption(); + clock_sync_algorithm.handle_disruption(); + receiver_stream.handle_disruption(); + } + } + /// Takes in a vector of `source::NTPSource` structs and returns the `async_ring_buffer` senders and receivers /// for each `ntp_source`'s IO event delivery. /// # Parameters diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 739bd89..7134d65 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -113,7 +113,6 @@ impl ClockState { /// Call this function after the system detects a VMClock disruption event. /// /// It will go through and clear the state (like startup). - #[cfg_attr(not(test), expect(unused))] pub fn handle_disruption(&mut self, new_disruption_marker: u64) { // Use the destructure pattern to get a mutable reference to each item. // diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 7cbe567..3207a24 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -174,7 +174,7 @@ impl SourceIO { } // Creates a new [`watch::Receiver`] connected to the clock distribution watch [`watch::Sender`]. - pub fn get_clock_disruption_receiver(&self) -> watch::Receiver { + pub fn clock_disruption_receiver(&self) -> watch::Receiver { self.clock_disruption_channels.sender.subscribe() } @@ -245,7 +245,7 @@ struct ClockDisruptionChannels { #[derive(Clone, Debug, Default)] pub struct ClockDisruptionEvent { - disruption_marker: Option, + pub disruption_marker: Option, } // TODO: This is a stub for future control events. diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index bc28984..cd5dc8b 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -16,7 +16,7 @@ use super::ntp::{ }; use super::{ClockDisruptionEvent, ControlRequest}; use crate::daemon::{ - async_ring_buffer, + async_ring_buffer::{self, SendError}, event::{self}, io::ntp::{self, SamplePacketError, packet::Timestamp}, selected_clock::SelectedClockSource, @@ -148,44 +148,66 @@ impl LinkLocal { info!("Starting link local sampling loop."); loop { tokio::select! { - _ = self.interval.tick() => { - match self.sample().await { - Err(e) => {debug!(?e, "Failed to sample link local source.");} - Ok(ntp_event) => { - self.event_sender.send(ntp_event.clone()).expect("Buffer Closing is not expected in alpha."); - debug!(?ntp_event, "Successfully sent Link Local IO event."); - } - } - if let Mode::Burst(start_time) = self.mode - && start_time.elapsed() >= LINK_LOCAL_BURST_DURATION { - self.transition_to_normal_mode(); - info!("Transitioning from `Burst` mode to `Normal` mode."); - } + biased; // priority order is disruption, commands, and ticks + _ = self.clock_disruption_receiver.changed() => { + // Clock Disruption logic here + self.handle_disruption(); + info!("Received clock disruption signal. Entering Burst mode."); } _ = self.ctrl_receiver.recv() => { // Ctrl logic here. // Currently we breakout of the loop if we receive a control event. break; } - _ = self.clock_disruption_receiver.changed() => { - // Clock Disruption logic here - self.transition_to_burst_mode(); - info!("Received clock disruption signal. Entering Burst mode."); + _ = self.interval.tick() => { + self.handle_interval_tick().await; } + + } } info!("Link local runner exiting."); Ok(()) } + async fn handle_interval_tick(&mut self) { + let ntp_event = match self.sample().await { + Ok(ntp_event) => ntp_event, + Err(e) => { + debug!(?e, "Failed to sample link local source."); + return; + } + }; + + match self.event_sender.send(ntp_event.clone()) { + Ok(()) => debug!(?ntp_event, "Successfully sent Link Local IO event."), + Err(SendError::Disrupted(_)) => { + tracing::info!("Trying to send when there was a disruption event."); + } + Err(SendError::BufferClosed(e)) => { + tracing::error!(?e, "link local Channel closed not supported in alpha"); + panic!("Link local unable to communicate with daemon. {e:?}"); + } + } + + if let Mode::Burst(start_time) = self.mode + && start_time.elapsed() >= LINK_LOCAL_BURST_DURATION + { + self.transition_to_normal_mode(); + info!("Transitioning from `Burst` mode to `Normal` mode."); + } + } + + /// Handles a clock disruption event + /// /// Changes the source's mode to [`Mode::Burst`] and the polling frequency to /// [`BURST_INTERVAL_DURATION`]. - fn transition_to_burst_mode(&mut self) { + fn handle_disruption(&mut self) { let LinkLocal { socket: _socket, - event_sender: _event_sender, + event_sender, ctrl_receiver: _ctrl_receiver, - clock_disruption_receiver: _clock_disruption_receiver, + clock_disruption_receiver, ntp_buffer: _ntp_buffer, interval: ll_interval, mode, @@ -193,10 +215,14 @@ impl LinkLocal { transmit_counter: _, } = self; - *mode = Mode::burst(); - *ll_interval = interval(LINK_LOCAL_BURST_INTERVAL_DURATION); - ll_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); - ll_interval.reset_immediately(); + let val = clock_disruption_receiver.borrow_and_update().clone(); + if val.disruption_marker.is_some() { + event_sender.handle_disruption(); + *mode = Mode::burst(); + *ll_interval = interval(LINK_LOCAL_BURST_INTERVAL_DURATION); + ll_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + ll_interval.reset_immediately(); + } } /// Changes the source's mode to [`Mode::Normal`] and changes polling frequency to @@ -279,7 +305,7 @@ mod tests { #[tokio::test] async fn validate_to_burst_mode() { let (mut link_local, _, _) = create_link_local().await; - link_local.transition_to_burst_mode(); + link_local.handle_disruption(); assert!(matches!(link_local.mode, Mode::Burst(_))); assert_eq!( @@ -291,7 +317,7 @@ mod tests { #[tokio::test] async fn validate_to_normal_mode() { let (mut link_local, _, _) = create_link_local().await; - link_local.transition_to_burst_mode(); + link_local.handle_disruption(); link_local.transition_to_normal_mode(); assert!(matches!(link_local.mode, Mode::Normal)); diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 18ab36d..64479d0 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -11,7 +11,7 @@ use tokio::{ use tracing::{debug, info}; use crate::daemon::{ - async_ring_buffer, + async_ring_buffer::{self, SendError}, event::{self}, io::{ ClockDisruptionEvent, ControlRequest, DaemonInfo, @@ -141,28 +141,64 @@ impl NTPSource { info!("Starting NTP Source IO sampling loop."); loop { tokio::select! { - _ = self.interval.tick() => { - match self.sample().await { - Err(e) => {debug!(?e, "Failed to sample NTP source source.");}, - Ok(ntp_event) => { - self.event_sender - .send(ntp_event.clone()).expect("Buffer Closing is not expected in alpha."); - debug!(?ntp_event, "Successfully sent NTP Source IO event."); - } - } + biased; // priority order is disruption, commands, and ticks + _ = self.clock_disruption_receiver.changed() => { + self.handle_disruption(); } _ = self.ctrl_receiver.recv() => { // Ctrl logic here. // Currently we breakout of the loop if we receive a control event. break; } - _ = self.clock_disruption_receiver.changed() => { - // Clock Disruption logic here - todo!("Clock disruption logic has yet to be implemented."); + _ = self.interval.tick() => { + self.handle_interval_tick().await; } } } info!("NTP Source IO runner exiting."); Ok(()) } + + async fn handle_interval_tick(&mut self) { + let ntp_event = match self.sample().await { + Err(e) => { + debug!(?e, "Failed to sample NTP source source."); + return; + } + Ok(ntp_event) => ntp_event, + }; + + match self.event_sender.send(ntp_event.clone()) { + Ok(()) => debug!(?ntp_event, "Successfully sent Link Local IO event."), + Err(SendError::Disrupted(_)) => { + tracing::info!("Trying to send when there was a disruption event."); + } + Err(SendError::BufferClosed(e)) => { + tracing::error!(?e, "link local Channel closed not supported in alpha"); + panic!("Link local unable to communicate with daemon. {e:?}"); + } + } + } + + /// Handles a clock disruption event + /// + /// Currently not bursting, but will clear the sender + fn handle_disruption(&mut self) { + let Self { + event_sender, + socket: _, + address: _, + ctrl_receiver: _, + clock_disruption_receiver, + ntp_buffer: _, + interval: _, + selected_clock: _, + daemon_info: _, + transmit_counter: _, + } = self; + let val = clock_disruption_receiver.borrow_and_update().clone(); + if val.disruption_marker.is_some() { + event_sender.handle_disruption(); + } + } } diff --git a/clock-bound/src/daemon/receiver_stream.rs b/clock-bound/src/daemon/receiver_stream.rs index 10c6c8d..f2ba488 100644 --- a/clock-bound/src/daemon/receiver_stream.rs +++ b/clock-bound/src/daemon/receiver_stream.rs @@ -110,6 +110,26 @@ impl ReceiverStream { let routable_event = event_result.expect("todo: Implement logic for buffers closing. We do not expect this to happen as a part of the alpha release implementation"); Some(routable_event) } + + /// Handle a clock disruption event + /// + /// This struct should loop through all receivers and clear the buffers when possible + pub fn handle_disruption(&mut self) { + let Self { + link_local, + ntp_sources, + phc, + } = self; + + link_local.handle_disruption(); + for source in ntp_sources.values_mut() { + source.handle_disruption(); + } + + if let Some(phc) = phc { + phc.handle_disruption(); + } + } } #[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)] From 4ebbf030ed8b5651fda13242aa32f71b6af4c711 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Wed, 12 Nov 2025 17:21:30 -0500 Subject: [PATCH 111/177] Cleanups in ClockAdjust state machine (#125) * Modify ASCII art and update debug logs * rename `transition` to `to_` * update f64 math around frequency calculation to increase precision * Clean up handle_event and durations Durations are now members of their respective structs for each state. handle_event uses tracing logs now. additionally, it matches solely on the enum, and the conditional checks are done in each arm. --- .../src/daemon/clock_state/clock_adjust.rs | 114 +++++----- .../clock_state/clock_adjust/state_machine.rs | 201 ++++++++++-------- 2 files changed, 174 insertions(+), 141 deletions(-) diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index d7db441..6426a1e 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -17,7 +17,7 @@ //! that as the phase correction to `CLOCK_REALTIME`, using the PLL slewing method in kernel. An alternative approach //! to fixing phase corrections could involve temporary slewing of the clock frequency, but can risk overshooting, or //! leaving the system in a bad state if in the middle of a slew and the daemon is terminated. -use tracing::{debug, error, info}; +use tracing::{error, info, trace}; use crate::{ daemon::{ @@ -105,24 +105,6 @@ impl ClockAdjust for ClockAdjuster { } } -/// Duration for which to wait for phase correction halting to complete. -/// Since PLL adjustment starts at the top of a second, this should take at least one second. -/// It's possible the clock used for calculation of `Duration` in our async runtime -/// runs a bit slower than this though, so we use 2 seconds to be safe. -const PHASE_CORRECTION_HALT_DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(2); -/// Duration which we should stay in `State::InitialSnapshotA`. We calculate relative frequency adjustment to apply -/// to have `CLOCK_REALTIME` match `ClockBound` clock rate over this duration, by grabbing two snapshots A and B. -const INITIAL_SNAPSHOT_RETRIEVED_DURATION: tokio::time::Duration = - tokio::time::Duration::from_secs(1); -/// Duration which we should let the PLL phase correction run for. -/// The amount of phase offset corrected via PLL slewing can be approximated based on PLL, -/// to be `(1 - 0.75^n)` where `n` = seconds since PLL start. -/// With 8 seconds, we slew approximately 90% of the offset we intended to. -const PHASE_CORRECTING_DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(8); -/// Duration which we should stay in `State::InitialSnapshotA`. We calculate relative frequency adjustment to apply -/// to have `CLOCK_REALTIME` match `ClockBound` clock rate over this duration, by grabbing two snapshots A and B. -const SNAPSHOT_RETRIEVED_DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(10); - use state_machine::{ ClockAdjusted, Disrupted, InitialPhaseCorrectHalted, InitialSnapshotRetrieved, Initialized, PhaseCorrectHalted, SnapshotRetrieved, State, @@ -145,48 +127,66 @@ impl ClockAdjuster { pub fn handle_event(&mut self, now: tokio::time::Instant, clock_params: &ClockParameters) { match &self.state { State::Disrupted(inner) => { - self.state = State::InitialPhaseCorrectHalted(inner.transition(&self.ntp_adjtime)); + self.state = State::InitialPhaseCorrectHalted( + inner.to_initial_phase_correct_halted(&self.ntp_adjtime), + ); } State::Initialized(inner) => { self.state = State::InitialPhaseCorrectHalted( - inner.transition(&self.ntp_adjtime, clock_params), + inner.to_initial_phase_correct_halted(&self.ntp_adjtime, clock_params), ); } - State::InitialPhaseCorrectHalted(inner @ InitialPhaseCorrectHalted { instant }) - if now.duration_since(*instant) >= PHASE_CORRECTION_HALT_DURATION => - { - self.state = State::InitialSnapshotRetrieved(inner.transition(&self.ntp_adjtime)); - } - State::InitialSnapshotRetrieved( - inner @ InitialSnapshotRetrieved { instant, snapshot }, - ) if now.duration_since(*instant) >= INITIAL_SNAPSHOT_RETRIEVED_DURATION => { - self.state = State::ClockAdjusted(inner.transition( - &self.ntp_adjtime, - clock_params, - snapshot, - )); + State::InitialPhaseCorrectHalted(inner) => { + let InitialPhaseCorrectHalted { instant } = inner; + if now.duration_since(*instant) >= InitialPhaseCorrectHalted::DURATION { + self.state = State::InitialSnapshotRetrieved( + inner.to_initial_snapshot_retrieved(&self.ntp_adjtime), + ); + } else { + trace!("No state transition expected from InitialPhaseCorrectHalted"); + } } - State::ClockAdjusted(inner @ ClockAdjusted { instant }) - if now.duration_since(*instant) >= PHASE_CORRECTING_DURATION => - { - self.state = State::PhaseCorrectHalted(inner.transition(&self.ntp_adjtime)); + State::InitialSnapshotRetrieved(inner) => { + let InitialSnapshotRetrieved { instant, snapshot } = inner; + if now.duration_since(*instant) >= InitialSnapshotRetrieved::DURATION { + self.state = State::ClockAdjusted(inner.to_clock_adjusted( + &self.ntp_adjtime, + clock_params, + snapshot, + )); + } else { + trace!("No state transition expected from InitialSnapshotRetrieved"); + } } - State::PhaseCorrectHalted(inner @ PhaseCorrectHalted { instant }) - if now.duration_since(*instant) >= PHASE_CORRECTION_HALT_DURATION => - { - self.state = State::SnapshotRetrieved(inner.transition(&self.ntp_adjtime)); + State::ClockAdjusted(inner) => { + let ClockAdjusted { instant } = inner; + if now.duration_since(*instant) >= ClockAdjusted::DURATION { + self.state = + State::PhaseCorrectHalted(inner.to_phase_correct_halted(&self.ntp_adjtime)); + } else { + trace!("No state transition expected from ClockAdjusted"); + } } - State::SnapshotRetrieved(inner @ SnapshotRetrieved { instant, snapshot }) - if now.duration_since(*instant) >= SNAPSHOT_RETRIEVED_DURATION => - { - self.state = State::ClockAdjusted(inner.transition( - &self.ntp_adjtime, - clock_params, - snapshot, - )); + State::PhaseCorrectHalted(inner) => { + let PhaseCorrectHalted { instant } = inner; + if now.duration_since(*instant) >= PhaseCorrectHalted::DURATION { + self.state = + State::SnapshotRetrieved(inner.to_snapshot_retrieved(&self.ntp_adjtime)); + } else { + trace!("No state transition expected from PhaseCorrectHalted"); + } } - _ => { - debug!("No state transition expected"); + State::SnapshotRetrieved(inner) => { + let SnapshotRetrieved { instant, snapshot } = inner; + if now.duration_since(*instant) >= SnapshotRetrieved::DURATION { + self.state = State::ClockAdjusted(inner.to_clock_adjusted( + &self.ntp_adjtime, + clock_params, + snapshot, + )); + } else { + trace!("No state transition expected from SnapshotRetrieved"); + } } } } @@ -288,7 +288,7 @@ mod test { #[case::initial_phase_correct_halted_to_initial_snapshot_retrieved( State::InitialPhaseCorrectHalted(InitialPhaseCorrectHalted { instant: tokio::time::Instant::now() }), State::InitialSnapshotRetrieved(InitialSnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), - tokio::time::Instant::now() + PHASE_CORRECTION_HALT_DURATION, + tokio::time::Instant::now() + InitialPhaseCorrectHalted::DURATION, 1, )] #[case::initial_snapshot_retrieved_no_change_if_too_early( @@ -300,7 +300,7 @@ mod test { #[case::initial_snapshot_retrieved_to_clock_adjusted( State::InitialSnapshotRetrieved(InitialSnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), State::ClockAdjusted(ClockAdjusted { instant: tokio::time::Instant::now() }), - tokio::time::Instant::now() + INITIAL_SNAPSHOT_RETRIEVED_DURATION, + tokio::time::Instant::now() + InitialSnapshotRetrieved::DURATION, 2, )] #[case::clock_adjusted_no_change_if_too_early( @@ -312,7 +312,7 @@ mod test { #[case::clock_adjusted_to_phase_correct_halted( State::ClockAdjusted(ClockAdjusted { instant: tokio::time::Instant::now() }), State::PhaseCorrectHalted(PhaseCorrectHalted { instant: tokio::time::Instant::now() }), - tokio::time::Instant::now() + PHASE_CORRECTING_DURATION, + tokio::time::Instant::now() + ClockAdjusted::DURATION, 1, )] #[case::phase_correct_halted_no_change_if_too_early( @@ -324,7 +324,7 @@ mod test { #[case::initial_phase_correct_halted_to_snapshot_retrieved( State::PhaseCorrectHalted(PhaseCorrectHalted { instant: tokio::time::Instant::now() }), State::SnapshotRetrieved(SnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), - tokio::time::Instant::now() + PHASE_CORRECTION_HALT_DURATION, + tokio::time::Instant::now() + PhaseCorrectHalted::DURATION, 1, )] #[case::snapshot_retrieved_no_change_if_too_early( @@ -336,7 +336,7 @@ mod test { #[case::snapshot_retrieved_to_clock_adjusted( State::SnapshotRetrieved(SnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot() }), State::ClockAdjusted(ClockAdjusted { instant: tokio::time::Instant::now() }), - tokio::time::Instant::now() + SNAPSHOT_RETRIEVED_DURATION, + tokio::time::Instant::now() + SnapshotRetrieved::DURATION, 2, )] #[tokio::test(start_paused = true)] diff --git a/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs b/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs index 87f494f..4bd8ba3 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs @@ -28,58 +28,47 @@ //!```text //! Clock Adjustment State Machine //! ┌─────────────────┐ ┌─────────────────┐ -//! │ Initialized │ | Disrupted | (any other state can immediately transition to Disrupted) -//! │ │ | | -//! │ step clock + │ | reset clock | -//! │ halt PLL │ | adjustments | -//! │ (unreliable) │ | (unreliable) | +//! │ Initialized │ | Disrupted | (any other state can immediately transition to Disrupted +//! │ (unreliable) │ | (unreliable) | and clock adjustments are reset in its handler) //! └─────────────────┘ └─────────────────┘ //! | | -//! | | -//! | | +//! | halt PLL | halt PLL +//! | step clock | //! v | //! ┌──────────────────────────┐ | -//! │ InitialPhaseCorrectHalted│ | -//! │ │ <────────┘ -//! │ halt PLL │ -//! │ (unreliable) │ +//! │ InitialPhaseCorrectHalted│ <────────┘ +//! │ (unreliable) │ //! └──────────────────────────┘ -//! | +//! | wait PHASE_CORRECTION_HALT_DURATION, then take snapshot A //! | //! v //! ┌──────────────────────────┐ //! │ InitialSnapshotRetrieved │ -//! │ │ -//! │ take snapshot B + │ -//! │ calc frequency + │ -//! │ apply corrections │ //! │ (unreliable) │ //! └──────────────────────────┘ -//! | -//! | -//! v +//! | wait INITIAL_SNAPSHOT_RETRIEVED_DURATION then +//! | take snapshot B + +//! | calc frequency + +//! v apply corrections //! ┌─────────────────┐ //! │ ClockAdjusted │ -//! │ │ -//! ┌───────────│ apply freq + │<────────┐ -//! │ │ phase correct │ │ -//! │ │ (reliable) │ │ -//! │ └─────────────────┘ │ -//! │ | -//! │ | +//! │ halt the PLL │ +//! ┌───────────│ │<────────┐ +//! │ │ (reliable) │ | +//! │ │ │ | wait SNAPSHOT_RETRIEVED_DURATION, then +//! │ └─────────────────┘ │ take snapshot B + +//! │ wait PHASE_CORRECTING_DURATION | calc frequency + +//! │ then halt PLL | apply corrections //! │ | //! v | //! ┌──────────────────┐ ┌──────────────────┐ //! │PhaseCorrectHalted│ │ SnapshotRetrieved│ -//! │ │ │ │ -//! │ halt PLL │ │ take snapshot B +│ -//! │ (reliable) │ │ calc frequency + │ -//! | │ | apply corrections│ -//! | | │ (reliable) │ +//! | (reliable). | │ (reliable) │ //! └──────────────────┘ └──────────────────┘ //! │ ^ //! │ │ //! └────────────────────────────────────────┘ +//! wait PHASE_CORRECTION_HALT_DURATION, then take snapshot A //! ``` use tracing::debug; @@ -157,12 +146,16 @@ pub(super) struct Disrupted; impl Disrupted { #[must_use] #[allow(clippy::unused_self)] - pub(super) fn transition(&self, ntp_adjtime: &impl NtpAdjTimeExt) -> InitialPhaseCorrectHalted { + pub(super) fn to_initial_phase_correct_halted( + &self, + ntp_adjtime: &impl NtpAdjTimeExt, + ) -> InitialPhaseCorrectHalted { // Unwrap safety: If we can't adjust the clock, better to panic, // else we are outside the expectations of our state machine ntp_adjtime .apply_phase_correction(Duration::from_secs(0)) - .unwrap(); + .expect("failed to halt phase correction"); + debug!("Disrupted now transitioning to InitialPhaseCorrectHalted"); InitialPhaseCorrectHalted { instant: tokio::time::Instant::now(), } @@ -173,7 +166,7 @@ pub(super) struct Initialized; impl Initialized { #[must_use] #[allow(clippy::unused_self)] - pub(super) fn transition( + pub(super) fn to_initial_phase_correct_halted( &self, ntp_adjtime: &impl NtpAdjTimeExt, clock_params: &ClockParameters, @@ -182,13 +175,16 @@ impl Initialized { // else we are outside the expectations of our state machine ntp_adjtime .apply_phase_correction(Duration::from_secs(0)) - .unwrap(); + .expect("failed to halt phase correction"); let clockbound_clock = ClockBound::new(clock_params.clone(), ReadTscImpl); // TODO: implement multiple attempts in case of latency increase let offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); // Unwrap safety: If we can't adjust the clock, better to panic, // else we are outside the expectations of our state machine - ntp_adjtime.step_clock(offset_and_rtt.offset()).unwrap(); + ntp_adjtime + .step_clock(offset_and_rtt.offset()) + .expect("failed to step clock"); + debug!("Initialized now transitioning to InitialPhaseCorrectHalted"); InitialPhaseCorrectHalted { instant: tokio::time::Instant::now(), } @@ -199,12 +195,19 @@ pub(super) struct InitialPhaseCorrectHalted { pub(super) instant: tokio::time::Instant, } impl InitialPhaseCorrectHalted { + /// Duration which we should stay in `InitialPhaseCorrectHalted`. + /// Since PLL adjustment starts at the top of a second, this should take at least one second. + /// It's possible the clock used for calculation of `Duration` in our async runtime + /// runs a bit slower than this though, so we use 2 seconds to be safe. + pub(super) const DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(2); + #[must_use] #[allow(clippy::unused_self)] - pub(super) fn transition(&self, ntp_adjtime: &impl NtpAdjTimeExt) -> InitialSnapshotRetrieved { - debug!( - "ClockAdjustmentState: InitialPhaseCorrectHalted now transitioning to InitialSnapshotRetrieved" - ); + pub(super) fn to_initial_snapshot_retrieved( + &self, + ntp_adjtime: &impl NtpAdjTimeExt, + ) -> InitialSnapshotRetrieved { + debug!("InitialPhaseCorrectHalted now transitioning to InitialSnapshotRetrieved"); InitialSnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: ClockSnapshot::retrieve(ntp_adjtime), @@ -217,9 +220,14 @@ pub(super) struct InitialSnapshotRetrieved { pub(super) snapshot: ClockSnapshot, } impl InitialSnapshotRetrieved { + /// Duration which we should stay in `InitialSnapshotRetrieved`. We calculate relative frequency adjustment to apply + /// to have `CLOCK_REALTIME` match `ClockBound` clock rate over this duration, by grabbing two snapshots A and B + /// and calculating the relative difference between the two. + pub(super) const DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(1); + #[must_use] #[allow(clippy::unused_self)] - pub(super) fn transition( + pub(super) fn to_clock_adjusted( &self, ntp_adjtime: &impl NtpAdjTimeExt, clock_params: &ClockParameters, @@ -234,8 +242,8 @@ impl InitialSnapshotRetrieved { // else we are outside the expectations of our state machine ntp_adjtime .adjust_clock(offset_and_rtt.offset(), freq) - .unwrap(); - debug!("ClockAdjustmentState: InitialSnapshotA now transitioning to PhaseCorrecting"); + .expect("failed to adjust clock frequency and phase correction"); + debug!("InitialSnapshotRetrieved now transitioning to ClockAdjusted"); ClockAdjusted { instant: tokio::time::Instant::now(), } @@ -246,15 +254,24 @@ pub(super) struct ClockAdjusted { pub(super) instant: tokio::time::Instant, } impl ClockAdjusted { + /// Duration which we should stay in `ClockAdjusted` (letting the PLL phase correction run). + /// The amount of phase offset corrected via PLL slewing can be approximated based on PLL, + /// to be `(1 - 0.75^n)` where `n` = seconds since PLL start. + /// With 8 seconds, we slew approximately 90% of the offset we intended to. + pub(super) const DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(8); + #[must_use] #[allow(clippy::unused_self)] - pub(super) fn transition(&self, ntp_adjtime: &impl NtpAdjTimeExt) -> PhaseCorrectHalted { + pub(super) fn to_phase_correct_halted( + &self, + ntp_adjtime: &impl NtpAdjTimeExt, + ) -> PhaseCorrectHalted { // Unwrap safety: If we can't adjust the clock, better to panic, // else we are outside the expectations of our state machine ntp_adjtime .apply_phase_correction(Duration::from_secs(0)) - .unwrap(); - debug!("ClockAdjustmentPhaseCorrecting now transitioning to PhaseCorrectHalt"); + .expect("failed to halt phase correction"); + debug!("ClockAdjusted now transitioning to PhaseCorrectHalted"); PhaseCorrectHalted { instant: tokio::time::Instant::now(), } @@ -265,10 +282,19 @@ pub(super) struct PhaseCorrectHalted { pub(super) instant: tokio::time::Instant, } impl PhaseCorrectHalted { + /// Duration which we should stay in `PhaseCorrectHalted`. + /// Since PLL adjustment starts at the top of a second, this should take at least one second. + /// It's possible the clock used for calculation of `Duration` in our async runtime + /// runs a bit slower than this though, so we use 2 seconds to be safe. + pub(super) const DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(2); + #[must_use] #[allow(clippy::unused_self)] - pub(super) fn transition(&self, ntp_adjtime: &impl NtpAdjTimeExt) -> SnapshotRetrieved { - debug!("ClockAdjustmentPhaseCorrectHalted now transitioning to SnapshotRetrieved"); + pub(super) fn to_snapshot_retrieved( + &self, + ntp_adjtime: &impl NtpAdjTimeExt, + ) -> SnapshotRetrieved { + debug!("PhaseCorrectHalted now transitioning to SnapshotRetrieved"); SnapshotRetrieved { instant: tokio::time::Instant::now(), snapshot: ClockSnapshot::retrieve(ntp_adjtime), @@ -281,9 +307,13 @@ pub(super) struct SnapshotRetrieved { pub(super) snapshot: ClockSnapshot, } impl SnapshotRetrieved { + /// Duration which we should stay in `SnapshotRetrieved`. We calculate relative frequency adjustment to apply + /// to have `CLOCK_REALTIME` match `ClockBound` clock rate over this duration, by grabbing two snapshots A and B. + pub(super) const DURATION: tokio::time::Duration = tokio::time::Duration::from_secs(10); + #[must_use] #[allow(clippy::unused_self)] - pub(super) fn transition( + pub(super) fn to_clock_adjusted( &self, ntp_adjtime: &impl NtpAdjTimeExt, clock_params: &ClockParameters, @@ -298,8 +328,8 @@ impl SnapshotRetrieved { // else we are outside the expectations of our state machine ntp_adjtime .adjust_clock(offset_and_rtt.offset(), freq) - .unwrap(); - debug!("ClockAdjustmentState: SnapshotA now transitioning to PhaseCorrecting"); + .expect("failed to adjust clock frequency and phase correction"); + debug!("SnapshotRetrieved now transitioning to ClockAdjusted"); ClockAdjusted { instant: tokio::time::Instant::now(), } @@ -373,11 +403,9 @@ fn calculate_frequency_correction( let old_freq = old_snapshot.kernel_state.freq(); let diff_clock_realtime = new_realtime_ts - old_realtime_ts; - let diff_clockbound = Duration::from_seconds_f64( - (new_tsc_value.get() - old_tsc_value.get()) as f64 * clock_params.period.get(), - ); - let clockbound_rate_wrt_clock_realtime = - diff_clockbound.get() as f64 / diff_clock_realtime.get() as f64; + let diff_clockbound = + (new_tsc_value.get() - old_tsc_value.get()) as f64 * clock_params.period.get(); + let clockbound_rate_wrt_clock_realtime = diff_clockbound / diff_clock_realtime.get() as f64; let old_frequency_clock_realtime = old_freq.get() + 1.0; let fractional_correction = @@ -401,7 +429,9 @@ impl ClockSnapshot { // Unwrap safety: retrieving adjtime parameters should succeed regardless of // timex status. If we received an actual error from the system call, we have // nothing better we can do here - let kernel_state = ntp_adjtime.read_adjtime().unwrap(); + let kernel_state = ntp_adjtime + .read_adjtime() + .expect("failed to retrieve ntp_adjtime parameters from kernel"); let system_clock = SystemClockMeasurement::now(); Self { system_clock, @@ -450,11 +480,11 @@ mod test { .once() .return_once(move |_: Duration| Ok(Timex::retrieve())); let disrupted = Disrupted; - let _ = disrupted.transition(&mock_ntp_adjtime); + let _ = disrupted.to_initial_phase_correct_halted(&mock_ntp_adjtime); } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to halt phase correction: BadState(1)")] fn disrupted_to_initial_phase_correct_halted_panic_on_failed_adjtime() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -462,7 +492,7 @@ mod test { .once() .return_once(move |_: Duration| Err(NtpAdjTimeError::BadState(1))); let disrupted = Disrupted; - let _ = disrupted.transition(&mock_ntp_adjtime); + let _ = disrupted.to_initial_phase_correct_halted(&mock_ntp_adjtime); } #[test] @@ -477,11 +507,12 @@ mod test { .once() .return_once(move |_: Duration| Ok(Timex::retrieve())); let initialized = Initialized; - let _ = initialized.transition(&mock_ntp_adjtime, &test_clock_parameters()); + let _ = initialized + .to_initial_phase_correct_halted(&mock_ntp_adjtime, &test_clock_parameters()); } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to halt phase correction: BadState(1)")] fn initialized_to_initial_phase_correct_halted_panic_on_phase_correct_halt_failure() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -493,11 +524,12 @@ mod test { .never() .return_once(move |_: Duration| Ok(Timex::retrieve())); let initialized = Initialized; - let _ = initialized.transition(&mock_ntp_adjtime, &test_clock_parameters()); + let _ = initialized + .to_initial_phase_correct_halted(&mock_ntp_adjtime, &test_clock_parameters()); } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to step clock: BadState(1)")] fn initialized_to_initial_phase_correct_halted_panic_on_step_clock_failure() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -509,7 +541,8 @@ mod test { .once() .return_once(move |_: Duration| Err(NtpAdjTimeError::BadState(1))); let initialized = Initialized; - let _ = initialized.transition(&mock_ntp_adjtime, &test_clock_parameters()); + let _ = initialized + .to_initial_phase_correct_halted(&mock_ntp_adjtime, &test_clock_parameters()); } #[test] @@ -522,11 +555,11 @@ mod test { let initial_phase_correct_halted = InitialPhaseCorrectHalted { instant: tokio::time::Instant::now(), }; - let _ = initial_phase_correct_halted.transition(&mock_ntp_adjtime); + let _ = initial_phase_correct_halted.to_initial_snapshot_retrieved(&mock_ntp_adjtime); } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to retrieve ntp_adjtime parameters from kernel: BadState(1)")] fn initial_phase_correct_halted_to_initial_snapshot_retrieved_panic_on_fail_adjtime() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -536,7 +569,7 @@ mod test { let initial_phase_correct_halted = InitialPhaseCorrectHalted { instant: tokio::time::Instant::now(), }; - let _ = initial_phase_correct_halted.transition(&mock_ntp_adjtime); + let _ = initial_phase_correct_halted.to_initial_snapshot_retrieved(&mock_ntp_adjtime); } #[test] @@ -554,7 +587,7 @@ mod test { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot(), }; - let _ = initial_snapshot_retrieved.transition( + let _ = initial_snapshot_retrieved.to_clock_adjusted( &mock_ntp_adjtime, &test_clock_parameters(), &test_clock_snapshot(), @@ -562,7 +595,7 @@ mod test { } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to retrieve ntp_adjtime parameters from kernel: BadState(1)")] fn initial_snapshot_retrieved_to_clock_adjusted_panic_on_failed_retrieve() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -577,7 +610,7 @@ mod test { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot(), }; - let _ = initial_snapshot_retrieved.transition( + let _ = initial_snapshot_retrieved.to_clock_adjusted( &mock_ntp_adjtime, &test_clock_parameters(), &test_clock_snapshot(), @@ -585,7 +618,7 @@ mod test { } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to adjust clock frequency and phase correction: BadState(1)")] fn initial_snapshot_retrieved_to_clock_adjusted_panic_on_failed_adjustment() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -600,7 +633,7 @@ mod test { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot(), }; - let _ = initial_snapshot_retrieved.transition( + let _ = initial_snapshot_retrieved.to_clock_adjusted( &mock_ntp_adjtime, &test_clock_parameters(), &test_clock_snapshot(), @@ -617,11 +650,11 @@ mod test { let clock_adjusted = ClockAdjusted { instant: tokio::time::Instant::now(), }; - let _ = clock_adjusted.transition(&mock_ntp_adjtime); + let _ = clock_adjusted.to_phase_correct_halted(&mock_ntp_adjtime); } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to halt phase correction: BadState(1)")] fn clock_adjusted_to_phase_correct_halted_panic_on_failed_phase_correct_halt() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -631,7 +664,7 @@ mod test { let clock_adjusted = ClockAdjusted { instant: tokio::time::Instant::now(), }; - let _ = clock_adjusted.transition(&mock_ntp_adjtime); + let _ = clock_adjusted.to_phase_correct_halted(&mock_ntp_adjtime); } #[test] @@ -644,11 +677,11 @@ mod test { let phase_correct_halted = PhaseCorrectHalted { instant: tokio::time::Instant::now(), }; - let _ = phase_correct_halted.transition(&mock_ntp_adjtime); + let _ = phase_correct_halted.to_snapshot_retrieved(&mock_ntp_adjtime); } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to retrieve ntp_adjtime parameters from kernel: BadState(1)")] fn phase_correct_halted_to_snapshot_retrieved_panic_on_failed_retrieve() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -658,7 +691,7 @@ mod test { let phase_correct_halted = PhaseCorrectHalted { instant: tokio::time::Instant::now(), }; - let _ = phase_correct_halted.transition(&mock_ntp_adjtime); + let _ = phase_correct_halted.to_snapshot_retrieved(&mock_ntp_adjtime); } #[test] @@ -676,7 +709,7 @@ mod test { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot(), }; - let _ = snapshot_retrieved.transition( + let _ = snapshot_retrieved.to_clock_adjusted( &mock_ntp_adjtime, &test_clock_parameters(), &test_clock_snapshot(), @@ -684,7 +717,7 @@ mod test { } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to retrieve ntp_adjtime parameters from kernel: BadState(1)")] fn snapshot_retrieved_to_clock_adjusted_panic_on_failed_retrieve() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -699,7 +732,7 @@ mod test { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot(), }; - let _ = snapshot_retrieved.transition( + let _ = snapshot_retrieved.to_clock_adjusted( &mock_ntp_adjtime, &test_clock_parameters(), &test_clock_snapshot(), @@ -707,7 +740,7 @@ mod test { } #[test] - #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: BadState(1)")] + #[should_panic(expected = "failed to adjust clock frequency and phase correction: BadState(1)")] fn snapshot_retrieved_to_clock_adjusted_panic_on_failed_adjustment() { let mut mock_ntp_adjtime = MockNtpAdjTimeExt::new(); mock_ntp_adjtime @@ -722,7 +755,7 @@ mod test { instant: tokio::time::Instant::now(), snapshot: test_clock_snapshot(), }; - let _ = snapshot_retrieved.transition( + let _ = snapshot_retrieved.to_clock_adjusted( &mock_ntp_adjtime, &test_clock_parameters(), &test_clock_snapshot(), From 14c527a5478fb438b80299075cb2a0770f09ba28 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 13 Nov 2025 09:06:32 -0500 Subject: [PATCH 112/177] Fix bug in frequency correction (#133) * Fix bug in frequency correction * Add back unit tests for calculate_frequency_correction (#134) Unit tests were lost between some PRs for calculation of the frequency correction, when they were moved into a new module state_machine for the larger changes in clock_adjust. These would have caught an issue in the frequency calculation sooner - they emulate different input snapshots and how the frequency calculation is made for those. * Remove TODO comment for unit tests The TODO has been TODONE --------- Co-authored-by: tphan25 Co-authored-by: Tom Phan --- .../clock_state/clock_adjust/state_machine.rs | 196 +++++++++++++++++- 1 file changed, 194 insertions(+), 2 deletions(-) diff --git a/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs b/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs index 4bd8ba3..40ff76e 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs @@ -403,9 +403,10 @@ fn calculate_frequency_correction( let old_freq = old_snapshot.kernel_state.freq(); let diff_clock_realtime = new_realtime_ts - old_realtime_ts; - let diff_clockbound = + let diff_clockbound_seconds = (new_tsc_value.get() - old_tsc_value.get()) as f64 * clock_params.period.get(); - let clockbound_rate_wrt_clock_realtime = diff_clockbound / diff_clock_realtime.get() as f64; + let clockbound_rate_wrt_clock_realtime = + diff_clockbound_seconds / diff_clock_realtime.as_seconds_f64(); let old_frequency_clock_realtime = old_freq.get() + 1.0; let fractional_correction = @@ -442,6 +443,8 @@ impl ClockSnapshot { #[cfg(test)] mod test { + use rstest::rstest; + use crate::daemon::{ clock_state::clock_adjust::{NtpAdjTimeError, ntp_adjtime::MockNtpAdjTimeExt}, time::{ @@ -761,4 +764,193 @@ mod test { &test_clock_snapshot(), ); } + + #[rstest] + #[case::clocks_aligned( + ClockParameters { + tsc_count: TscCount::new(0), + period: Period::from_frequency(Frequency::from_hz(1.0)), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period_max_error: Period::from_seconds(0.0), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(0), + system_time: Instant::from_secs(0), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(0.0)), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(1), + system_time: Instant::from_secs(1), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(0.0)), // not used + }, + Skew::from_ppm(0.0), + )] + #[case::clockbound_1_ppm_slow_of_clock_realtime_with_initial_freq_100_ppm( + // 1.000_100 * 0.999_999 = 1.000_098_999_900 + ClockParameters { + tsc_count: TscCount::new(0), + period: Frequency::from_hz(1_000_000.0).period(), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period_max_error: Period::from_seconds(0.0), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(0), + system_time: Instant::from_secs(0), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(100.0)), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(999_999), + system_time: Instant::from_secs(1), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(0.0)), // not used + }, + Skew::from_ppm(98.999_900), + )] + #[case::clockbound_1_ppm_fast_of_clock_realtime_with_initial_freq_100_ppm( + // 1.000_100 * 1.000_001 = 1.000_101_000_100 + ClockParameters { + tsc_count: TscCount::new(0), + period: Frequency::from_hz(1_000_000.0).period(), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period_max_error: Period::from_seconds(0.0), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(0), + system_time: Instant::from_secs(0), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(100.0)), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(1_000_001), + system_time: Instant::from_secs(1), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(0.0)), // not used + }, + Skew::from_ppm(101.000_100), + )] + #[case::clockbound_2_point_5_ppm_fast_of_clock_realtime_with_initial_freq_100_ppm( + // 1.000_100 * 1.000_002_500 = 1.000_102_500_250 + ClockParameters { + tsc_count: TscCount::new(0), + period: Frequency::from_hz(1_000_000.0).period(), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period_max_error: Period::from_seconds(0.0), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(0), + system_time: Instant::from_secs(0), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(100.0)), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(2_000_005), + system_time: Instant::from_secs(2), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(0.0)), // not used + }, + Skew::from_ppm(102.50025), + )] + #[case::clockbound_2_point_5_ppm_slow_of_clock_realtime_with_initial_freq_100_ppm( + // 1.000_100 * 0.999_997_500 = 1.000_097_499_750 + ClockParameters { + tsc_count: TscCount::new(0), + period: Frequency::from_hz(1_000_000.0).period(), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period_max_error: Period::from_seconds(0.0), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(0), + system_time: Instant::from_secs(0), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(100.0)), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(1_999_995), + system_time: Instant::from_secs(2), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(0.0)), // not used + }, + Skew::from_ppm(97.499_750), + )] + #[case::clockbound_2_point_5_ppm_fast_of_clock_realtime_with_initial_freq_negative_100_ppm( + // 0.999_900 * 1.000_002_500 = 0.999_902_499 + // -(1 - 0.999_902_499) = -0.000_097_500_250 + ClockParameters { + tsc_count: TscCount::new(0), + period: Frequency::from_hz(1_000_000.0).period(), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period_max_error: Period::from_seconds(0.0), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(0), + system_time: Instant::from_secs(0), + }, + kernel_state: Timex::frequency_correction(-Skew::from_ppm(100.0)), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(2_000_005), + system_time: Instant::from_secs(2), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(0.0)), // not used + }, + -Skew::from_ppm(97.500_250), + )] + #[case::clockbound_2_point_5_ppm_slow_of_clock_realtime_with_initial_freq_negative_100_ppm( + // 0.999_900 * 0.999_997_500 = 0.999_897_500_250 + // -(1 - 0.999_897_500_250) = -0.000_102_499_749 + ClockParameters { + tsc_count: TscCount::new(0), + period: Frequency::from_hz(1_000_000.0).period(), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period_max_error: Period::from_seconds(0.0), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(0), + system_time: Instant::from_secs(0), + }, + kernel_state: Timex::frequency_correction(-Skew::from_ppm(100.0)), + }, + ClockSnapshot { + system_clock: SystemClockMeasurement { + tsc: TscCount::new(1_999_995), + system_time: Instant::from_secs(2), + }, + kernel_state: Timex::frequency_correction(Skew::from_ppm(0.0)), // not used + }, + -Skew::from_ppm(102.499_749_999_984_680), + )] + fn test_calculate_new_frequency_correction( + #[case] clock_params: ClockParameters, + #[case] old_snapshot: ClockSnapshot, + #[case] new_snapshot: ClockSnapshot, + #[case] expected: Skew, + ) { + approx::assert_abs_diff_eq!( + calculate_frequency_correction(&clock_params, &old_snapshot, &new_snapshot).get(), + expected.get() + ); + } } From 8661bf682234c6a93197a321ce558d28f8bee338 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:24:36 -0500 Subject: [PATCH 113/177] minor clean-ups in time module (#132) * minor clean-ups in time module This commit includes two very minor clean-ups/changes to code in the `time` module. The first adds a constant for the f64 version of femtoseconds per second being used in `TscDiff` calculations. The latter adds a convenience function for converting `nix:time:timespec`s into the `Instant` type. --- clock-bound/src/daemon/time/clocks.rs | 30 +++------------------------ clock-bound/src/daemon/time/inner.rs | 19 +++++++++++++++++ clock-bound/src/daemon/time/tsc.rs | 6 ++++-- 3 files changed, 26 insertions(+), 29 deletions(-) diff --git a/clock-bound/src/daemon/time/clocks.rs b/clock-bound/src/daemon/time/clocks.rs index 4bd2b9f..3597beb 100644 --- a/clock-bound/src/daemon/time/clocks.rs +++ b/clock-bound/src/daemon/time/clocks.rs @@ -40,18 +40,10 @@ impl Clock for RealTime { /// /// # Panics /// Panics if `clock_gettime` call fails (if pointer allocated for the call is invalid, or `ClockId` supplied is invalid or unavailable on the system) - #[allow( - clippy::cast_possible_truncation, - reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no truncation" - )] - #[allow( - clippy::cast_sign_loss, - reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no loss of sign" - )] fn get_time(&self) -> Instant { // Unwrap safety: `nix` crate supplies valid pointer and `ClockId` so the `clock_gettime` call should not be able to fail let now = clock_gettime(ClockId::CLOCK_REALTIME).unwrap(); - Instant::from_time(now.tv_sec().into(), now.tv_nsec() as u32) + Instant::from_timespec(now) } } @@ -66,18 +58,10 @@ impl Clock for MonotonicRaw { /// /// # Panics /// Panics if `clock_gettime` call fails (if pointer allocated for the call is invalid, or `ClockId` supplied is invalid or unavailable on the system) - #[allow( - clippy::cast_possible_truncation, - reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no truncation" - )] - #[allow( - clippy::cast_sign_loss, - reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no loss of sign" - )] fn get_time(&self) -> Instant { // Unwrap safety: `nix` crate supplies valid pointer and `ClockId` so the `clock_gettime` call should not be able to fail let now = clock_gettime(ClockId::CLOCK_MONOTONIC_RAW).unwrap(); - Instant::from_time(now.tv_sec().into(), now.tv_nsec() as u32) + Instant::from_timespec(now) } } @@ -87,18 +71,10 @@ impl Clock for MonotonicCoarse { /// /// # Panics /// Panics if `clock_gettime` call fails (if pointer allocated for the call is invalid, or `ClockId` supplied is invalid or unavailable on the system) - #[allow( - clippy::cast_possible_truncation, - reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no truncation" - )] - #[allow( - clippy::cast_sign_loss, - reason = "clock_gettime tv_nsec should be between 0 and 1e9-1 so no loss of sign" - )] fn get_time(&self) -> Instant { // Unwrap safety: `nix` crate supplies valid pointer and `ClockId` so the `clock_gettime` call should not be able to fail let now = clock_gettime(ClockId::CLOCK_MONOTONIC_COARSE).unwrap(); - Instant::from_time(now.tv_sec().into(), now.tv_nsec() as u32) + Instant::from_timespec(now) } } diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 88312e2..1ee592b 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -11,6 +11,7 @@ use std::{ }; use libc::timeval; +use nix::sys::time::TimeSpec; /// Abstraction used to reuse basic time arithmetic, but allow for different types based on its usage pub trait Type {} @@ -242,6 +243,24 @@ impl Time { secs + nanos } + /// Construct from a `nix::time::TimeSpec` since the Unix Epoch. + /// Generally only expected to be called using `TimeSpec` retrieved via + /// `nix::time::clock_gettime` call. + /// + /// # Panics + /// Panics if `nanos >= 1_000_000_000`, or value does not fit within the type + #[allow( + clippy::cast_possible_truncation, + reason = "timespec from clock_gettime tv_nsec should be between 0 and 1e9-1 so no truncation" + )] + #[allow( + clippy::cast_sign_loss, + reason = "timespect from clock_gettime tv_nsec should be between 0 and 1e9-1 so no loss of sign" + )] + pub fn from_timespec(timespec: TimeSpec) -> Self { + Self::from_time(timespec.tv_sec().into(), timespec.tv_nsec() as u32) + } + /// Returns the total number of femtoseconds since the Unix Epoch pub const fn as_femtos(self) -> i128 { self.get() diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index 7eaddeb..eae21dc 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -15,6 +15,8 @@ use std::{ use serde::{Deserialize, Serialize}; +const FEMTOS_PER_SEC_F64: f64 = 1.0e15; + const FREQUENCY_TO_TIMEX_SCALE: f64 = (1 << 16) as f64; /// Marker type to crate a raw timestamp with [`super::inner::Time`] @@ -211,7 +213,7 @@ impl Div for TscDiff { fn div(self, rhs: Frequency) -> Self::Output { let raw = self.get() as f64; - let duration_femtos = raw / rhs.0 * 1.0e15; + let duration_femtos = raw / rhs.0 * FEMTOS_PER_SEC_F64; Duration::from_femtos(duration_femtos.round() as i128) } } @@ -221,7 +223,7 @@ impl Mul for Diff { fn mul(self, rhs: Frequency) -> Self::Output { let duration_femtos = self.as_femtos() as f64; - let raw = duration_femtos * rhs.0 / 1.0e15; + let raw = duration_femtos * rhs.0 / FEMTOS_PER_SEC_F64; TscDiff::new(raw.round() as i128) } } From 7ae74a5ca1c9c5e3a0c017e676846a6838e22f31 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Thu, 13 Nov 2025 09:03:07 -0800 Subject: [PATCH 114/177] =?UTF-8?q?[shm]=20put=20the=20existing=20ClockErr?= =?UTF-8?q?orBound=20behind=20a=20versioned=20clockbound=5F=E2=80=A6=20(#1?= =?UTF-8?q?35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [shm] put the existing ClockErrorBound behind a versioned clockbound_enum This patch hides the existing ClockErrorBound behind an enum (of the same name) to allow us to track and manage different layouts and versions of the ClockBound daemon shared memory segment. This patch does not change existing functionality, and is instead a refactor only. It focuses on the current layout version 2 only, but prepares for upcoming changes to introduce layout version 3. --- clock-bound-ffi/src/lib.rs | 12 +- clock-bound/Cargo.toml | 3 +- clock-bound/src/client.rs | 79 ++---- .../daemon/clock_state/clock_state_writer.rs | 73 ++--- clock-bound/src/shm.rs | 262 +++++++++++++----- clock-bound/src/shm/reader.rs | 30 +- clock-bound/src/shm/writer.rs | 21 +- 7 files changed, 295 insertions(+), 185 deletions(-) diff --git a/clock-bound-ffi/src/lib.rs b/clock-bound-ffi/src/lib.rs index 34a1778..3665bd2 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -5,10 +5,8 @@ // Align with C naming conventions #![allow(non_camel_case_types)] -use clock_bound::client::{ - ClockBoundClient, ClockBoundError, ClockBoundErrorKind, ClockBoundNowResult, -}; -use clock_bound::shm::ClockStatus; +use clock_bound::client::{ClockBoundClient, ClockBoundError, ClockBoundErrorKind}; +use clock_bound::shm::{ClockBoundNowResult, ClockStatus}; use clock_bound::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use core::ptr; use errno::Errno; @@ -346,7 +344,7 @@ pub unsafe extern "C" fn clockbound_now( mod t_ffi { use super::*; use byteorder::{LittleEndian, NativeEndian, WriteBytesExt}; - use clock_bound::shm::ClockErrorBound; + use clock_bound::shm::{ClockErrorBound, ClockErrorBoundGeneric}; use std::ffi::CString; use std::fs::OpenOptions; use std::io::Write; @@ -366,8 +364,8 @@ mod t_ffi { $segsize:literal, $version:literal, $generation:literal) => { - // Build a the bound on clock error data - let ceb = ClockErrorBound::default(); + // Build a default ClockErrorBound struct (layout version 2) + let ceb = ClockErrorBoundGeneric::builder().build(2).unwrap(); // Convert the ceb struct into a slice so we can write it all out, fairly magic. // Definitely needs the #[repr(C)] layout. diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index b57e890..825b3f4 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -13,7 +13,7 @@ repository.workspace = true version.workspace = true [dependencies] -bon = { version = "3.8.0", optional = true } +bon = { version = "3.8.0" } byteorder = "1" bytes = { version = "1", optional = true } chrono = { version = "0.4", optional = true } @@ -71,7 +71,6 @@ tracing-test = "0.2.5" [features] client = [] daemon = [ - "dep:bon", "dep:clap", "dep:serde", "dep:tokio", diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index a707766..33e2525 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -3,11 +3,10 @@ pub use crate::shm::CLOCKBOUND_SHM_DEFAULT_PATH; pub use crate::shm::ClockStatus; use crate::shm::ShmReader; -use crate::shm::{ClockErrorBound, ShmError}; +use crate::shm::{ClockBoundNowResult, ClockBoundSnapshot, ClockErrorBound, ShmError}; pub use crate::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use crate::vmclock::shm_reader::VMClockShmReader; use errno::Errno; -use nix::sys::time::TimeSpec; use std::ffi::CString; use std::path::Path; @@ -70,7 +69,7 @@ impl ClockBoundClient { // Create the VMClock shared memory accessor let vmclock_shm = VMClockSHM::new( vmclock_shm_path, - cb_snapshot.clock_disruption_support_enabled, + cb_snapshot.clock_disruption_support_enabled(), )?; Ok(ClockBoundClient { @@ -94,12 +93,13 @@ impl ClockBoundClient { // The very first thing to do is to read from the ClockBound shared memory segment, take a // snapshot to obtain the clock parameters and bound on error, and create a timestamp. let cb_snap = self.clockbound_shm.snapshot()?; - let (earliest, latest, clock_status) = cb_snap.now()?; + let mut clock_bound_now_result = cb_snap.now()?; // Now that the timestamp is created, check whether the clockbound daemon has been // restarted and the option to enable the clock disruption support has been turned on. If // so, need to create a reader for the VMClock device shared memory. - if self.vmclock_shm.vmclock_shm_reader.is_none() && cb_snap.clock_disruption_support_enabled + if self.vmclock_shm.vmclock_shm_reader.is_none() + && cb_snap.clock_disruption_support_enabled() { self.vmclock_shm.vmclock_shm_reader = Some(VMClockShmReader::new( self.vmclock_shm.vmclock_shm_path.as_str(), @@ -112,22 +112,16 @@ impl ClockBoundClient { // marker between the clockbound daemon and the VMClock. If these disagree, a disruption // has occured, and the clockbound daemon has not recovered from it yet. let is_disrupted = match self.vmclock_shm.disruption_marker()? { - Some(marker) => marker != cb_snap.disruption_marker, + Some(marker) => marker != cb_snap.disruption_marker(), None => false, }; // If the clock is disrupted, overwrite the status - let result_status = if is_disrupted { - ClockStatus::Disrupted - } else { - clock_status - }; + if is_disrupted { + clock_bound_now_result.clock_status = ClockStatus::Disrupted; + } - Ok(ClockBoundNowResult { - earliest, - latest, - clock_status: result_status, - }) + Ok(clock_bound_now_result) } } @@ -224,14 +218,6 @@ impl VMClockSHM { } } -/// Result of the `ClockBoundClient::now()` function. -#[derive(PartialEq, Clone, Debug)] -pub struct ClockBoundNowResult { - pub earliest: TimeSpec, - pub latest: TimeSpec, - pub clock_status: ClockStatus, -} - #[derive(Debug)] pub struct ClockBoundError { pub kind: ClockBoundErrorKind, @@ -284,10 +270,13 @@ pub enum ClockBoundErrorKind { #[cfg(test)] mod lib_tests { use super::*; + use crate::shm::ClockErrorBoundGeneric; use crate::shm::{ClockErrorBound, ShmWrite, ShmWriter}; use crate::vmclock::shm::{VMClockClockStatus, VMClockShmBody}; use crate::vmclock::shm_writer::{VMClockShmWrite, VMClockShmWriter}; + use byteorder::{NativeEndian, WriteBytesExt}; + use nix::sys::time::TimeSpec; use std::ffi::CStr; use std::fs::{File, OpenOptions}; use std::io::Write; @@ -307,16 +296,11 @@ mod lib_tests { $segsize:literal, $version:literal, $generation:literal) => { - // Build a the bound on clock error data - let ceb = ClockErrorBound::new( - TimeSpec::new(0, 0), // as_of - TimeSpec::new(0, 0), // void_after - 0, // bound_nsec - 0, // disruption_marker - 0, // max_drift_ppb - ClockStatus::Unknown, // clock_status - true, // clock_disruption_support_enabled - ); + // Build a default ClockErrorBound layout version 2 + let ceb = ClockErrorBoundGeneric::builder() + .clock_disruption_support_enabled(true) + .build(2) + .unwrap(); // Convert the ceb struct into a slice so we can write it all out, fairly magic. // Definitely needs the #[repr(C)] layout. @@ -706,15 +690,7 @@ mod lib_tests { let mut writer = ShmWriter::new(Path::new(clockbound_shm_path)).expect("Failed to create a writer"); - let ceb = ClockErrorBound::new( - TimeSpec::new(0, 0), // as_of - TimeSpec::new(0, 0), // void_after - 0, // bound_nsec - 0, // disruption_marker - 0, // max_drift_ppb - ClockStatus::Unknown, // clock_status - true, // clock_disruption_support_enabled - ); + let ceb = ClockErrorBoundGeneric::builder().build(2).unwrap(); writer.write(&ceb); let mut clockbound = @@ -732,15 +708,14 @@ mod lib_tests { // Write out data with a extremely high max_drift_ppb value so that // the client will have an error when calling now(). - let ceb = ClockErrorBound::new( - TimeSpec::new(100, 0), - TimeSpec::new(10, 0), - 0, - 0, - 1_000_000_000, // max_drift_ppb - ClockStatus::Synchronized, - true, - ); + let ceb = ClockErrorBoundGeneric::builder() + .as_of(TimeSpec::new(100, 0)) + .void_after(TimeSpec::new(10, 0)) + .max_drift_ppb(1_000_000_000) + .clock_status(ClockStatus::Synchronized) + .clock_disruption_support_enabled(true) + .build(2) + .unwrap(); writer.write(&ceb); // Validate now has Result with an error. diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index fe4a053..c5bf3b9 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -10,7 +10,7 @@ use crate::{ instant::Utc, }, }, - shm::{ClockErrorBound, ClockStatus, ShmWrite, ShmWriter}, + shm::{ClockErrorBound, ClockErrorBoundGeneric, ClockStatus, ShmWrite, ShmWriter}, }; /// The drift rate/maximal frequency error in parts-per-billion @@ -125,25 +125,33 @@ impl ClockStateWriter { } fn write_shm(&mut self, as_of: Instant, bound_nsec: i64, clock_status: ClockStatus) { - let void_after = as_of + Duration::from_secs(1000); - // TODO: It may be worthwhile to add to this max drift ppb base the following components: - // - any slew rate for phase correction, since kernel clocks are used on client side - // - error inherent to our frequency calculation e.g. `period_max_error` - let max_drift_ppb = self.max_drift_ppb; - let ceb: ClockErrorBound = ClockErrorBound::new( - // Unwrap safety: unlikely to fail for any value for the distant future, - // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds - TimeSpec::try_from(as_of).unwrap(), - // Unwrap safety: unlikely to fail for any value for the distant future, - // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds - TimeSpec::try_from(void_after).unwrap(), - bound_nsec, - self.disruption_marker, - max_drift_ppb, - clock_status, - self.clock_disruption_support_enabled, - ); - self.shm_writer.write(&ceb); + let ceb = ClockErrorBoundGeneric::builder() + .as_of( + // Unwrap safety: unlikely to fail for any value for the distant future, + // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds + TimeSpec::try_from(as_of).unwrap(), + ) + .void_after( + // Unwrap safety: unlikely to fail for any value for the distant future, + // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds + TimeSpec::try_from(as_of + Duration::from_secs(1000)).unwrap(), + ) + .bound_nsec(bound_nsec) + .disruption_marker(self.disruption_marker) + .max_drift_ppb( + // TODO: It may be worthwhile to add to this max drift ppb base the following + // components: + // - any slew rate for phase correction, since kernel clocks are used on client side + // - error inherent to our frequency calculation e.g. `period_max_error` + self.max_drift_ppb, + ) + .clock_status(clock_status) + .clock_disruption_support_enabled(self.clock_disruption_support_enabled) + .build(2); + + if let Ok(ceb) = ceb { + self.shm_writer.write(&ceb); + } } } @@ -167,13 +175,11 @@ fn get_bound( #[cfg(test)] mod tests { + use super::*; + use crate::daemon::time::{Duration, TscCount, tsc::Period}; use mockall::mock; use rstest::rstest; - use crate::daemon::time::{Duration, TscCount, tsc::Period}; - - use super::*; - mock! { ShmWriter {} impl ShmWrite for ShmWriter { @@ -220,15 +226,16 @@ mod tests { let max_drift_ppb = 234; let disruption_marker = 345; let clock_disruption_support_enabled = true; - let expected_ceb = ClockErrorBound::new( - TimeSpec::try_from(as_of).unwrap(), - TimeSpec::try_from(as_of + Duration::from_secs(1000)).unwrap(), - bound_nsec, - disruption_marker, - max_drift_ppb, - clock_status, - clock_disruption_support_enabled, - ); + let expected_ceb = ClockErrorBoundGeneric::builder() + .as_of(TimeSpec::try_from(as_of).unwrap()) + .void_after(TimeSpec::try_from(as_of + Duration::from_secs(1000)).unwrap()) + .bound_nsec(bound_nsec) + .disruption_marker(disruption_marker) + .max_drift_ppb(max_drift_ppb) + .clock_status(clock_status) + .clock_disruption_support_enabled(clock_disruption_support_enabled) + .build(2) + .unwrap(); let mut shm_writer = MockShmWriter::new(); shm_writer .expect_write() diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 3974228..95496aa 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -17,6 +17,7 @@ mod writer; pub use reader::ShmReader; pub use writer::{ShmWrite, ShmWriter}; +use bon::Builder; use errno::Errno; use nix::sys::time::{TimeSpec, TimeValLike}; use std::error::Error; @@ -41,6 +42,140 @@ macro_rules! syserror { }; } +pub trait ClockBoundSnapshot { + /// The `ClockErrorBound` equivalent of `clock_gettime()`, but with bound on accuracy. + /// + /// Returns a `ClockBoundNowResult` with contains the (earliest, latest) timespec between which + /// current time exists. The interval width is twice the clock error bound (ceb) such that: + /// (earliest, latest) = ((now - ceb), (now + ceb)) + /// + /// The function also returns a clock status to assert that the clock is being synchronized, or + /// free-running, or ... + #[expect(clippy::missing_errors_doc, reason = "todo")] + fn now(&self) -> Result; +} + +/// Enum that holds supported layout of the `ClockErrorBound` stored in the ClockBound daemon +/// shared memory segment. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum ClockErrorBound { + V2(ClockErrorBoundV2), +} + +impl ClockErrorBound { + pub fn as_of(&self) -> TimeSpec { + match self { + ClockErrorBound::V2(ceb) => ceb.as_of, + } + } + + pub fn void_after(&self) -> TimeSpec { + match self { + ClockErrorBound::V2(ceb) => ceb.void_after, + } + } + + pub fn bound_nsec(&self) -> i64 { + match self { + ClockErrorBound::V2(ceb) => ceb.bound_nsec, + } + } + + pub fn max_drift_ppb(&self) -> u32 { + match self { + ClockErrorBound::V2(ceb) => ceb.max_drift_ppb, + } + } + + pub fn clock_status(&self) -> ClockStatus { + match self { + ClockErrorBound::V2(ceb) => ceb.clock_status, + } + } + + pub fn disruption_marker(&self) -> u64 { + match self { + ClockErrorBound::V2(ceb) => ceb.disruption_marker, + } + } + + pub fn clock_disruption_support_enabled(&self) -> bool { + match self { + ClockErrorBound::V2(ceb) => ceb.clock_disruption_support_enabled, + } + } +} + +impl ClockBoundSnapshot for ClockErrorBound { + fn now(&self) -> Result { + match self { + ClockErrorBound::V2(ceb) => ceb.now(), + } + } +} + +/// Generic `ClockErrorBound` builder. +#[derive(Builder)] +// Rename auto-generated build() function into build_internal so we have a custom finishing +// function to create the enum variants +#[builder(finish_fn(vis = "", name = build_internal))] +pub struct ClockErrorBoundGeneric { + #[builder(default = TimeSpec::new(0, 0))] + as_of: TimeSpec, + + #[builder(default = TimeSpec::new(0, 0))] + void_after: TimeSpec, + + #[builder(default)] + bound_nsec: i64, + + #[builder(default)] + disruption_marker: u64, + + #[builder(default)] + max_drift_ppb: u32, + + #[builder(default = ClockStatus::Unknown)] + clock_status: ClockStatus, + + #[builder(default)] + clock_disruption_support_enabled: bool, +} + +impl ClockErrorBoundGenericBuilder { + /// Custom `build` finishing function on the generated `ClockErrorBoundLayoutBuilder`. + /// + /// Take the layout version number as a parameter, it is a u16 to ease casting of the earlier + /// version of the `SHMHeader`. + #[expect(clippy::missing_errors_doc, reason = "todo")] + pub fn build(self, layout_version: u16) -> Result { + // Build the ClockErrorBoundGeneric object + let ceb = self.build_internal(); + + // Build the specific version of the ClockErrorBound + match layout_version { + 2 => Ok(ClockErrorBound::V2(ClockErrorBoundV2::new( + ceb.as_of, + ceb.void_after, + ceb.bound_nsec, + ceb.disruption_marker, + ceb.max_drift_ppb, + ceb.clock_status, + ceb.clock_disruption_support_enabled, + ))), + _ => Err(ShmError::SegmentVersionNotSupported), + } + } +} + +/// Result of the `ClockBoundClient::now()` function. +#[derive(PartialEq, Clone, Debug)] +pub struct ClockBoundNowResult { + pub earliest: TimeSpec, + pub latest: TimeSpec, + pub clock_status: ClockStatus, +} + /// Error condition returned by all low-level ClockBound APIs. /// // FIXME: the `detail` static CString referenced on the Syscall variant can be changed. The C @@ -129,7 +264,7 @@ pub enum ClockStatus { /// this specific layout. #[repr(C)] #[derive(Debug, Copy, Clone, PartialEq)] -pub struct ClockErrorBound { +pub struct ClockErrorBoundV2 { /// The `CLOCK_MONOTONIC_COARSE` timestamp recorded when the bound on clock error was /// calculated. The current implementation relies on Chrony tracking data, which accounts for /// the dispersion between the last clock processing event, and the reading of tracking data. @@ -170,24 +305,7 @@ pub struct ClockErrorBound { _padding: [u8; 7], } -impl Default for ClockErrorBound { - /// Get a default `ClockErrorBound` struct - /// Equivalent to zero'ing this bit of memory - fn default() -> Self { - ClockErrorBound { - as_of: TimeSpec::new(0, 0), - void_after: TimeSpec::new(0, 0), - bound_nsec: 0, - disruption_marker: 0, - max_drift_ppb: 0, - clock_status: ClockStatus::Unknown, - clock_disruption_support_enabled: false, - _padding: [0u8; 7], - } - } -} - -impl ClockErrorBound { +impl ClockErrorBoundV2 { /// Create a new `ClockErrorBound` struct. pub fn new( as_of: TimeSpec, @@ -197,8 +315,8 @@ impl ClockErrorBound { max_drift_ppb: u32, clock_status: ClockStatus, clock_disruption_support_enabled: bool, - ) -> ClockErrorBound { - ClockErrorBound { + ) -> ClockErrorBoundV2 { + ClockErrorBoundV2 { as_of, void_after, bound_nsec, @@ -210,7 +328,8 @@ impl ClockErrorBound { } } - /// The `ClockErrorBound` equivalent of `clock_gettime()`, but with bound on accuracy. + /// The `ClockErrorBoundV2` implementation of `now()`, a `clock_gettime()` equivalent but with + /// bound on clock accuracy. /// /// Returns a pair of (earliest, latest) timespec between which current time exists. The /// interval width is twice the clock error bound (ceb) such that: @@ -218,7 +337,7 @@ impl ClockErrorBound { /// The function also returns a clock status to assert that the clock is being synchronized, or /// free-running, or ... #[expect(clippy::missing_errors_doc, reason = "todo")] - pub fn now(&self) -> Result<(TimeSpec, TimeSpec, ClockStatus), ShmError> { + pub fn now(&self) -> Result { // Read the clock, start with the REALTIME one to be as close as possible to the event the // caller is interested in. The monotonic clock should be read after. It is correct for the // process be preempted between the two calls: a delayed read of the monotonic clock will @@ -245,7 +364,7 @@ impl ClockErrorBound { &self, real: TimeSpec, mono: TimeSpec, - ) -> Result<(TimeSpec, TimeSpec, ClockStatus), ShmError> { + ) -> Result { // Sanity checks: // - `now()` should operate on a consistent snapshot of the shared memory segment, and // causality between mono and as_of should be enforced. @@ -343,39 +462,28 @@ impl ClockErrorBound { let earliest = real - updated_bound; let latest = real + updated_bound; - Ok((earliest, latest, clock_status)) + Ok(ClockBoundNowResult { + earliest, + latest, + clock_status, + }) } } -/// Getters exposed for the sake of unit tests across other modules in crate -#[cfg(test)] -impl ClockErrorBound { - pub fn as_of(&self) -> TimeSpec { - self.as_of - } - - pub fn void_after(&self) -> TimeSpec { - self.void_after - } - - pub fn bound_nsec(&self) -> i64 { - self.bound_nsec - } - - pub fn disruption_marker(&self) -> u64 { - self.disruption_marker - } - - pub fn max_drift_ppb(&self) -> u32 { - self.max_drift_ppb - } - - pub fn clock_status(&self) -> ClockStatus { - self.clock_status - } +impl ClockBoundSnapshot for ClockErrorBoundV2 { + /// The `ClockErrorBoundV2` implementation of `now()`. + /// + /// This version relies on the system clock to retrieve the current time as well as grow the + /// bound on the clock error at a constant rate. + fn now(&self) -> Result { + // Read the clock, start with the REALTIME one to be as close as possible to the event the + // caller is interested in. The monotonic clock should be read after. It is correct for the + // process be preempted between the two calls: a delayed read of the monotonic clock will + // make the bound on clock error more pessimistic, but remains correct. + let real = clock_gettime_safe(CLOCK_REALTIME)?; + let mono = clock_gettime_safe(CLOCK_MONOTONIC)?; - pub fn clock_disruption_support_enabled(&self) -> bool { - self.clock_disruption_support_enabled + self.compute_bound_at(real, mono) } } @@ -384,9 +492,9 @@ mod t_lib { use super::*; // Convenience macro to build ClockBoundError for unit tests - macro_rules! clockbound { + macro_rules! clockbound_v2 { (($asof_tv_sec:literal, $asof_tv_nsec:literal), ($after_tv_sec:literal, $after_tv_nsec:literal)) => { - ClockErrorBound::new( + ClockErrorBoundV2::new( TimeSpec::new($asof_tv_sec, $asof_tv_nsec), // as_of TimeSpec::new($after_tv_sec, $after_tv_nsec), // void_after 10000, // bound_nsec @@ -401,11 +509,15 @@ mod t_lib { /// Assert the bound on clock error is computed correctly #[test] fn compute_bound_ok() { - let ceb = clockbound!((0, 0), (10, 0)); + let ceb = clockbound_v2!((0, 0), (10, 0)); let real = TimeSpec::new(2, 0); let mono = TimeSpec::new(2, 0); - let (earliest, latest, status) = ceb + let ClockBoundNowResult { + earliest, + latest, + clock_status, + } = ceb .compute_bound_at(real, mono) .expect("Failed to compute bound"); @@ -415,18 +527,22 @@ mod t_lib { assert_eq!(earliest.tv_nsec(), 1_000_000_000 - 12_000); assert_eq!(latest.tv_sec(), 2); assert_eq!(latest.tv_nsec(), 12_000); - assert_eq!(status, ClockStatus::Synchronized); + assert_eq!(clock_status, ClockStatus::Synchronized); } /// Assert the bound on clock error is computed correctly, with realtime and monotonic clocks /// disagreeing on time #[test] fn compute_bound_ok_when_real_ahead() { - let ceb = clockbound!((0, 0), (10, 0)); + let ceb = clockbound_v2!((0, 0), (10, 0)); let real = TimeSpec::new(20, 0); // realtime clock way ahead let mono = TimeSpec::new(4, 0); - let (earliest, latest, status) = ceb + let ClockBoundNowResult { + earliest, + latest, + clock_status, + } = ceb .compute_bound_at(real, mono) .expect("Failed to compute bound"); @@ -436,18 +552,22 @@ mod t_lib { assert_eq!(earliest.tv_nsec(), 1_000_000_000 - 14_000); assert_eq!(latest.tv_sec(), 20); assert_eq!(latest.tv_nsec(), 14_000); - assert_eq!(status, ClockStatus::Synchronized); + assert_eq!(clock_status, ClockStatus::Synchronized); } /// Assert the clock status is FreeRunning if the ClockErrorBound data is passed the grace /// period #[test] fn compute_bound_force_free_running_status() { - let ceb = clockbound!((0, 0), (100, 0)); + let ceb = clockbound_v2!((0, 0), (100, 0)); let real = TimeSpec::new(8, 0); let mono = TimeSpec::new(8, 0); - let (earliest, latest, status) = ceb + let ClockBoundNowResult { + earliest, + latest, + clock_status, + } = ceb .compute_bound_at(real, mono) .expect("Failed to compute bound"); @@ -457,17 +577,21 @@ mod t_lib { assert_eq!(earliest.tv_nsec(), 1_000_000_000 - 18_000); assert_eq!(latest.tv_sec(), 8); assert_eq!(latest.tv_nsec(), 18_000); - assert_eq!(status, ClockStatus::FreeRunning); + assert_eq!(clock_status, ClockStatus::FreeRunning); } /// Assert the clock status is Unknown if the ClockErrorBound data is passed void_after #[test] fn compute_bound_unknown_status_if_expired() { - let ceb = clockbound!((0, 0), (5, 0)); + let ceb = clockbound_v2!((0, 0), (5, 0)); let real = TimeSpec::new(10, 0); let mono = TimeSpec::new(10, 0); // Passed void_after - let (earliest, latest, status) = ceb + let ClockBoundNowResult { + earliest, + latest, + clock_status, + } = ceb .compute_bound_at(real, mono) .expect("Failed to compute bound"); @@ -477,13 +601,13 @@ mod t_lib { assert_eq!(earliest.tv_nsec(), 1_000_000_000 - 20_000); assert_eq!(latest.tv_sec(), 10); assert_eq!(latest.tv_nsec(), 20_000); - assert_eq!(status, ClockStatus::Unknown); + assert_eq!(clock_status, ClockStatus::Unknown); } /// Assert errors are returned if the ClockBoundError data is malformed with bad drift #[test] fn compute_bound_bad_drift() { - let mut ceb = clockbound!((0, 0), (10, 0)); + let mut ceb = clockbound_v2!((0, 0), (10, 0)); let real = TimeSpec::new(5, 0); let mono = TimeSpec::new(5, 0); ceb.max_drift_ppb = 2_000_000_000; @@ -495,7 +619,7 @@ mod t_lib { /// reading clocks at 'now' #[test] fn compute_bound_causality_break() { - let ceb = clockbound!((5, 0), (10, 0)); + let ceb = clockbound_v2!((5, 0), (10, 0)); let real = TimeSpec::new(1, 0); let mono = TimeSpec::new(1, 0); diff --git a/clock-bound/src/shm/reader.rs b/clock-bound/src/shm/reader.rs index 2b99afc..c64c581 100644 --- a/clock-bound/src/shm/reader.rs +++ b/clock-bound/src/shm/reader.rs @@ -8,7 +8,7 @@ use std::sync::atomic; use crate::shm::shm_header::{CLOCKBOUND_SHM_SUPPORTED_VERSION, ShmHeader}; use crate::{ - shm::{ClockErrorBound, ShmError}, + shm::{ClockErrorBound, ClockErrorBoundGeneric, ShmError}, syserror, }; @@ -184,13 +184,18 @@ impl ShmReader { cursor = unsafe { cursor.add(size_of::()) }; let ceb_shm = ptr::addr_of!(*cursor.cast::()); + // Atomically read the current version in the shared memory segment + // SAFETY: `self.version` has been validated when creating the reader + let shm_version = unsafe { &*version }; + let shm_version = shm_version.load(atomic::Ordering::Acquire); + Ok(ShmReader { _marker: std::marker::PhantomData, _guard: mmap_guard, version, generation, ceb_shm, - snapshot_ceb: ClockErrorBound::default(), + snapshot_ceb: ClockErrorBoundGeneric::builder().build(shm_version)?, snapshot_gen: 0, }) } @@ -327,15 +332,16 @@ mod t_reader { $bound_nsec:literal, $max_drift: literal) => { // Build the bound on clock error data - let ceb = ClockErrorBound::new( - TimeSpec::new($as_of_sec, $as_of_nsec), // as_of - TimeSpec::new($void_after_sec, $void_after_nsec), // void_after - $bound_nsec, // bound_nsec - 0, // disruption_marker - $max_drift, // max_drift_ppb - ClockStatus::Synchronized, // clock_status - true, // clock_disruption_support_enabled - ); + let ceb = ClockErrorBoundGeneric::builder() + .as_of(TimeSpec::new($as_of_sec, $as_of_nsec)) + .void_after(TimeSpec::new($void_after_sec, $void_after_nsec)) + .bound_nsec($bound_nsec) + .disruption_marker(0) + .max_drift_ppb($max_drift) + .clock_status(ClockStatus::Synchronized) + .clock_disruption_support_enabled(true) + .build(2) + .unwrap(); // Convert the ceb struct into a slice so we can write it all out, fairly magic. // Definitely needs the #[repr(C)] layout. @@ -400,7 +406,7 @@ mod t_reader { assert_eq!(version.load(atomic::Ordering::Relaxed), 2); assert_eq!(generation.load(atomic::Ordering::Relaxed), 10); - assert_eq!(ceb.bound_nsec, 123); + assert_eq!(ceb.bound_nsec(), 123); } /// Assert that creating a reader when the diff --git a/clock-bound/src/shm/writer.rs b/clock-bound/src/shm/writer.rs index 61c9d9e..2ad725a 100644 --- a/clock-bound/src/shm/writer.rs +++ b/clock-bound/src/shm/writer.rs @@ -312,19 +312,20 @@ mod t_writer { /// afterwards. use tempfile::NamedTempFile; - use crate::shm::ClockStatus; + use crate::shm::{ClockErrorBoundGeneric, ClockStatus}; macro_rules! clockerrorbound { () => { - ClockErrorBound::new( - TimeSpec::new(1, 2), // as_of - TimeSpec::new(3, 4), // void_after - 123, // bound_nsec - 10, // disruption_marker - 100, // max_drift_ppb - ClockStatus::Synchronized, // clock_status - true, // clock_disruption_support_enabled - ) + ClockErrorBoundGeneric::builder() + .as_of(TimeSpec::new(1, 2)) + .void_after(TimeSpec::new(3, 4)) + .bound_nsec(123) + .disruption_marker(10) + .max_drift_ppb(100) + .clock_status(ClockStatus::Synchronized) + .clock_disruption_support_enabled(true) + .build(2) + .unwrap() }; } From 670435dbba9225a87d3f55da7de7c40162f3a0f0 Mon Sep 17 00:00:00 2001 From: Ryan Luu Date: Thu, 13 Nov 2025 16:12:10 -0500 Subject: [PATCH 115/177] [phc 1/2] Add PHC IO source implementation. (#128) * Add PHC IO source implementation. This commit does not integrate this implementation with the daemon. That will be done in a subsequent commit. Co-authored-by: Jennifer Solidum --- clock-bound/Cargo.toml | 8 +- clock-bound/src/daemon/io.rs | 2 + clock-bound/src/daemon/io/phc.rs | 1192 ++++++++++++++++++++++++++++++ 3 files changed, 1200 insertions(+), 2 deletions(-) create mode 100644 clock-bound/src/daemon/io/phc.rs diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 825b3f4..c341512 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -23,7 +23,7 @@ libc = { version = "0.2", default-features = false, features = [ "extra_traits", ] } md5 = "0.8.0" -nix = { version = "0.26", features = ["feature", "time"] } +nix = { version = "0.26" } nom = { version = "8", optional = true } reqwest = { version = "0.12.24", default-features = false, optional = true } serde = { version = "1.0", features = ["derive"], optional = true } @@ -70,6 +70,7 @@ tracing-test = "0.2.5" [features] client = [] + daemon = [ "dep:clap", "dep:serde", @@ -77,10 +78,13 @@ daemon = [ "dep:chrono", "dep:bytes", "dep:nom", + "dep:reqwest", "dep:thiserror", "dep:tracing-appender", + "nix/feature", + "nix/ioctl", + "nix/time", "tracing-subscriber/env-filter", - "dep:reqwest", ] test-side-by-side = [ ] # run without changing system clock. And compare against system clock diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 3207a24..61bca81 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -26,6 +26,8 @@ use link_local::LinkLocal; mod ntp_source; use ntp_source::NTPSource; +mod phc; + pub mod tsc; mod vmclock; diff --git a/clock-bound/src/daemon/io/phc.rs b/clock-bound/src/daemon/io/phc.rs new file mode 100644 index 0000000..97054cf --- /dev/null +++ b/clock-bound/src/daemon/io/phc.rs @@ -0,0 +1,1192 @@ +//! Module for collecting the phc samples and selecting the best sample + +use super::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; +use super::{ClockDisruptionEvent, ControlRequest}; +use crate::daemon::{ + async_ring_buffer::{self, SendError}, + event::{self, PhcData}, + time::tsc::TscCount, + time::{Duration, Instant}, +}; +use libc::c_uint; +use nix::ioctl_readwrite; +use std::fs::File; +use std::os::unix::io::AsRawFd; +use thiserror::Error; +use tokio::{ + io, + sync::{mpsc, watch}, + task, + time::{Interval, MissedTickBehavior, interval, timeout}, +}; +use tracing::{debug, error, info, warn}; + +const PHC_SOURCE_INTERVAL_DURATION: std::time::Duration = tokio::time::Duration::from_secs(1); + +/// Timeout for a single read of the PHC device. +const PHC_SOURCE_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_millis(20); + +const NUM_SAMPLES_PER_POLL: u32 = 5; + +/// `PTP_SYS_OFFSET_EXTENDED2` ioctl call. +const PTP_SYS_OFFSET_EXTENDED2: u32 = 3_300_932_882; + +/// Maximum number of samples supported within a single `PTP_SYS_OFFSET_EXTENDED2` ioctl call. +const PTP_MAX_SAMPLES: usize = 25; + +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct PtpClockTime { + pub sec: i64, + pub nsec: u32, + pub reserved: u32, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct PtpSysOffsetExtended { + /// Number of samples to collect + pub n_samples: c_uint, + /// Resevered + pub rsv: [c_uint; 3], + /// Array of samples in the form [pre-TS, PHC, post-TS ] + pub ts: [[PtpClockTime; 3]; PTP_MAX_SAMPLES], +} + +ioctl_readwrite!( + ptp_sys_offset_extended2, + b'=', + PTP_SYS_OFFSET_EXTENDED2, + PtpSysOffsetExtended +); + +#[derive(Debug)] +pub enum TryFromPhcSampleError { + InvalidCounterDiff, + InvalidClockErrorBound, + UnexpectedError, +} + +#[derive(Debug, Error)] +pub enum PhcError { + #[error("IO failure")] + Io(#[from] io::Error), + #[error("File does not exist")] + FileNotFound(String), + #[error("PTP device not found")] + PtpDeviceNotFound(String), + #[error("PTP device name not found")] + PtpDeviceNameNotFound(String), + #[error("PCI_SLOT_NAME not found in uevent file")] + PciSlotNameNotFound(String), + #[error("PHC clock error bound file not found")] + PhcClockErrorBoundFileNotFound(String), + #[error("PHC clock error bound read failure")] + PhcClockErrorBoundReadFailure(String), + #[error("Tokio spawn JoinError")] + AsyncTaskJoinError(task::JoinError), + #[error("Device driver name not found")] + DeviceDriverNameNotFound(String), + #[error("PhcSample to event::Phc TryFrom error")] + PhcSampleToPhcEventConversionError(TryFromPhcSampleError), + #[error("Unexpected error")] + UnexpectedError(String), +} + +impl From for PhcError { + fn from(err: tokio::time::error::Elapsed) -> Self { + PhcError::Io(io::Error::from(err)) + } +} + +impl From for PhcError { + fn from(err: task::JoinError) -> Self { + PhcError::AsyncTaskJoinError(err) + } +} + +impl From for PhcError { + fn from(err: nix::Error) -> Self { + PhcError::Io(io::Error::from_raw_os_error(err as i32)) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct PhcSample { + pub counter_pre: u64, + pub counter_post: u64, + pub counter_diff: u64, + + /// Reference clock time + pub ptp_clock_time: PtpClockTime, + + /// Clock error bound of this measurement + pub ptp_clock_error_bound_nsec: i64, +} + +impl TryFrom for event::Phc { + type Error = TryFromPhcSampleError; + + fn try_from(sample: PhcSample) -> Result { + if sample.counter_post < sample.counter_pre { + return Err(TryFromPhcSampleError::InvalidCounterDiff); + } + if sample.ptp_clock_error_bound_nsec < 0 { + return Err(TryFromPhcSampleError::InvalidClockErrorBound); + } + + let builder = event::Phc::builder() + .tsc_pre(TscCount::new(sample.counter_pre.into())) + .tsc_post(TscCount::new(sample.counter_post.into())) + .data(PhcData { + time: Instant::from_time( + sample.ptp_clock_time.sec.into(), + sample.ptp_clock_time.nsec, + ), + clock_error_bound: Duration::from_nanos(sample.ptp_clock_error_bound_nsec.into()), + }); + + match builder.build() { + Some(phc) => Ok(phc), + None => Err(TryFromPhcSampleError::UnexpectedError), + } + } +} + +/// Contains data used to run PHC runner. +/// +/// Notably the struct contains the path to the PHC device +#[derive(Debug)] +pub struct Phc { + /// The message channel used to send PHC events. + event_sender: async_ring_buffer::Sender, + /// The message channel used to receive control requests. + ctrl_receiver: mpsc::Receiver, + /// The message channel used to send clock disruption events. + clock_disruption_receiver: watch::Receiver, + /// The polling interval. + interval: Interval, + /// Reader for the PTP hardware clock (PHC). + reader: PhcReader, + /// Reader for the PTP hardware clock (PHC) clock error bound. + clock_error_bound_reader: PhcClockErrorBoundReader, +} + +impl Phc { + /// Constructs a new `Phc` IO source instance using the given parameters. + /// + /// This implementation performs autoconfiguration to determine + /// the PHC device that should be used for obtaining reference clock + /// timestamps and the corresponding clock error bound for those timestamps. + pub async fn construct( + event_sender: async_ring_buffer::Sender, + ctrl_receiver: mpsc::Receiver, + clock_disruption_receiver: watch::Receiver, + ) -> Result { + let (phc_reader, phc_clock_error_bound_reader) = Self::autoconfigure_phc_readers().await?; + let mut phc_interval = interval(PHC_SOURCE_INTERVAL_DURATION); + phc_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + Ok(Phc { + event_sender, + ctrl_receiver, + clock_disruption_receiver, + interval: phc_interval, + reader: phc_reader, + clock_error_bound_reader: phc_clock_error_bound_reader, + }) + } + + /// Attempts to autoconfigure readers for the PHC and PHC clock error bound + /// by navigating and reading from the filesystem to obtain PTP device details. + /// + /// If there are no eligible PTP devices found then a `PhcError::PtpDeviceNotFound` + /// will be returned in the Result. + #[expect( + clippy::too_many_lines, + reason = "This function is already refactored to call + separate functions for specific functionality. The big for loop is needed + because we have many `continue` statements in the loop body, and other alternate + approaches would make the code harder to follow than the current implementation." + )] + pub async fn autoconfigure_phc_readers() + -> Result<(PhcReader, PhcClockErrorBoundReader), PhcError> { + // Get the list of network interfaces. + let network_interfaces = match get_network_interfaces().await { + Ok(network_interfaces) => network_interfaces, + Err(e) => { + warn!( + error = ?e, + "PHC reader autoconfiguration failed due to inability to get the list of network interfaces." + ); + return Err(e); + } + }; + + // Create a vec of tuples holding the PTP device path and PHC clock error bound sysfs path. + // Each tuple entry in this vec is a valid PHC configuration. + let mut ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec: Vec<(String, String)> = + Vec::new(); + + for network_interface in network_interfaces { + debug!( + ?network_interface, + "Gathering information on network_interface", + ); + + let uevent_file_path = + match get_uevent_file_path_for_network_interface(&network_interface).await { + Ok(uevent_file_path) => { + debug!( + ?network_interface, + ?uevent_file_path, + "Network interface association with uevent file path" + ); + uevent_file_path + } + Err(e) => { + debug!(error = ?e, ?network_interface, + "uevent file not found for network interface" + ); + continue; + } + }; + + let is_ena = match is_ena_network_interface(&uevent_file_path).await { + Ok(is_ena) => { + debug!( + ?network_interface, + ?is_ena, + "Network interface driver details" + ); + is_ena + } + Err(e) => { + debug!(error = ?e, ?network_interface, + "Failed to determine if network interface driver is ena", + ); + continue; + } + }; + + if !is_ena { + // We only consider PTP devices attached to ENA network interfaces as in-scope + // for use because this is the configuration used within Amazon Web Services. + info!( + ?network_interface, + ?is_ena, + "Network interface does not use the ena driver. Skipping.", + ); + continue; + } + + let pci_slot_name = match get_pci_slot_name(&uevent_file_path).await { + Ok(pci_slot_name) => { + debug!( + ?network_interface, + ?pci_slot_name, + "Network interface association with PCI slot name", + ); + pci_slot_name + } + Err(e) => { + debug!(error = ?e, ?uevent_file_path, + "PCI slot name not found for uevent file path", + ); + continue; + } + }; + + let phc_clock_error_bound_sysfs_path = + match get_phc_clock_error_bound_sysfs_path(&pci_slot_name).await { + Ok(phc_clock_error_bound_sysfs_path) => { + debug!( + ?network_interface, + ?phc_clock_error_bound_sysfs_path, + "Network interface association with PHC clock error bound sysfs path", + ); + phc_clock_error_bound_sysfs_path + } + Err(e) => { + debug!( + error = ?e, ?pci_slot_name, + "PHC clock error bound sysfs path not found for PCI slot name", + ); + continue; + } + }; + + let ptp_uevent_file_paths = + match get_ptp_uevent_file_paths_for_pci_slot(&pci_slot_name).await { + Ok(ptp_uevent_file_paths) => { + debug!( + ?network_interface, + ?ptp_uevent_file_paths, + "Network interface association with PTP uevent file paths", + ); + ptp_uevent_file_paths + } + Err(e) => { + debug!( + error = ?e, ?pci_slot_name, + "PTP uevent file paths not found for PCI slot name", + ); + continue; + } + }; + + for ptp_uevent_file_path in ptp_uevent_file_paths { + let ptp_device_name = + match get_ptp_device_name_from_uevent_file(&ptp_uevent_file_path).await { + Ok(ptp_device_name) => { + debug!( + ?network_interface, + ?ptp_device_name, + "Network interface association with PTP device name", + ); + ptp_device_name + } + Err(e) => { + debug!( + error = ?e, ?ptp_uevent_file_path, + "Device name not found for PTP uevent file path", + ); + continue; + } + }; + + let ptp_device_path = match get_ptp_device_path(&ptp_device_name).await { + Ok(ptp_device_path) => { + debug!( + ?network_interface, + ?ptp_device_path, + "Network interface association with PTP device path", + ); + ptp_device_path + } + Err(e) => { + debug!(error = ?e, ?ptp_device_name, + "Device path not found for PTP device name", + ); + continue; + } + }; + + ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec + .push((ptp_device_path, phc_clock_error_bound_sysfs_path.clone())); + } + } + + // Sort the tuples in ascending order so that if there is more than one + // PTP device, the lower numbered device names are preferred first. e.g.: + // + // [ + // ("/dev/ptp0", "/sys/bus/pci/devices/0000:27:00.0/phc_error_bound"), + // ("/dev/ptp1", "/sys/bus/pci/devices/0000:28:00.0/phc_error_bound"), + // ] + ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec.sort(); + debug!(?ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec); + + // There is at least one PTP device available to use. + // Use the first PTP device in the vec. + if let Some((ptp_device_path, phc_clock_error_bound_sysfs_path)) = + ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec.first() + { + info!( + ?ptp_device_path, + ?phc_clock_error_bound_sysfs_path, + "Configuring PHC readers" + ); + + let ptp_device_file = match File::open(ptp_device_path.clone()) { + Ok(file) => file, + Err(e) => { + let error_detail = format!( + "Failed to open PTP device file: {:?} {:?}", + &ptp_device_path, e + ); + return Err(PhcError::Io(io::Error::new(e.kind(), error_detail))); + } + }; + + let phc_reader: PhcReader = PhcReader::new(ptp_device_file); + let phc_clock_error_bound_reader: PhcClockErrorBoundReader = + PhcClockErrorBoundReader::new(phc_clock_error_bound_sysfs_path.clone()); + + info!( + ?ptp_device_path, + ?phc_clock_error_bound_sysfs_path, + "Done configuring PHC readers" + ); + Ok((phc_reader, phc_clock_error_bound_reader)) + } else { + Err(PhcError::PtpDeviceNotFound( + "No eligible PTP devices found".to_string(), + )) + } + } + + /// Creates a `event::Phc` by reading from the PHC `NUM_SAMPLES_PER_POLL` + /// times and retaining the best sample. + async fn sample(&mut self) -> Result { + let mut last_phc_error: Option = None; + let mut best_sample: Option = None; + let mut best_sample_i: u32 = 0; + + debug!("Reading {:?} PHC samples ...", NUM_SAMPLES_PER_POLL); + + for i in 0..NUM_SAMPLES_PER_POLL { + let sample = match self.read_phc_sample().await { + Ok(sample) => { + debug!(?i, ?sample, "Successfully read PHC sample"); + sample + } + Err(e) => { + warn!(?i, error = ?e, "PHC sample read failure"); + last_phc_error = Some(e); + continue; + } + }; + + // Retain the best sample. + // The best sample is defined as the sample with the smallest + // time stamp counter value difference. + if let Some(bs) = best_sample { + if sample.counter_diff < bs.counter_diff { + best_sample = Some(sample); + best_sample_i = i; + } + } else { + best_sample = Some(sample); + best_sample_i = i; + } + } + + if let Some(best_sample) = best_sample { + debug!(?best_sample_i, ?best_sample); + + // Convert from PhcSample to event::Phc. + match event::Phc::try_from(best_sample) { + Ok(phc) => Ok(phc), + Err(e) => Err(PhcError::PhcSampleToPhcEventConversionError(e)), + } + } else if let Some(last_phc_error) = last_phc_error { + Err(last_phc_error) + } else { + // Should never get here. We either should have a best sample + // found, or there was some error encountered that was recorded. + unreachable!("PHC IO source: unexpected unreachable"); + } + } + + /// Reads a single PHC sample which consists of: + /// - A reference clock timestamp + /// - The corresponding clock error bound for that reference clock timestamp. + /// - Timestamp stamp counter value prior to collecing the reference clock timestamp. + /// - Timestamp stamp counter value after collecing the reference clock timestamp. + /// + /// If the `PhcReader` and `PhcClockErrorBoundReader` are not initialized, then this + async fn read_phc_sample(&self) -> Result { + let (ptp_clock_time, counter_pre, counter_post) = self + .reader + .ptp_sys_offset_extended2_with_counter_pre_and_counter_post(PHC_SOURCE_TIMEOUT) + .await?; + + let ptp_clock_error_bound_nsec: i64 = match self.clock_error_bound_reader.read().await { + Ok(ceb_nsec) => { + if ceb_nsec < 0 { + return Err(PhcError::PhcClockErrorBoundReadFailure(format!( + "Negative PHC clock error bound: {ceb_nsec}" + ))); + } + ceb_nsec + } + Err(e) => { + return Err(PhcError::PhcClockErrorBoundReadFailure(e.clone())); + } + }; + + Ok(PhcSample { + counter_pre, + counter_post, + counter_diff: counter_post - counter_pre, + ptp_clock_time, + ptp_clock_error_bound_nsec, + }) + } + + /// `PHC` task runner. + /// + /// Performs periodic sampling of the PHC IO source until a control event is received. + /// + /// # Panics + /// Function will panic if not called within the `tokio` runtime. + /// + pub async fn run(&mut self) { + // Sampling loop + info!("Starting PHC sampling loop"); + loop { + tokio::select! { + biased; // priority order is disruption, commands, and ticks + _ = self.clock_disruption_receiver.changed() => { + // Clock Disruption logic here + Self::handle_disruption(); + } + _ = self.ctrl_receiver.recv() => { + // Ctrl logic here. + // Currently we breakout of the loop if we receive a control event. + break; + } + _ = self.interval.tick() => { + self.handle_interval_tick().await; + } + } + } + info!("PHC runner exiting"); + } + + async fn handle_interval_tick(&mut self) { + let phc_event = match self.sample().await { + Ok(phc_event) => phc_event, + Err(e) => { + warn!(error = ?e, "Failed to sample PHC source"); + return; + } + }; + + match self.event_sender.send(phc_event.clone()) { + Ok(()) => debug!(?phc_event, "Successfully sent PHC IO event"), + Err(SendError::Disrupted(_)) => { + info!("Trying to send when there was a disruption event."); + } + Err(SendError::BufferClosed(e)) => { + error!(?e, "PHC channel closed not supported in alpha"); + panic!("PHC IO source unable to communicate with daemon. {e:?}"); + } + } + } + + /// Handles a clock disruption event + fn handle_disruption() { + // No special logic to handle disruption. + debug!("PHC IO source handle_disruption()"); + } +} + +#[cfg_attr(test, mockall::automock)] +mod filesystem { + /// Tests to see if the file exists. + pub(crate) async fn fs_try_exists(path: &String) -> tokio::io::Result { + tokio::fs::try_exists(path).await + } +} + +#[cfg(not(test))] +pub(crate) use filesystem::fs_try_exists; +#[cfg(test)] +pub(crate) use mock_filesystem::fs_try_exists; + +#[cfg_attr(test, mockall::automock)] +mod phc_path_locator { + use crate::daemon::io::phc::PhcError; + use crate::daemon::io::phc::fs_try_exists; + + /// Gets a list of network interface names on the host by inspecting + /// the files under the path "/sys/class/net/". + pub(crate) async fn get_network_interfaces() -> Result, PhcError> { + let mut network_interfaces = Vec::new(); + let network_interfaces_path = "/sys/class/net/"; + + // Validate the file path containing entries of the network interfaces exists. + if !fs_try_exists(&network_interfaces_path.to_string()).await? { + return Err(PhcError::FileNotFound(network_interfaces_path.into())); + } + + let mut entries = match tokio::fs::read_dir(network_interfaces_path).await { + Ok(entries) => entries, + Err(e) => return Err(PhcError::Io(e)), + }; + + while let Some(entry) = match entries.next_entry().await { + Ok(entry) => entry, + Err(e) => return Err(PhcError::Io(e)), + } { + let file_name = entry.file_name().to_string_lossy().to_string(); + network_interfaces.push(file_name); + } + + tracing::debug!(?network_interfaces); + Ok(network_interfaces) + } + + /// Gets the uevent file path for a particular network interface. + pub(crate) async fn get_uevent_file_path_for_network_interface( + network_interface: &str, + ) -> Result { + let uevent_file_path = format!("/sys/class/net/{network_interface}/device/uevent"); + if !fs_try_exists(&uevent_file_path).await? { + return Err(PhcError::FileNotFound(uevent_file_path)); + } + + Ok(uevent_file_path) + } + + /// Inspects the given uevent file for a network interface and determines if + /// the corresponding driver is "ena", which is the Amazon elastic network adapter. + pub(crate) async fn is_ena_network_interface(uevent_file_path: &str) -> Result { + let contents = match tokio::fs::read_to_string(uevent_file_path).await { + Ok(contents) => contents, + Err(e) => return Err(PhcError::Io(e)), + }; + + let driver_name = contents + .lines() + .find_map(|line| line.strip_prefix("DRIVER=")) + .ok_or_else(|| { + PhcError::DeviceDriverNameNotFound(format!( + "Failed to find DRIVER at uevent file path {uevent_file_path}" + )) + }) + .map(std::string::ToString::to_string)?; + + tracing::debug!( + ?uevent_file_path, + ?driver_name, + "uevent file association with DRIVER value" + ); + Ok(driver_name == "ena") + } + + /// Gets the PCI slot name for a given network interface name. + /// + /// # Arguments + /// + /// * `uevent_file_path` - The path of the uevent file where we lookup the `PCI_SLOT_NAME`. + pub(crate) async fn get_pci_slot_name(uevent_file_path: &str) -> Result { + let contents = match tokio::fs::read_to_string(uevent_file_path).await { + Ok(contents) => contents, + Err(e) => return Err(PhcError::Io(e)), + }; + + let pci_slot_name = contents + .lines() + .find_map(|line| line.strip_prefix("PCI_SLOT_NAME=")) + .ok_or_else(|| { + PhcError::PciSlotNameNotFound(format!( + "Failed to find PCI_SLOT_NAME at uevent file path {uevent_file_path}" + )) + }) + .map(std::string::ToString::to_string)?; + + tracing::debug!( + ?uevent_file_path, + ?pci_slot_name, + "uevent file association with PCI_SLOT_NAME value" + ); + Ok(pci_slot_name) + } + + /// Gets the absolute file paths of the uevent files for PTP devices, + /// given the PCI slot name that corresponds to the ENA network interface. + /// + /// File paths are expected to look like: + /// `/sys/bus/pci/devices/{pci_slot_name}/ptp/ptp0/uevent`, + /// `/sys/bus/pci/devices/{pci_slot_name}/ptp/ptp1/uevent`, + /// `/sys/bus/pci/devices/{pci_slot_name}/ptp/ptp2/uevent`, + /// etc. + pub(crate) async fn get_ptp_uevent_file_paths_for_pci_slot( + pci_slot_name: &str, + ) -> Result, PhcError> { + let mut uevent_file_paths = Vec::new(); + let uevent_file_search_path = format!("/sys/bus/pci/devices/{pci_slot_name}/ptp/"); + + if !fs_try_exists(&uevent_file_search_path).await? { + return Err(PhcError::FileNotFound(uevent_file_search_path)); + } + + let mut entries = match tokio::fs::read_dir(&uevent_file_search_path).await { + Ok(entries) => entries, + Err(e) => return Err(PhcError::Io(e)), + }; + + while let Some(entry) = match entries.next_entry().await { + Ok(entry) => entry, + Err(e) => return Err(PhcError::Io(e)), + } { + let entry_name = entry.file_name().to_string_lossy().to_string(); + if entry_name.starts_with("ptp") { + let uevent_path = format!("{uevent_file_search_path}{entry_name}/uevent"); + if fs_try_exists(&uevent_path).await? { + uevent_file_paths.push(uevent_path); + } + } + } + + tracing::debug!(?uevent_file_paths, "PTP uevent file paths"); + Ok(uevent_file_paths) + } + + /// Gets the PTP device name from the given `uevent_file_path`. + /// + /// # Arguments + /// + /// * `uevent_file_path` - The path of the uevent file where we lookup DEVNAME. + pub(crate) async fn get_ptp_device_name_from_uevent_file( + uevent_file_path: &str, + ) -> Result { + let contents = match tokio::fs::read_to_string(uevent_file_path).await { + Ok(contents) => contents, + Err(e) => return Err(PhcError::Io(e)), + }; + + let ptp_device_name = contents + .lines() + .find_map(|line| line.strip_prefix("DEVNAME=")) + .ok_or_else(|| { + PhcError::PtpDeviceNameNotFound(format!( + "Failed to find DEVNAME at uevent file path {uevent_file_path}" + )) + }) + .map(std::string::ToString::to_string)?; + + tracing::debug!( + ?uevent_file_path, + ?ptp_device_name, + "uevent file assocation with DEVNAME value" + ); + Ok(ptp_device_name) + } + + /// Gets the PTP device path for a particular PTP device name. + /// + /// # Arguments + /// + /// * `ptp_device_name` - The network interface to lookup the PHC error bound path for. + pub(crate) async fn get_ptp_device_path(ptp_device_name: &str) -> Result { + let ptp_device_path = format!("/dev/{ptp_device_name}"); + if !fs_try_exists(&ptp_device_path).await? { + return Err(PhcError::PtpDeviceNotFound(format!( + "Failed to find PTP device at path {ptp_device_path}" + ))); + } + tracing::debug!( + ?ptp_device_name, + ?ptp_device_path, + "PTP device name association with PTP device path" + ); + Ok(ptp_device_path) + } + + /// Gets the PHC Error Bound sysfs file path given a PCI slot name. + /// + /// # Arguments + /// + /// * `pci_slot_name` - The PCI slot name to use for constructing and locating the PHC clock error bound sysfs file. + pub(crate) async fn get_phc_clock_error_bound_sysfs_path( + pci_slot_name: &str, + ) -> Result { + let phc_clock_error_bound_sysfs_path = + format!("/sys/bus/pci/devices/{pci_slot_name}/phc_error_bound"); + if !fs_try_exists(&phc_clock_error_bound_sysfs_path).await? { + return Err(PhcError::PhcClockErrorBoundFileNotFound(format!( + "Failed to find PHC clock error bound sysfs file for PCI slot name {pci_slot_name}" + ))); + } + tracing::debug!( + ?pci_slot_name, + ?phc_clock_error_bound_sysfs_path, + "PCI slot name assocation with PHC clock error bound sysfs path" + ); + Ok(phc_clock_error_bound_sysfs_path) + } +} + +pub(crate) use phc_path_locator::{ + get_network_interfaces, get_pci_slot_name, get_phc_clock_error_bound_sysfs_path, + get_ptp_device_name_from_uevent_file, get_ptp_device_path, + get_ptp_uevent_file_paths_for_pci_slot, get_uevent_file_path_for_network_interface, + is_ena_network_interface, +}; + +#[derive(Debug)] +pub struct PhcReader { + /// PHC device file + phc_device_file: File, +} + +#[cfg_attr(test, mockall::automock)] +impl PhcReader { + pub(crate) fn new(phc_device_file: File) -> Self { + Self { phc_device_file } + } + + pub(crate) async fn ptp_sys_offset_extended2_with_counter_pre_and_counter_post( + &self, + timeout_duration: tokio::time::Duration, + ) -> Result<(PtpClockTime, u64, u64), PhcError> { + let mut ptp_sys_offset_extended = PtpSysOffsetExtended { + n_samples: 1, + ..Default::default() + }; + let phc_device_fd = self.phc_device_file.as_raw_fd(); + + let result = timeout( + timeout_duration, + task::spawn_blocking(move || unsafe { + let counter_pre = read_timestamp_counter_begin(); + let ptp_clock_time = + match ptp_sys_offset_extended2(phc_device_fd, &raw mut ptp_sys_offset_extended) + { + Ok(_) => ptp_sys_offset_extended.ts[0][1], + Err(e) => { + return Err(e); + } + }; + let counter_post = read_timestamp_counter_end(); + Ok((ptp_clock_time, counter_pre, counter_post)) + }), + ) + .await?; + + result?.map_err(PhcError::from) + } +} + +#[derive(Debug, Clone, Default)] +pub struct PhcClockErrorBoundReader { + sysfs_phc_error_bound_path: String, +} + +#[cfg_attr(test, mockall::automock)] +impl PhcClockErrorBoundReader { + pub(crate) fn new(phc_clock_error_bound_path: String) -> Self { + Self { + sysfs_phc_error_bound_path: phc_clock_error_bound_path, + } + } + + pub(crate) async fn read(&self) -> Result { + let contents = tokio::fs::read_to_string(&self.sysfs_phc_error_bound_path) + .await + .map_err(|e| e.to_string())?; + contents + .trim() + .parse::() + .map_err(|e| format!("Failed to parse PHC error bound value to i64: {e}")) + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + use tempfile::NamedTempFile; + + use super::*; + use crate::daemon::event; + + use std::io::Write; + + #[tokio::test] + #[rstest] + #[case::happy_path("PCI_SLOT_NAME=12345", "12345")] + #[case::happy_path_multi_line( + " +oneline +PCI_SLOT_NAME=23456 +twoline", + "23456" + )] + async fn test_get_pci_slot_name_success( + #[case] file_contents_to_write: &str, + #[case] return_value: &str, + ) { + let mut test_uevent_file = NamedTempFile::new().expect("create mock uevent file failed"); + test_uevent_file + .write_all(file_contents_to_write.as_bytes()) + .expect("write to mock uevent file failed"); + + let rt = + phc_path_locator::get_pci_slot_name(test_uevent_file.path().to_str().unwrap()).await; + assert!(rt.is_ok()); + assert_eq!(rt.unwrap(), return_value.to_string()); + } + + #[tokio::test] + #[rstest] + #[case::missing_pci_slot_name("no pci slot name")] + async fn test_get_pci_slot_name_failure(#[case] file_contents_to_write: &str) { + let mut test_uevent_file = NamedTempFile::new().expect("create mock uevent file failed"); + test_uevent_file + .write_all(file_contents_to_write.as_bytes()) + .expect("write to mock uevent file failed"); + + let rt = + phc_path_locator::get_pci_slot_name(test_uevent_file.path().to_str().unwrap()).await; + assert!(rt.is_err()); + match rt.unwrap_err() { + PhcError::PciSlotNameNotFound(_) => assert!(true), + _ => assert!(false), + }; + } + + #[tokio::test] + async fn test_get_pci_slot_name_file_does_not_exist() { + let rt = phc_path_locator::get_pci_slot_name("/does/not/exist").await; + assert!(rt.is_err()); + } + + #[tokio::test] + #[rstest] + #[case::happy_path("12345", 12345)] + async fn test_read_phc_error_bound_success( + #[case] file_contents_to_write: &str, + #[case] return_value: i64, + ) { + let mut test_phc_error_bound_file = + NamedTempFile::new().expect("create mock phc error bound file failed"); + test_phc_error_bound_file + .write_all(file_contents_to_write.as_bytes()) + .expect("write to mock phc error bound file failed"); + + let phc_error_bound_reader = PhcClockErrorBoundReader::new( + test_phc_error_bound_file + .path() + .to_string_lossy() + .to_string(), + ); + let rt = phc_error_bound_reader.read().await; + assert!(rt.is_ok()); + assert_eq!(rt.unwrap(), return_value); + } + + #[tokio::test] + #[rstest] + #[case::parsing_fail("asdf_not_an_i64")] + async fn test_read_phc_error_bound_bad_file_contents(#[case] file_contents_to_write: &str) { + let mut test_phc_error_bound_file = + NamedTempFile::new().expect("create mock phc error bound file failed"); + test_phc_error_bound_file + .write_all(file_contents_to_write.as_bytes()) + .expect("write to mock phc error bound file failed"); + + let phc_error_bound_reader = PhcClockErrorBoundReader::new( + test_phc_error_bound_file + .path() + .to_string_lossy() + .to_string(), + ); + let rt = phc_error_bound_reader.read().await; + assert!(rt.is_err()); + assert!( + rt.unwrap_err() + .to_string() + .contains("Failed to parse PHC error bound value to i64") + ); + } + + #[tokio::test] + async fn test_read_phc_error_bound_file_does_not_exist() { + let phc_error_bound_reader = PhcClockErrorBoundReader::new("/does/not/exist".into()); + let rt = phc_error_bound_reader.read().await; + assert!(rt.is_err()); + } + + #[test] + fn test_phc_clock_error_bound_reader_new() { + let path = String::from("/test/path"); + let reader = PhcClockErrorBoundReader::new(path.clone()); + assert_eq!(reader.sysfs_phc_error_bound_path, path); + } + + #[test] + fn test_phc_sample_try_from_success() { + let sample = PhcSample { + counter_pre: 100, + counter_post: 200, + counter_diff: 100, + ptp_clock_time: PtpClockTime { + sec: 1234567890, + nsec: 123456789, + reserved: 0, + }, + ptp_clock_error_bound_nsec: 1000, + }; + + let result = event::Phc::try_from(sample); + assert!(result.is_ok()); + } + + #[test] + fn test_phc_sample_try_from_invalid_counter_diff() { + let sample = PhcSample { + counter_pre: 200, + counter_post: 100, + counter_diff: 0, + ptp_clock_time: PtpClockTime { + sec: 1234567890, + nsec: 123456789, + reserved: 0, + }, + ptp_clock_error_bound_nsec: 1000, + }; + + let result = event::Phc::try_from(sample); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TryFromPhcSampleError::InvalidCounterDiff + )); + } + + #[test] + fn test_phc_sample_try_from_invalid_clock_error_bound() { + let sample = PhcSample { + counter_pre: 100, + counter_post: 200, + counter_diff: 100, + ptp_clock_time: PtpClockTime { + sec: 1234567890, + nsec: 123456789, + reserved: 0, + }, + ptp_clock_error_bound_nsec: -1000, + }; + + let result = event::Phc::try_from(sample); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + TryFromPhcSampleError::InvalidClockErrorBound + )); + } + + #[tokio::test] + async fn test_get_network_interfaces_directory_not_found() { + let ctx = mock_filesystem::fs_try_exists_context(); + ctx.expect().returning(|_| Ok(false)); + + let result = phc_path_locator::get_network_interfaces().await; + assert!(result.is_err()); + match result.unwrap_err() { + PhcError::FileNotFound(path) => { + assert_eq!(path, "/sys/class/net/"); + } + _ => panic!("Expected FileNotFound error"), + } + } + + #[tokio::test] + async fn test_get_uevent_file_path_for_network_interface_success() { + let ctx = mock_filesystem::fs_try_exists_context(); + ctx.expect().returning(|_| Ok(true)); + + let result = phc_path_locator::get_uevent_file_path_for_network_interface("eth0").await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "/sys/class/net/eth0/device/uevent"); + } + + #[tokio::test] + #[rstest] + #[case::ena_driver("DRIVER=ena", true)] + #[case::other_driver("DRIVER=e1000e", false)] + #[case::multiline_ena("SUBSYSTEM=net\nDRIVER=ena\nOTHER=value", true)] + async fn test_is_ena_network_interface_success( + #[case] file_contents: &str, + #[case] expected: bool, + ) { + let mut test_file = NamedTempFile::new().expect("create temp file failed"); + test_file + .write_all(file_contents.as_bytes()) + .expect("write to temp file failed"); + + let result = + phc_path_locator::is_ena_network_interface(test_file.path().to_str().unwrap()).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), expected); + } + + #[tokio::test] + async fn test_is_ena_network_interface_no_driver() { + let mut test_file = NamedTempFile::new().expect("create temp file failed"); + test_file + .write_all(b"SUBSYSTEM=net\nOTHER=value") + .expect("write to temp file failed"); + + let result = + phc_path_locator::is_ena_network_interface(test_file.path().to_str().unwrap()).await; + assert!(result.is_err()); + match result.unwrap_err() { + PhcError::DeviceDriverNameNotFound(_) => {} + _ => panic!("Expected DeviceDriverNameNotFound error"), + } + } + + #[tokio::test] + async fn test_get_ptp_uevent_file_paths_for_pci_slot_directory_not_found() { + let ctx = mock_filesystem::fs_try_exists_context(); + ctx.expect().returning(|_| Ok(false)); + + let result = phc_path_locator::get_ptp_uevent_file_paths_for_pci_slot("test_slot").await; + assert!(result.is_err()); + match result.unwrap_err() { + PhcError::FileNotFound(path) => { + assert_eq!(path, "/sys/bus/pci/devices/test_slot/ptp/"); + } + _ => panic!("Expected FileNotFound error"), + } + } + + #[tokio::test] + #[rstest] + #[case::simple_devname("DEVNAME=ptp0", "ptp0")] + #[case::multiline_devname("SUBSYSTEM=ptp\nDEVNAME=ptp1\nOTHER=value", "ptp1")] + async fn test_get_ptp_device_name_from_uevent_file_success( + #[case] file_contents: &str, + #[case] expected: &str, + ) { + let mut test_file = NamedTempFile::new().expect("create temp file failed"); + test_file + .write_all(file_contents.as_bytes()) + .expect("write to temp file failed"); + + let result = phc_path_locator::get_ptp_device_name_from_uevent_file( + test_file.path().to_str().unwrap(), + ) + .await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), expected); + } + + #[tokio::test] + async fn test_get_ptp_device_name_from_uevent_file_no_devname() { + let mut test_file = NamedTempFile::new().expect("create temp file failed"); + test_file + .write_all(b"SUBSYSTEM=ptp\nOTHER=value") + .expect("write to temp file failed"); + + let result = phc_path_locator::get_ptp_device_name_from_uevent_file( + test_file.path().to_str().unwrap(), + ) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + PhcError::PtpDeviceNameNotFound(_) => {} + _ => panic!("Expected PtpDeviceNameNotFound error"), + } + } + + #[tokio::test] + async fn test_get_phc_clock_error_bound_sysfs_path_not_found() { + let ctx = mock_filesystem::fs_try_exists_context(); + ctx.expect().returning(|_| Ok(false)); + + let result = phc_path_locator::get_phc_clock_error_bound_sysfs_path("0000:27:00.0").await; + assert!(result.is_err()); + match result.unwrap_err() { + PhcError::PhcClockErrorBoundFileNotFound(msg) => { + assert!(msg.contains("0000:27:00.0")); + } + _ => panic!("Expected PhcClockErrorBoundFileNotFound error"), + } + } +} From a3fc2e9349e03737e3bf1cf02b97632fac089ecf Mon Sep 17 00:00:00 2001 From: Ryan Luu Date: Fri, 14 Nov 2025 11:52:41 -0500 Subject: [PATCH 116/177] [phc 2/2] Integrate PHC IO source implementation. Add phc-test program. (#129) --- Cargo.lock | 12 +++++++ Cargo.toml | 2 ++ clock-bound/src/daemon/io.rs | 53 ++++++++++++++++++++++++++-- test/phc/Cargo.toml | 27 ++++++++++++++ test/phc/Makefile.toml | 8 +++++ test/phc/README.md | 68 ++++++++++++++++++++++++++++++++++++ test/phc/src/main.rs | 68 ++++++++++++++++++++++++++++++++++++ 7 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 test/phc/Cargo.toml create mode 100644 test/phc/Makefile.toml create mode 100644 test/phc/README.md create mode 100644 test/phc/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 0767475..ea374c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,6 +1257,18 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "2.0.3" +dependencies = [ + "clock-bound", + "rand 0.9.2", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + [[package]] name = "pin-project-lite" version = "0.2.16" diff --git a/Cargo.toml b/Cargo.toml index 0da15f4..4701ea3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "test/clock-bound-vmclock-client-test", "test/link-local", "test/ntp-source", + "test/phc", "test/vmclock-updater", "test/clock-bound-adjust-clock", "test/clock-bound-adjust-clock-test", @@ -25,6 +26,7 @@ authors = [ "Mohammed Kabir ", "Myles Neloms ", "Nick Matthews ", + "Jennifer Solidum ", ] categories = [ "date-and-time" ] edition = "2024" diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 61bca81..ae35dae 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -27,6 +27,7 @@ mod ntp_source; use ntp_source::NTPSource; mod phc; +use phc::Phc; pub mod tsc; @@ -42,6 +43,8 @@ pub struct SourceIO { link_local: Option>, /// Mapping between the socket ip-address and the ntp io source ntp_sources: HashMap>, + /// The PHC source. + phc: Option>, /// The VMClock source vmclock: Option>, /// Contains the channel used to communicate clock disruption events. @@ -60,6 +63,7 @@ impl SourceIO { SourceIO { link_local: None, ntp_sources: HashMap::new(), + phc: None, vmclock: None, clock_disruption_channels: ClockDisruptionChannels { sender, receiver }, selected_clock, @@ -99,7 +103,7 @@ impl SourceIO { }; } - info!("Source update complete."); + info!("Source link local update complete."); } /// Spawns the IO task for sampling a specific NTP Server source. @@ -139,7 +143,35 @@ impl SourceIO { self.ntp_sources.insert(server_address, source); } - info!("Source update complete."); + info!("Source NTP update complete."); + } + + /// Spawns the IO task for sampling the PHC source. + /// + /// # Panics + /// - If not called within the `tokio` runtime. + pub async fn create_phc(&mut self, event_sender: async_ring_buffer::Sender) { + info!("Creating PHC source."); + + debug!(?self.phc, "Current PHC source entry status"); + if self.phc.is_none() { + self.phc = { + let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); + let clock_disruption_receiver = self.clock_disruption_channels.sender.subscribe(); + match Phc::construct(event_sender, ctrl_receiver, clock_disruption_receiver).await { + Ok(phc) => Some(Source { + state: SourceState::Initialized(phc), + ctrl_sender, + }), + Err(e) => { + warn!("{}", e); + None + } + } + }; + } + + info!("Source PHC update complete."); } /// Spawns the IO task for sampling the VMClock shared memory file. @@ -209,6 +241,23 @@ impl SourceIO { debug!("Could not spawn a link local source. No source data provided."); } + // Spawn PHC source + if let Some(Source { + state, + ctrl_sender: _, + }) = &mut self.phc + { + debug!("Attempting to spawn PHC source."); + if let SourceState::Initialized(mut phc) = state.transition_to_running() { + spawn(async move { phc.run().await }); + debug!("Successfully spawned PHC source."); + } else { + warn!("Attempted to spawn a PHC source when one is currently running."); + } + } else { + debug!("Could not spawn a PHC source. No source data provided."); + } + // Spawn vmclock source if let Some(Source { state, diff --git a/test/phc/Cargo.toml b/test/phc/Cargo.toml new file mode 100644 index 0000000..6b32abd --- /dev/null +++ b/test/phc/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "phc" +description = "A test program that attempts to obtain samples from the PHC." +license = "Apache-2.0" +publish = false + +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +keywords.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "phc-test" +path = "src/main.rs" + +[dependencies] +clock-bound = { version = "2.0", path = "../../clock-bound", features = [ + "daemon", +] } +rand = "0.9.2" +tokio = { version = "1.47.1", features = ["macros", "rt"] } +tracing = "0.1" +tracing-appender = { version = "0.2" } +tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/phc/Makefile.toml b/test/phc/Makefile.toml new file mode 100644 index 0000000..1aa5b00 --- /dev/null +++ b/test/phc/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in test/phc" +''' diff --git a/test/phc/README.md b/test/phc/README.md new file mode 100644 index 0000000..cc7baa9 --- /dev/null +++ b/test/phc/README.md @@ -0,0 +1,68 @@ +# Test program: phc-test + +This directory contains the source code for a test program written to +validate the implementation of the PHC runner. + +Upon startup, the PHC runner attempts to locate a PTP hardware clock (PHC) +on the host. If a PTP hardware clock is found, then the PHC runner +will periodically read the time and clock error bound from it. + +## Prerequisites + +AWS EC2 instance is required. + +On non-Amazon Linux distributions, the ENA Linux driver will need to be installed and configured with support for the PHC enabled: +- https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configure-ec2-ntp.html#connect-to-the-ptp-hardware-clock +- https://github.com/amzn/amzn-drivers/tree/master/kernel/linux/ena + + +The PTP hardware clock (PHC) device must have read permissions for the user that is running the phc-test program. + +```sh +sudo chmod 644 /dev/ptp0 +``` + + +## Building with Cargo + +Run the following command to build the test program. + +```sh +cargo build --release +``` + +## Running the program after a Cargo build + +Run the following commands to run the test program. + +```sh +cd target/release/ +./phc-test +``` + + +The output should look something like the following: + +```sh +[ec2-user@ip-172-31-31-154 ~]$ ./phc-test +Lets get a PHC sample! +It looks like we got an PHC sample +Ok( + Phc { + tsc_pre: TscCount( + 834121827837145, + ), + tsc_post: TscCount( + 834121827896745, + ), + data: PhcData { + time: Instant( + 1762897830.385_289_585, + ), + clock_error_bound: Duration( + 0.000_016_998, + ), + }, + }, +) +``` diff --git a/test/phc/src/main.rs b/test/phc/src/main.rs new file mode 100644 index 0000000..56a8010 --- /dev/null +++ b/test/phc/src/main.rs @@ -0,0 +1,68 @@ +//! PHC test executable. +//! +//! This executable tests that the PHC runner is able to read timestamps and error bounds, +//! and that the polling rate is roughly once a second. + +use clock_bound::daemon::io::SourceIO; +use clock_bound::daemon::selected_clock::SelectedClockSource; +use clock_bound::daemon::{async_ring_buffer, io::ntp::DaemonInfo}; +use std::sync::Arc; +use std::time; + +use rand::{RngCore, rng}; +use tokio::time::{Duration, timeout}; +use tracing_subscriber::EnvFilter; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + println!("Lets get a PHC sample!"); + let (phc_sender, phc_receiver) = async_ring_buffer::create(1); + + let mut start = time::Instant::now(); + + let daemon_info = DaemonInfo { + major_version: 2, + minor_version: 100, + startup_id: rng().next_u64(), + }; + + let mut sourceio = SourceIO::construct(Arc::new(SelectedClockSource::default()), daemon_info); + sourceio.create_phc(phc_sender).await; + sourceio.spawn_all(); + + let max_events_to_receive = 11; + + let mut total_polling_duration = time::Duration::from_secs(0); + let mut num_events_received = 0; + + for i in 0..max_events_to_receive { + // PHC events are expected to be received periodically. + // Use a timeout() to prevent the runner from running forever if there happens + // to be an issue with the PHC or there is a bug that prevents events from + // being sent to us. + let phc_event = timeout(Duration::from_secs(5), phc_receiver.recv()) + .await + .unwrap(); + let now = time::Instant::now(); + let duration = now - start; + println!( + "It looks like we got an PHC sample \n{phc_event:#?}\n{:?} msec", + duration.as_millis() + ); + + // Skip the first sample, the IO runner will poll immediately after it's created. + if i > 0 { + total_polling_duration += duration; + num_events_received += 1; + } + + start = now; + } + let polling_rate = total_polling_duration / num_events_received; + println!("Polling rate avg: {polling_rate:?}"); + assert!(polling_rate.abs_diff(time::Duration::from_secs(1)) < time::Duration::from_millis(100)); +} From f893bf57f99c1ee070c2f1a9b35459ff4ee75aa8 Mon Sep 17 00:00:00 2001 From: Ryan Luu Date: Fri, 14 Nov 2025 11:56:11 -0500 Subject: [PATCH 117/177] [phc] Improve code style and apply Rust best practices in PHC IO source (#141) Addresses some follow-up actions items mentioned in pull request #128: - Use `unsafe` only around the unsafe code. Add SAFETY code comment. - https://github.com/aws/private-clock-bound-staging/pull/128#discussion_r2524731412 - Use `PathBuf` for paths instead of a `String`. - https://github.com/aws/private-clock-bound-staging/pull/128#discussion_r2524734448 - Use error variant templating for the variable that is relevant for the error. - https://github.com/aws/private-clock-bound-staging/pull/128#discussion_r2524749331 --- clock-bound/src/daemon/io/phc.rs | 65 ++++++++++++++++---------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/clock-bound/src/daemon/io/phc.rs b/clock-bound/src/daemon/io/phc.rs index 97054cf..17017e5 100644 --- a/clock-bound/src/daemon/io/phc.rs +++ b/clock-bound/src/daemon/io/phc.rs @@ -12,6 +12,7 @@ use libc::c_uint; use nix::ioctl_readwrite; use std::fs::File; use std::os::unix::io::AsRawFd; +use std::path::PathBuf; use thiserror::Error; use tokio::{ io, @@ -79,8 +80,8 @@ pub enum PhcError { PtpDeviceNameNotFound(String), #[error("PCI_SLOT_NAME not found in uevent file")] PciSlotNameNotFound(String), - #[error("PHC clock error bound file not found")] - PhcClockErrorBoundFileNotFound(String), + #[error("PHC clock error bound file not found for PCI slot name {pci_slot_name}")] + PhcClockErrorBoundFileNotFound { pci_slot_name: String }, #[error("PHC clock error bound read failure")] PhcClockErrorBoundReadFailure(String), #[error("Tokio spawn JoinError")] @@ -411,7 +412,7 @@ impl Phc { let phc_reader: PhcReader = PhcReader::new(ptp_device_file); let phc_clock_error_bound_reader: PhcClockErrorBoundReader = - PhcClockErrorBoundReader::new(phc_clock_error_bound_sysfs_path.clone()); + PhcClockErrorBoundReader::new(PathBuf::from(phc_clock_error_bound_sysfs_path)); info!( ?ptp_device_path, @@ -788,9 +789,9 @@ mod phc_path_locator { let phc_clock_error_bound_sysfs_path = format!("/sys/bus/pci/devices/{pci_slot_name}/phc_error_bound"); if !fs_try_exists(&phc_clock_error_bound_sysfs_path).await? { - return Err(PhcError::PhcClockErrorBoundFileNotFound(format!( - "Failed to find PHC clock error bound sysfs file for PCI slot name {pci_slot_name}" - ))); + return Err(PhcError::PhcClockErrorBoundFileNotFound { + pci_slot_name: pci_slot_name.into(), + }); } tracing::debug!( ?pci_slot_name, @@ -832,16 +833,24 @@ impl PhcReader { let result = timeout( timeout_duration, - task::spawn_blocking(move || unsafe { + task::spawn_blocking(move || { let counter_pre = read_timestamp_counter_begin(); - let ptp_clock_time = - match ptp_sys_offset_extended2(phc_device_fd, &raw mut ptp_sys_offset_extended) - { - Ok(_) => ptp_sys_offset_extended.ts[0][1], - Err(e) => { - return Err(e); - } - }; + // SAFETY: The ptp_sys_offset_extended2() function is generated by the + // nix::ioctl_readwrite! macro and the call is safe because the arguments + // are expected to be valid. The file descriptor comes from a File + // that had File::open() successfully called on the path, ensuring + // that the file descriptor is valid. The other argument provided to + // the ptp_sys_offset_extended2() was created within this function + // just above, and its definition matches the expected struct format. + let result = unsafe { + ptp_sys_offset_extended2(phc_device_fd, &raw mut ptp_sys_offset_extended) + }; + let ptp_clock_time = match result { + Ok(_) => ptp_sys_offset_extended.ts[0][1], + Err(e) => { + return Err(e); + } + }; let counter_post = read_timestamp_counter_end(); Ok((ptp_clock_time, counter_pre, counter_post)) }), @@ -854,12 +863,12 @@ impl PhcReader { #[derive(Debug, Clone, Default)] pub struct PhcClockErrorBoundReader { - sysfs_phc_error_bound_path: String, + sysfs_phc_error_bound_path: PathBuf, } #[cfg_attr(test, mockall::automock)] impl PhcClockErrorBoundReader { - pub(crate) fn new(phc_clock_error_bound_path: String) -> Self { + pub(crate) fn new(phc_clock_error_bound_path: PathBuf) -> Self { Self { sysfs_phc_error_bound_path: phc_clock_error_bound_path, } @@ -948,12 +957,8 @@ twoline", .write_all(file_contents_to_write.as_bytes()) .expect("write to mock phc error bound file failed"); - let phc_error_bound_reader = PhcClockErrorBoundReader::new( - test_phc_error_bound_file - .path() - .to_string_lossy() - .to_string(), - ); + let phc_error_bound_reader = + PhcClockErrorBoundReader::new(test_phc_error_bound_file.path().to_path_buf()); let rt = phc_error_bound_reader.read().await; assert!(rt.is_ok()); assert_eq!(rt.unwrap(), return_value); @@ -969,12 +974,8 @@ twoline", .write_all(file_contents_to_write.as_bytes()) .expect("write to mock phc error bound file failed"); - let phc_error_bound_reader = PhcClockErrorBoundReader::new( - test_phc_error_bound_file - .path() - .to_string_lossy() - .to_string(), - ); + let phc_error_bound_reader = + PhcClockErrorBoundReader::new(test_phc_error_bound_file.path().to_path_buf()); let rt = phc_error_bound_reader.read().await; assert!(rt.is_err()); assert!( @@ -993,7 +994,7 @@ twoline", #[test] fn test_phc_clock_error_bound_reader_new() { - let path = String::from("/test/path"); + let path = PathBuf::from("/test/path"); let reader = PhcClockErrorBoundReader::new(path.clone()); assert_eq!(reader.sysfs_phc_error_bound_path, path); } @@ -1183,8 +1184,8 @@ twoline", let result = phc_path_locator::get_phc_clock_error_bound_sysfs_path("0000:27:00.0").await; assert!(result.is_err()); match result.unwrap_err() { - PhcError::PhcClockErrorBoundFileNotFound(msg) => { - assert!(msg.contains("0000:27:00.0")); + PhcError::PhcClockErrorBoundFileNotFound { pci_slot_name } => { + assert_eq!(pci_slot_name, "0000:27:00.0"); } _ => panic!("Expected PhcClockErrorBoundFileNotFound error"), } From 58f4d4b472ea2aecfeadd785ad48bf1b54e44b8e Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Fri, 14 Nov 2025 13:12:52 -0500 Subject: [PATCH 118/177] Adds link local vmclock test (#136) Adds link local vmclock polling rate test and refactors the link local integration test module. --- Cargo.lock | 1 + test/link-local/Cargo.toml | 1 + test/link-local/src/main.rs | 134 +++++++++++++++++++++++++-------- test/link-local/src/vmclock.rs | 75 ++++++++++++++++++ 4 files changed, 180 insertions(+), 31 deletions(-) create mode 100644 test/link-local/src/vmclock.rs diff --git a/Cargo.lock b/Cargo.lock index ea374c3..ad6c2ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -978,6 +978,7 @@ version = "2.0.3" dependencies = [ "clock-bound", "rand 0.9.2", + "tempfile", "tokio", "tracing-subscriber", ] diff --git a/test/link-local/Cargo.toml b/test/link-local/Cargo.toml index 336518b..6896281 100644 --- a/test/link-local/Cargo.toml +++ b/test/link-local/Cargo.toml @@ -21,5 +21,6 @@ clock-bound = { version = "2.0", path = "../../clock-bound", features = [ "daemon", ] } rand = "0.9.2" +tempfile = "3.13" tokio = { version = "1.47.1", features = ["macros", "rt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index 1ee84b5..5f968be 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -10,14 +10,20 @@ use clock_bound::daemon::{ async_ring_buffer::{self, BufferClosedError, Receiver}, io::ntp::DaemonInfo, }; +use std::fs::OpenOptions; +use std::io::Seek; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time; use rand::{RngCore, rng}; +use tempfile::NamedTempFile; use tokio::time::{Duration, timeout}; use tracing_subscriber::EnvFilter; +mod vmclock; +use vmclock::{VMClockContent, write_vmclock_content}; + /// Time out for waiting on source polling task to produce an NTP event /// /// On instances that aren't able to connect to link local the runner will run infinitely. @@ -30,7 +36,8 @@ async fn main() { .init(); test_normal_polling_rate().await; - test_burst_polling_rate().await; + test_startup_polling_rate().await; + test_clock_disruption_polling_rate().await; test_polls_with_selected_clock_source_combos().await; } @@ -51,10 +58,21 @@ async fn setup() -> (Receiver, Arc, SourceIO) { (receiver, selected_clock, sourceio) } +/// Set up link local source io for testing +async fn setup_with_vmclock( + vmclock_shm_path: &str, +) -> (Receiver, Arc, SourceIO) { + let (link_local_receiver, selected_clock, mut sourceio) = setup().await; + + sourceio.create_vmclock(vmclock_shm_path).await.unwrap(); + sourceio.spawn_all(); + + (link_local_receiver, selected_clock, sourceio) +} + /// Test normal polling async fn test_normal_polling_rate() { println!("Testing normal polling rate ..."); - let polling_iterations: u32 = 10; let (receiver, _selected_clock, _sourceio) = setup().await; // Wait for burst mode to end before testing normal polling rate @@ -63,31 +81,89 @@ async fn test_normal_polling_rate() { // Clear any burst mode packets from buffer while let Ok(Ok(_)) = timeout(Duration::from_millis(100), receiver.recv()).await {} - let mut start = time::Instant::now(); - let mut polling_rate = time::Duration::from_secs(0); - for _ in 1..=polling_iterations { - let ntpevent = timeout(Duration::from_secs(TIMEOUT_SECS), receiver.recv()) - .await - .unwrap(); - let now = time::Instant::now(); - let d = now - start; - println!( - "It looks like we got an ntp packet \n{ntpevent:#?}\n{:?} ms", - d.as_millis() - ); - polling_rate += d; - start = now; - } - polling_rate /= polling_iterations; + // Poll the link local receiver + let polling_rate = measure_polling_rate(&receiver, Duration::from_secs(20)).await; + println!("Polling rate avg: {polling_rate:?}"); assert!(polling_rate.abs_diff(Duration::from_secs(2)) < time::Duration::from_millis(100)); println!("Normal poll rate test PASSED"); } -/// Test burst polling -async fn test_burst_polling_rate() { - println!("Testing burst polling rate ..."); +/// Test startup polling +async fn test_startup_polling_rate() { + println!("Testing startup polling rate ..."); let (receiver, _selected_clock, _sourceio) = setup().await; + let polling_rate = measure_polling_rate(&receiver, time::Duration::from_secs(1)).await; + + println!("Burst Polling rate avg: {polling_rate:?}"); + assert!( + polling_rate.abs_diff(LINK_LOCAL_BURST_INTERVAL_DURATION) + < time::Duration::from_millis(100) + ); + println!("Burst poll rate test PASSED"); +} + +/// Test clock disruption polling +async fn test_clock_disruption_polling_rate() { + println!("Testing normal polling rate ..."); + + let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); + let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); + let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); + let mut vmclock_shm_file = OpenOptions::new() + .write(true) + .open(vmclock_shm_path) + .expect("open vmclock file failed"); + let mut vmclock_content = VMClockContent::default(); + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + let (receiver, _selected_clock, _sourceio) = setup_with_vmclock(vmclock_shm_path).await; + + // Wait for burst mode, initiated at startup to end + tokio::time::sleep(Duration::from_secs(3)).await; + + // Clear any burst mode packets from buffer + while let Ok(Ok(_)) = timeout(Duration::from_millis(100), receiver.recv()).await {} + + // Base line polling rate. + let pre_base_line_polling_rate = measure_polling_rate(&receiver, Duration::from_secs(10)).await; + println!("Base line Polling rate avg: {pre_base_line_polling_rate:?}"); + + // Simulate a clock disruption event + // Update the shared memory file + vmclock_content.seq_count += 10; + vmclock_content.disruption_marker += 1; + + let write_time = time::SystemTime::now(); + vmclock_shm_file.rewind().unwrap(); + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + // Clock disruption polling rate + let polling_duration = Duration::from_millis(950) - write_time.elapsed().unwrap(); + let clock_disruption_polling_rate = measure_polling_rate(&receiver, polling_duration).await; + println!("Clock disruption polling rate avg: {clock_disruption_polling_rate:?}"); + println!("Clock disruption polling duration: {polling_duration:?}"); + assert!( + clock_disruption_polling_rate.abs_diff(LINK_LOCAL_BURST_INTERVAL_DURATION) + < time::Duration::from_millis(100) + ); + + // Base line polling rate. + let post_base_line_polling_rate = + measure_polling_rate(&receiver, Duration::from_secs(10)).await; + println!("Base line Polling rate avg: {post_base_line_polling_rate:?}"); + + // The pre and post clock disruption event polling rates should be the same. + assert!( + pre_base_line_polling_rate.abs_diff(post_base_line_polling_rate) + < time::Duration::from_millis(100) + ); + + println!("Clock disruption rate test PASSED"); +} + +/// Measure the polling rate over a given period. +async fn measure_polling_rate(receiver: &Receiver, sampling_period: Duration) -> Duration { let start = time::Instant::now(); let mut polling_rate = time::Duration::from_secs(0); let mut count = 0; @@ -105,6 +181,11 @@ async fn test_burst_polling_rate() { d.as_millis() ); + // Skip the sample if it comes after the accepted duration. + if start.elapsed() >= sampling_period { + break; + } + // Skip the first sample, the IO runner will poll immediately after it's created. if count == 0 { count += 1; @@ -112,18 +193,9 @@ async fn test_burst_polling_rate() { polling_rate += d; count += 1; } - - if start.elapsed() >= Duration::from_secs(1) { - break; - } } polling_rate /= count - 1; - println!("Burst Polling rate avg: {polling_rate:?}"); - assert!( - polling_rate.abs_diff(LINK_LOCAL_BURST_INTERVAL_DURATION) - < time::Duration::from_millis(100) - ); - println!("Burst poll rate test PASSED"); + polling_rate } /// Test polling with varied selected clock source and stratum combinations diff --git a/test/link-local/src/vmclock.rs b/test/link-local/src/vmclock.rs new file mode 100644 index 0000000..0f6d09a --- /dev/null +++ b/test/link-local/src/vmclock.rs @@ -0,0 +1,75 @@ +//! Light weight representation of the vmclock shared memory structure +use clock_bound::vmclock::shm::VMClockClockStatus; +use std::fs::File; +use std::io::Write; +use std::ptr::from_ref; + +pub fn write_vmclock_content(file: &mut File, vmclock_content: &VMClockContent) { + // Convert the VMClockShmBody struct into a slice so we can write it all out, fairly magic. + // Definitely needs the #[repr(C)] layout. + let slice = unsafe { + ::core::slice::from_raw_parts( + from_ref::(vmclock_content).cast::(), + ::core::mem::size_of::(), + ) + }; + + file.write_all(slice).expect("Write failed VMClockContent"); + file.sync_all().expect("Sync to disk failed"); +} + +#[repr(C)] +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct VMClockContent { + magic: u32, + size: u32, + version: u16, + counter_id: u8, + time_type: u8, + pub seq_count: u32, // Used to indicate a clock disruption event + pub disruption_marker: u64, + flags: u64, + _padding: [u8; 2], + clock_status: VMClockClockStatus, + leap_second_smearing_hint: u8, + tai_offset_sec: i16, + leap_indicator: u8, + counter_period_shift: u8, + counter_value: u64, + counter_period_frac_sec: u64, + counter_period_esterror_rate_frac_sec: u64, + counter_period_maxerror_rate_frac_sec: u64, + time_sec: u64, + time_frac_sec: u64, + time_esterror_nanosec: u64, + time_maxerror_nanosec: u64, +} + +impl Default for VMClockContent { + fn default() -> Self { + VMClockContent { + magic: 0x4B4C_4356, + size: 104_u32, + version: 1_u16, + counter_id: 1_u8, + time_type: 0_u8, + seq_count: 10_u32, + disruption_marker: 888_888_u64, + flags: 0_u64, + _padding: [0x00, 0x00], + clock_status: VMClockClockStatus::Synchronized, + leap_second_smearing_hint: 0_u8, + tai_offset_sec: 0_i16, + leap_indicator: 0_u8, + counter_period_shift: 0_u8, + counter_value: 123_456_u64, + counter_period_frac_sec: 0_u64, + counter_period_esterror_rate_frac_sec: 0_u64, + counter_period_maxerror_rate_frac_sec: 0_u64, + time_sec: 0_u64, + time_frac_sec: 0_u64, + time_esterror_nanosec: 0_u64, + time_maxerror_nanosec: 0_u64, + } + } +} From 2244ebb4866f01dfc9a20f08f63b850fb1cc07e8 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:50:28 -0500 Subject: [PATCH 119/177] rework side-by-side compilation feature This commit reworks the test-side-by-side compilation feature to allow for the ClockState async actor to run (and write to the SHM). When the binary is compiled with this option, we simply use a "no-op" clock adjuster instead of making actual ntp-adj system calls. --- clock-bound/src/daemon.rs | 17 +----- clock-bound/src/daemon/clock_state.rs | 12 +++- .../src/daemon/clock_state/clock_adjust.rs | 5 +- .../clock_state/clock_adjust/ntp_adjtime.rs | 9 ++- .../src/daemon/clock_sync_algorithm.rs | 4 +- .../src/daemon/clock_sync_algorithm/ff/phc.rs | 9 +-- clock-bound/src/daemon/event/ntp.rs | 10 ++-- clock-bound/src/daemon/event/phc.rs | 10 ++-- clock-bound/src/daemon/io/ntp.rs | 6 +- clock-bound/src/daemon/receiver_stream.rs | 2 +- clock-bound/src/daemon/time/timex.rs | 55 +++++++++++++++++++ 11 files changed, 94 insertions(+), 45 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 03b4779..9ddf8a1 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -33,7 +33,6 @@ use crate::daemon::{ time::tsc::Skew, }; -#[cfg(not(feature = "test-side-by-side"))] use crate::daemon::{ async_ring_buffer::Sender, clock_parameters::ClockParameters, clock_state::ClockState, }; @@ -59,9 +58,7 @@ pub struct Daemon { clock_sync_algorithm: ClockSyncAlgorithm, receiver_stream: ReceiverStream, clock_disruption_receiver: watch::Receiver, - #[cfg(not(feature = "test-side-by-side"))] clock_state_tx: Sender, - #[cfg(not(feature = "test-side-by-side"))] clock_state: Option, } @@ -74,7 +71,6 @@ impl Daemon { minor_version: 100, startup_id: rng().next_u64(), }; - #[cfg(not(feature = "test-side-by-side"))] let (clock_state_tx, clock_state) = { let (tx, rx) = async_ring_buffer::create(1); let clock_state = ClockState::construct(rx); @@ -129,9 +125,7 @@ impl Daemon { clock_sync_algorithm, receiver_stream, clock_disruption_receiver, - #[cfg(not(feature = "test-side-by-side"))] clock_state_tx, - #[cfg(not(feature = "test-side-by-side"))] clock_state: Some(clock_state), } } @@ -140,7 +134,6 @@ impl Daemon { pub async fn run(mut self: Box) { // Start IO polling self.io_front_end.spawn_all(); - #[cfg(not(feature = "test-side-by-side"))] tokio::spawn({ #[expect( clippy::missing_panics_doc, @@ -165,7 +158,6 @@ impl Daemon { } fn handle_event(&mut self, routable_event: RoutableEvent) { - #[cfg(not(feature = "test-side-by-side"))] if let Some(params) = self.clock_sync_algorithm.feed(routable_event) { use crate::daemon::async_ring_buffer::SendError; @@ -187,8 +179,6 @@ impl Daemon { } } } - #[cfg(feature = "test-side-by-side")] - let _ = self.clock_sync_algorithm.feed(routable_event); } /// Handle a clock disruption event @@ -197,20 +187,15 @@ impl Daemon { io_front_end: _, clock_sync_algorithm, receiver_stream, - #[cfg(not(feature = "test-side-by-side"))] - clock_state, // TODO clear the buffer when the actor pattern comes in clock_disruption_receiver, - #[cfg(not(feature = "test-side-by-side"))] clock_state_tx, + clock_state, } = self; let val = clock_disruption_receiver.borrow_and_update().clone(); - #[cfg_attr(feature = "test-side-by-side", expect(unused))] if let Some(disruption_marker) = val.disruption_marker { - #[cfg(not(feature = "test-side-by-side"))] if let Some(clock_state) = clock_state { clock_state.handle_disruption(disruption_marker); } - #[cfg(not(feature = "test-side-by-side"))] clock_state_tx.handle_disruption(); clock_sync_algorithm.handle_disruption(); receiver_stream.handle_disruption(); diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 7134d65..ca7f84e 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -7,7 +7,11 @@ use tracing::info; use crate::daemon::MAX_DISPERSION_GROWTH_PPB; use crate::daemon::async_ring_buffer::Receiver; use crate::daemon::clock_parameters::ClockParameters; -use crate::daemon::clock_state::clock_adjust::{ClockAdjust, ClockAdjuster, KAPIClockAdjuster}; +#[cfg(not(feature = "test-side-by-side"))] +use crate::daemon::clock_state::clock_adjust::KAPIClockAdjuster; +#[cfg(feature = "test-side-by-side")] +use crate::daemon::clock_state::clock_adjust::NoopClockAdjuster; +use crate::daemon::clock_state::clock_adjust::{ClockAdjust, ClockAdjuster}; use crate::daemon::clock_state::clock_state_writer::ClockStateWriter; use crate::daemon::clock_state::clock_state_writer::{ClockStateWrite, SafeShmWriter}; use crate::daemon::io::tsc::ReadTscImpl; @@ -28,7 +32,6 @@ pub(crate) struct ClockState { clock_params_receiver: Receiver, } -#[cfg_attr(feature = "test-side-by-side", expect(unused))] impl ClockState { pub fn new( clock_state_writer: Box, @@ -54,8 +57,13 @@ impl ClockState { .max_drift_ppb(MAX_DISPERSION_GROWTH_PPB) .disruption_marker(0) .build(); + #[cfg(not(feature = "test-side-by-side"))] let clock_adjuster: ClockAdjuster = ClockAdjuster::new(KAPIClockAdjuster); + #[cfg(feature = "test-side-by-side")] + let clock_adjuster: ClockAdjuster = + ClockAdjuster::new(NoopClockAdjuster); + Self::new( Box::new(clock_state_writer), Box::new(clock_adjuster), diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index 6426a1e..a57307a 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -29,7 +29,10 @@ use crate::{ mod ntp_adjtime; mod state_machine; -pub use ntp_adjtime::{KAPIClockAdjuster, NtpAdjTimeError, NtpAdjTimeExt}; +pub use ntp_adjtime::KAPIClockAdjuster; +#[cfg(feature = "test-side-by-side")] +pub use ntp_adjtime::NoopClockAdjuster; +pub use ntp_adjtime::{NtpAdjTimeError, NtpAdjTimeExt}; #[cfg_attr(test, mockall::automock)] pub(crate) trait ClockAdjust: Send + Sync { diff --git a/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs b/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs index 180801f..cf4e120 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs @@ -32,14 +32,19 @@ impl NtpAdjTimeExt for KAPIClockAdjuster {} /// Noop Clock Adjuster, which doesn't actually adjust the clock parameters but just /// returns `TIME_OK`. -#[expect(unused)] +#[cfg_attr(not(feature = "test-side-by-side"), expect(unused))] pub struct NoopClockAdjuster; impl NtpAdjTime for NoopClockAdjuster { fn ntp_adjtime(&self, _tx: &mut Timex) -> i32 { TIME_OK } } -impl NtpAdjTimeExt for NoopClockAdjuster {} +#[cfg(feature = "test-side-by-side")] +impl NtpAdjTimeExt for NoopClockAdjuster { + fn step_clock(&self, _phase_correction: Duration) -> Result { + Ok(Timex::create_dummy_timex()) + } +} /// Lightweight trait around `ntp_adjtime` function (formerly `adjtimex`). /// Useful for mocking, or potentially as an abstraction around modifying diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index a2d38ce..636d392 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -52,7 +52,7 @@ pub struct ClockSyncAlgorithm { impl ClockSyncAlgorithm { /// Feed the clock sync algorithm with a time synchronization event pub fn feed(&mut self, routable_event: RoutableEvent) -> Option<&ClockParameters> { - #[cfg(all(feature = "test-side-by-side", not(test)))] + #[cfg(not(test))] { use crate::daemon::event::TscRtt; let Some(system_clock) = routable_event.system_clock() else { @@ -79,7 +79,7 @@ impl ClockSyncAlgorithm { } retval } - #[cfg(any(not(feature = "test-side-by-side"), test))] + #[cfg(test)] { self.feed_repro(routable_event) } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs index c6bfdd4..630f7ac 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs @@ -174,12 +174,9 @@ impl Phc { /// /// This updates the local buffer with the event. If a `period` has been calculated already, /// then we can use this value to update the `estimate` buffer as well. - #[cfg_attr( - feature = "test-side-by-side", - expect( - clippy::result_large_err, - reason = "returning passed in value is idiomatic" - ) + #[expect( + clippy::result_large_err, + reason = "returning passed in value is idiomatic" )] fn feed_internal_buffers(&mut self, event: event::Phc) -> Result> { let event_rtt = event.rtt(); diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index 4b1d12c..965756d 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -21,7 +21,7 @@ pub struct Ntp { tsc_post: TscCount, /// NTP Packet data data: NtpData, - #[cfg(all(not(test), feature = "test-side-by-side"))] + #[cfg(not(test))] #[serde(skip)] system_clock: Option, } @@ -36,16 +36,14 @@ impl Ntp { tsc_pre: TscCount, tsc_post: TscCount, ntp_data: NtpData, - #[cfg(all(not(test), feature = "test-side-by-side"))] system_clock: Option< - super::SystemClockMeasurement, - >, + #[cfg(not(test))] system_clock: Option, ) -> Option { if tsc_post > tsc_pre { Some(Self { tsc_pre, tsc_post, data: ntp_data, - #[cfg(all(not(test), feature = "test-side-by-side"))] + #[cfg(not(test))] system_clock, }) } else { @@ -71,7 +69,7 @@ impl Ntp { } /// system time getter - #[cfg(all(not(test), feature = "test-side-by-side"))] + #[cfg(not(test))] pub fn system_clock(&self) -> Option<&super::SystemClockMeasurement> { self.system_clock.as_ref() } diff --git a/clock-bound/src/daemon/event/phc.rs b/clock-bound/src/daemon/event/phc.rs index 0088dd4..3dd59da 100644 --- a/clock-bound/src/daemon/event/phc.rs +++ b/clock-bound/src/daemon/event/phc.rs @@ -18,7 +18,7 @@ pub struct Phc { tsc_post: TscCount, /// PHC reference clock info data: PhcData, - #[cfg(all(not(test), feature = "test-side-by-side"))] + #[cfg(not(test))] #[serde(skip)] system_clock: Option, } @@ -33,9 +33,7 @@ impl Phc { tsc_pre: TscCount, tsc_post: TscCount, data: PhcData, - #[cfg(all(not(test), feature = "test-side-by-side"))] system_clock: Option< - super::SystemClockMeasurement, - >, + #[cfg(not(test))] system_clock: Option, ) -> Option { if tsc_post <= tsc_pre { return None; @@ -45,7 +43,7 @@ impl Phc { tsc_pre, tsc_post, data, - #[cfg(all(not(test), feature = "test-side-by-side"))] + #[cfg(not(test))] system_clock, }) } @@ -68,7 +66,7 @@ impl Phc { } /// system time getter - #[cfg(all(not(test), feature = "test-side-by-side"))] + #[cfg(not(test))] pub fn system_clock(&self) -> Option<&super::SystemClockMeasurement> { self.system_clock.as_ref() } diff --git a/clock-bound/src/daemon/io/ntp.rs b/clock-bound/src/daemon/io/ntp.rs index 4b96d9b..85dbb4a 100644 --- a/clock-bound/src/daemon/io/ntp.rs +++ b/clock-bound/src/daemon/io/ntp.rs @@ -71,7 +71,7 @@ pub async fn sample_packet( let (send_timestamp, ntp_data, received_timestamp) = fut.await??; - #[cfg(all(not(test), feature = "test-side-by-side"))] + #[cfg(not(test))] let system_clock_reading = crate::daemon::event::SystemClockMeasurement::now(); let builder = event::Ntp::builder() @@ -80,11 +80,11 @@ pub async fn sample_packet( .ntp_data(ntp_data); let ntp_event = { - #[cfg(all(not(test), feature = "test-side-by-side"))] + #[cfg(not(test))] { builder.system_clock(system_clock_reading).build() } - #[cfg(any(test, not(feature = "test-side-by-side")))] + #[cfg(test)] { builder.build() } diff --git a/clock-bound/src/daemon/receiver_stream.rs b/clock-bound/src/daemon/receiver_stream.rs index f2ba488..d287bd3 100644 --- a/clock-bound/src/daemon/receiver_stream.rs +++ b/clock-bound/src/daemon/receiver_stream.rs @@ -141,7 +141,7 @@ pub enum RoutableEvent { impl RoutableEvent { /// Get the system clock info - #[cfg(all(not(test), feature = "test-side-by-side"))] + #[cfg(not(test))] pub fn system_clock(&self) -> Option<&crate::daemon::event::SystemClockMeasurement> { match self { RoutableEvent::LinkLocal(data) | RoutableEvent::NtpSource(_, data) => { diff --git a/clock-bound/src/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs index 0a4effa..92bc4b7 100644 --- a/clock-bound/src/daemon/time/timex.rs +++ b/clock-bound/src/daemon/time/timex.rs @@ -419,6 +419,61 @@ impl Timex { __unused11: 0, }) } + + // Helper function to create a dummy Timex for `NoopClockAdjuster`. + #[cfg(feature = "test-side-by-side")] + pub fn create_dummy_timex() -> Timex { + Timex(timex { + modes: 0, + offset: 0, + freq: 0, + maxerror: 0, + esterror: 0, + status: 0, + constant: 0, + precision: 0, + tolerance: 0, + #[cfg(not(target_os = "macos"))] + time: timeval { + tv_sec: 0, + tv_usec: 0, + }, + #[cfg(not(target_os = "macos"))] + tick: 0, + ppsfreq: 0, + jitter: 0, + shift: 0, + stabil: 0, + jitcnt: 0, + calcnt: 0, + errcnt: 0, + stbcnt: 0, + #[cfg(not(target_os = "macos"))] + tai: 0, + #[cfg(not(target_os = "macos"))] + __unused1: 0, + #[cfg(not(target_os = "macos"))] + __unused2: 0, + #[cfg(not(target_os = "macos"))] + __unused3: 0, + #[cfg(not(target_os = "macos"))] + __unused4: 0, + #[cfg(not(target_os = "macos"))] + __unused5: 0, + #[cfg(not(target_os = "macos"))] + __unused6: 0, + #[cfg(not(target_os = "macos"))] + __unused7: 0, + #[cfg(not(target_os = "macos"))] + __unused8: 0, + #[cfg(not(target_os = "macos"))] + __unused9: 0, + #[cfg(not(target_os = "macos"))] + __unused10: 0, + #[cfg(not(target_os = "macos"))] + __unused11: 0, + }) + } } impl AsRef for Timex { From 60f049bfe923d8511b0dd3aa0588870f7a3b8fc9 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:27:34 -0500 Subject: [PATCH 120/177] graceful shutdown This commit implements a control request "shutdown" message and handlers for SourceIO components. It also takes advantage of some tokio utils including CancellationTokens and TaskTrackers to communicate shutdown intent from the top-level task to daemon & clock state actors. It includes a new ClockStateHandle struct that the daemon uses to interact with ClockState (really just a wrapper around the existing Sender and new lifecycle management resources). For now, we just exit naively - breaking out of runner function for loops. We may want to follow this change up with an update to actually reset/clean the SHM when the ClockState task exits. --- Cargo.lock | 25 +++++++ clock-bound/Cargo.toml | 3 + clock-bound/src/bin/clockbound.rs | 21 +++++- clock-bound/src/daemon.rs | 83 ++++++++++++++++------- clock-bound/src/daemon/clock_state.rs | 28 +++++++- clock-bound/src/daemon/io.rs | 89 ++++++++++++++++++++----- clock-bound/src/daemon/io/link_local.rs | 14 +++- clock-bound/src/daemon/io/ntp_source.rs | 14 +++- clock-bound/src/daemon/io/vmclock.rs | 14 +++- test/link-local/src/main.rs | 1 + 10 files changed, 237 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad6c2ba..321bb1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,7 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "tokio", + "tokio-util", "tracing", "tracing-appender", "tracing-subscriber", @@ -1701,6 +1702,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + [[package]] name = "simba" version = "0.9.1" @@ -1932,6 +1942,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "slab", "socket2", "tokio-macros", @@ -1949,6 +1960,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.7.3" diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index c341512..0f18c35 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -35,9 +35,11 @@ tokio = { version = "1.47.1", features = [ "macros", "rt", "rt-multi-thread", + "signal", "sync", "time", ], optional = true } +tokio-util = { version = "0.7.17", features = ["rt"], optional = true } tracing = "0.1" tracing-appender = { version = "0.2", optional = true } tracing-subscriber = { version = "0.3", features = [ @@ -75,6 +77,7 @@ daemon = [ "dep:clap", "dep:serde", "dep:tokio", + "dep:tokio-util", "dep:chrono", "dep:bytes", "dep:nom", diff --git a/clock-bound/src/bin/clockbound.rs b/clock-bound/src/bin/clockbound.rs index 5f13506..0f8b7f2 100644 --- a/clock-bound/src/bin/clockbound.rs +++ b/clock-bound/src/bin/clockbound.rs @@ -3,6 +3,9 @@ use std::path::PathBuf; use clap::Parser; use clock_bound::daemon::{Daemon, subscriber}; +use tokio::signal::unix::{SignalKind, signal}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; #[derive(Debug, Parser)] struct Args { @@ -14,7 +17,21 @@ struct Args { async fn main() { let args = Args::parse(); subscriber::init(&args.log_dir); - let d = Daemon::construct().await; + let cancellation_token = CancellationToken::new(); + let d = Daemon::construct(cancellation_token.clone()).await; let d = Box::new(d); - tokio::spawn(async move { d.run().await }).await.unwrap(); + let mut daemon_handle = tokio::spawn(async move { d.run().await }); + + let mut sigint = signal(SignalKind::interrupt()).expect("failed to create SIGINT listener."); + tokio::select! { + _ = sigint.recv() => { + info!("SIGINT receieved. Starting graceful shutdown of daemon."); + cancellation_token.cancel(); + match daemon_handle.await { + Ok(()) => info!("daemon exited gracefully."), + Err(e) => warn!(?e, "daemon exited ungracefully.") + } + }, + res = &mut daemon_handle => error!(?res, "daemon exited unexpectedly."), + } } diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 9ddf8a1..a047aa4 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -36,8 +36,12 @@ use crate::daemon::{ use crate::daemon::{ async_ring_buffer::Sender, clock_parameters::ClockParameters, clock_state::ClockState, }; +use tokio_util::task::TaskTracker; + use rand::{RngCore, rng}; use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use tracing::info; /// The maximum dispersion growth every second /// @@ -58,24 +62,33 @@ pub struct Daemon { clock_sync_algorithm: ClockSyncAlgorithm, receiver_stream: ReceiverStream, clock_disruption_receiver: watch::Receiver, - clock_state_tx: Sender, - clock_state: Option, + cancellation_token: CancellationToken, + clock_state_handle: ClockStateHandle, } impl Daemon { /// Construct and initialize a new daemon /// FIXME: Make this function not async. (Currently required for the io.run methods) - pub async fn construct() -> Self { + pub async fn construct(cancellation_token: CancellationToken) -> Self { let daemon_info = DaemonInfo { major_version: 2, minor_version: 100, startup_id: rng().next_u64(), }; + + let clock_state_cancellation_token = CancellationToken::new(); let (clock_state_tx, clock_state) = { let (tx, rx) = async_ring_buffer::create(1); - let clock_state = ClockState::construct(rx); + let clock_state = ClockState::construct(rx, clock_state_cancellation_token.clone()); (tx, clock_state) }; + let task_tracker = TaskTracker::new(); + let clock_state_handle = ClockStateHandle { + clock_state: Some(clock_state), + tx: clock_state_tx, + cancellation_token: clock_state_cancellation_token, + task_tracker, + }; let selected_clock = Arc::new(SelectedClockSource::default()); let clock_sync_algorithm = ClockSyncAlgorithm::builder() @@ -104,17 +117,8 @@ impl Daemon { let mut io_front_end = io::SourceIO::construct(selected_clock.clone(), daemon_info); let clock_disruption_receiver = io_front_end.clock_disruption_receiver(); - // FIXME, we are basically starting the application in the constructor - // We should be able to construct the link local and spawn it when `run` is called - // - // Also, this function doesn't return anything? - // - // Ideally we would construct the `io::LinkLocal` and just build the `io_front_end` with something like - // ``` - // let (tx, rx) = mpsc::channel(1); - // let link_local = io::LinkLocal::construct(rx, ..Args); - // let io_front_end = SourceIo::builder().link_local(tx, link_local).build(); - // ``` + + // Initialize IO components. io_front_end.create_link_local(link_local_tx).await; for source in ntp_source_event_senders { io_front_end.create_ntp_source(source).await; @@ -125,8 +129,8 @@ impl Daemon { clock_sync_algorithm, receiver_stream, clock_disruption_receiver, - clock_state_tx, - clock_state: Some(clock_state), + cancellation_token, + clock_state_handle, } } @@ -134,16 +138,17 @@ impl Daemon { pub async fn run(mut self: Box) { // Start IO polling self.io_front_end.spawn_all(); - tokio::spawn({ + self.clock_state_handle.task_tracker.spawn({ #[expect( clippy::missing_panics_doc, reason = "struct always initialized with Some" )] - let mut clock_state = self.clock_state.take().unwrap(); + let mut clock_state = self.clock_state_handle.clock_state.take().unwrap(); async move { clock_state.run().await; } }); + self.clock_state_handle.task_tracker.close(); loop { tokio::select! { biased; // biased to ensure disruption is handled first when this happens @@ -152,7 +157,22 @@ impl Daemon { } Some(routable_event) = self.receiver_stream.recv() => { self.handle_event(routable_event); - }, + } + () = self.cancellation_token.cancelled() => { + info!("Received shutdown signal. Starting graceful shutdown of daemon."); + + // TODO: we can asynchronously shutdown both clock state and io tasks. + + // shutdown clock state task + self.clock_state_handle.cancellation_token.cancel(); + self.clock_state_handle.task_tracker.wait().await; + + // shutdown all io tasks + self.io_front_end.shutdown_all().await; + + // exit ourselves + break; + } } } } @@ -161,7 +181,7 @@ impl Daemon { if let Some(params) = self.clock_sync_algorithm.feed(routable_event) { use crate::daemon::async_ring_buffer::SendError; - match self.clock_state_tx.send(params.clone()) { + match self.clock_state_handle.tx.send(params.clone()) { Ok(()) => (), Err(SendError::Disrupted(clock_parameters)) => { // don't handle_disruption. It will be handled on the next call of tokio::select @@ -188,15 +208,23 @@ impl Daemon { clock_sync_algorithm, receiver_stream, clock_disruption_receiver, - clock_state_tx, - clock_state, + cancellation_token: _, + clock_state_handle, } = self; + + let ClockStateHandle { + clock_state, // TODO clear the buffer when the actor pattern comes in + tx, + cancellation_token: _, + task_tracker: _, + } = clock_state_handle; + let val = clock_disruption_receiver.borrow_and_update().clone(); if let Some(disruption_marker) = val.disruption_marker { if let Some(clock_state) = clock_state { clock_state.handle_disruption(disruption_marker); } - clock_state_tx.handle_disruption(); + tx.handle_disruption(); clock_sync_algorithm.handle_disruption(); receiver_stream.handle_disruption(); } @@ -228,3 +256,10 @@ impl Daemon { (sender_vec, receiver_vec) } } + +struct ClockStateHandle { + clock_state: Option, + tx: Sender, + cancellation_token: CancellationToken, + task_tracker: TaskTracker, +} diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index ca7f84e..c678982 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -2,6 +2,7 @@ pub mod clock_adjust; pub mod clock_state_writer; +use tokio_util::sync::CancellationToken; use tracing::info; use crate::daemon::MAX_DISPERSION_GROWTH_PPB; @@ -30,6 +31,7 @@ pub(crate) struct ClockState { clock_parameters: Option, interval: tokio::time::Interval, clock_params_receiver: Receiver, + cancellation_token: CancellationToken, } impl ClockState { @@ -37,6 +39,7 @@ impl ClockState { clock_state_writer: Box, clock_adjuster: Box, clock_params_receiver: Receiver, + cancellation_token: CancellationToken, ) -> Self { let interval = tokio::time::interval(tokio::time::Duration::from_millis(100)); Self { @@ -45,10 +48,14 @@ impl ClockState { interval, clock_params_receiver, clock_parameters: None, + cancellation_token, } } - pub fn construct(clock_params_receiver: Receiver) -> Self { + pub fn construct( + clock_params_receiver: Receiver, + cancellation_token: CancellationToken, + ) -> Self { let shm_writer = ShmWriter::new(std::path::Path::new(CLOCKBOUND_SHM_DEFAULT_PATH)).unwrap(); let safe_shm_writer = SafeShmWriter::new(shm_writer); let clock_state_writer: ClockStateWriter = ClockStateWriter::builder() @@ -68,6 +75,7 @@ impl ClockState { Box::new(clock_state_writer), Box::new(clock_adjuster), clock_params_receiver, + cancellation_token, ) } @@ -80,9 +88,16 @@ impl ClockState { }, params = self.clock_params_receiver.recv() => { self.handle_clock_parameters(params.unwrap()); // todo fixme - } + }, + () = self.cancellation_token.cancelled() => { + // nothing fancy for now. just exit + // TODO: we may want to clean-up SHM here. + info!("Received shutdown signal. Exiting."); + break; + }, } } + info!("ClockState runner exiting."); } fn handle_tick(&mut self, now: tokio::time::Instant) { @@ -131,6 +146,7 @@ impl ClockState { clock_params_receiver, interval: _, clock_parameters, + cancellation_token: _, } = self; *clock_parameters = None; clock_params_receiver.handle_disruption(); @@ -168,6 +184,7 @@ mod tests { #[tokio::test] async fn handle_clock_parameters() { let clock_parameters = get_sample_clock_parameters(); + let cancellation_token = CancellationToken::new(); let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() @@ -185,6 +202,7 @@ mod tests { Box::new(mock_clock_state_writer), Box::new(mock_clock_adjuster), rx, + cancellation_token, ); assert_eq!(clock_state.clock_parameters, None); clock_state.handle_clock_parameters(clock_parameters.clone()); @@ -194,6 +212,7 @@ mod tests { #[tokio::test] async fn handle_disruption() { let disruption_marker = 123; + let cancellation_token = CancellationToken::new(); let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_disruption() @@ -211,12 +230,14 @@ mod tests { Box::new(mock_clock_state_writer), Box::new(mock_clock_adjuster), rx, + cancellation_token, ); clock_state.handle_disruption(disruption_marker); } #[tokio::test] async fn handle_tick_no_parameters() { + let cancellation_token = CancellationToken::new(); let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); mock_clock_adjuster .expect_handle_clock_parameters() @@ -234,6 +255,7 @@ mod tests { Box::new(mock_clock_state_writer), Box::new(mock_clock_adjuster), rx, + cancellation_token, ); clock_state.clock_parameters = None; clock_state.handle_tick(tokio::time::Instant::now()); @@ -245,6 +267,7 @@ mod tests { let expected_clock_status = ClockStatus::Synchronized; let expected_clock_params = get_sample_clock_parameters(); let expected_instant = tokio::time::Instant::now(); + let cancellation_token = CancellationToken::new(); let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); let expected_clock_params_clone = expected_clock_params.clone(); @@ -282,6 +305,7 @@ mod tests { Box::new(mock_clock_state_writer), Box::new(mock_clock_adjuster), rx, + cancellation_token, ); clock_state.clock_parameters = Some(expected_clock_params); clock_state.handle_tick(expected_instant); diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index ae35dae..7111ce6 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -10,7 +10,7 @@ use std::net::SocketAddr; use std::sync::Arc; use tokio::net::UdpSocket; use tokio::sync::{mpsc, watch}; -use tokio::task::spawn; +use tokio_util::task::TaskTracker; use tracing::{debug, info, warn}; pub mod ntp; @@ -53,6 +53,8 @@ pub struct SourceIO { selected_clock: Arc, /// Daemon metadata daemon_info: DaemonInfo, + /// `tokio::task::TaskTracker` to manage lifecycle of the individual IO tasks + task_tracker: TaskTracker, } impl SourceIO { @@ -60,6 +62,13 @@ impl SourceIO { pub fn construct(selected_clock: Arc, daemon_info: DaemonInfo) -> Self { let (sender, receiver) = watch::channel::(ClockDisruptionEvent::default()); + + // Task tracker is closed on initialization. We only open it during calls to spawn + // the IO tasks. This ensures that we don't introduce a deadlock if the shutdown function + // is called before the spawn function. + let task_tracker = TaskTracker::new(); + task_tracker.close(); + SourceIO { link_local: None, ntp_sources: HashMap::new(), @@ -68,10 +77,11 @@ impl SourceIO { clock_disruption_channels: ClockDisruptionChannels { sender, receiver }, selected_clock, daemon_info, + task_tracker, } } - /// Spawns the IO task for sampling the Link Local NTP source. + /// Initializes the IO task for sampling the Link Local NTP source. /// /// # Panics /// - If not called within the `tokio` runtime. @@ -106,7 +116,7 @@ impl SourceIO { info!("Source link local update complete."); } - /// Spawns the IO task for sampling a specific NTP Server source. + /// Initializes the IO task for sampling a specific NTP Server source. /// /// # Panics /// - If not called within the `tokio` runtime. @@ -146,7 +156,7 @@ impl SourceIO { info!("Source NTP update complete."); } - /// Spawns the IO task for sampling the PHC source. + /// Initializes the IO task for sampling the PHC source. /// /// # Panics /// - If not called within the `tokio` runtime. @@ -174,7 +184,7 @@ impl SourceIO { info!("Source PHC update complete."); } - /// Spawns the IO task for sampling the VMClock shared memory file. + /// Initializes the IO task for sampling the VMClock shared memory file. /// /// # Errors /// - If the vmclock shared memory file could not be found. @@ -212,18 +222,12 @@ impl SourceIO { self.clock_disruption_channels.sender.subscribe() } - /// Starts the control flow task. - #[allow( - clippy::unused_async, - reason = "This is a stubbed function. The async component will be implemented at a later date." - )] - pub async fn run(&self) {} - /// Spawns all io tasks which have been initialized. /// /// This function will spawn all initialized io tasks. If a task is not initialized it will be /// skipped. Spawning will occur in an arbitrary order. pub fn spawn_all(&mut self) { + self.task_tracker.reopen(); // Spawn link local source if let Some(Source { state, @@ -232,7 +236,8 @@ impl SourceIO { { debug!("Attempting to spawn link local source."); if let SourceState::Initialized(mut link_local) = state.transition_to_running() { - spawn(async move { link_local.run().await }); + self.task_tracker + .spawn(async move { link_local.run().await }); debug!("Successfully spawned link local source."); } else { warn!("Attempted to spawn a link local source when one is currently running."); @@ -249,7 +254,7 @@ impl SourceIO { { debug!("Attempting to spawn PHC source."); if let SourceState::Initialized(mut phc) = state.transition_to_running() { - spawn(async move { phc.run().await }); + self.task_tracker.spawn(async move { phc.run().await }); debug!("Successfully spawned PHC source."); } else { warn!("Attempted to spawn a PHC source when one is currently running."); @@ -265,7 +270,7 @@ impl SourceIO { }) = &mut self.vmclock { if let SourceState::Initialized(mut vmclock) = state.transition_to_running() { - spawn(async move { vmclock.run().await }); + self.task_tracker.spawn(async move { vmclock.run().await }); } else { warn!("Attempted to spawn a vmclock source when one is currently running."); } @@ -279,12 +284,59 @@ impl SourceIO { if let SourceState::Initialized(mut ntp_source) = ntp_source.state.transition_to_running() { - spawn(async move { ntp_source.run().await }); + self.task_tracker + .spawn(async move { ntp_source.run().await }); debug!("Successfully spawned ntp source."); } else { warn!("Attempted to spawn a ntp source when on is currently running."); } } + + self.task_tracker.close(); + } + + /// Sends shutdown signals to all io tasks. + /// + /// This function sequentially sends `ControlRequest::Shutdown` signals to all + /// io tasks and then waits for all io tasks to exit. + pub async fn shutdown_all(&mut self) { + info!("Starting shutdown of SourceIO components."); + // Shutdown link local source + if let Some(Source { + state: _, + ctrl_sender, + }) = &mut self.link_local + { + match ctrl_sender.send(ControlRequest::Shutdown).await { + Ok(()) => info!("Successfully sent shutdown signal to link local source."), + Err(e) => warn!(?e, "Failed to send shutdown signal to link local source."), + } + } + + // Shutdown vmclock source + if let Some(Source { + state: _, + ctrl_sender, + }) = &mut self.vmclock + { + match ctrl_sender.send(ControlRequest::Shutdown).await { + Ok(()) => info!("Successfully sent shutdown signal to vmclock source."), + Err(e) => warn!(?e, "Failed to send shutdown signal to vmclock source."), + } + } + + // Shutdown ntp sources + for ntp_source in self.ntp_sources.values_mut() { + match ntp_source.ctrl_sender.send(ControlRequest::Shutdown).await { + Ok(()) => info!("Successfully sent shutdown signal to ntp source."), + Err(e) => warn!(?e, "Failed to send shutdown signal to ntp source."), + } + } + + // Wait for all io tasks to exit + info!("Waiting for {} io tasks to exit.", self.task_tracker.len()); + self.task_tracker.wait().await; + info!("All tasks exited. Shutdown of io complete."); } } @@ -299,9 +351,10 @@ pub struct ClockDisruptionEvent { pub disruption_marker: Option, } -// TODO: This is a stub for future control events. #[derive(Debug)] -pub struct ControlRequest {} +pub enum ControlRequest { + Shutdown, +} /// A helper struct packaging the source state and its control sender together. #[derive(Debug)] diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index cd5dc8b..dc55377 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -154,10 +154,18 @@ impl LinkLocal { self.handle_disruption(); info!("Received clock disruption signal. Entering Burst mode."); } - _ = self.ctrl_receiver.recv() => { + ctrl_req = self.ctrl_receiver.recv() => { // Ctrl logic here. - // Currently we breakout of the loop if we receive a control event. - break; + match ctrl_req { + None => { + // this select can happen if `SourceIO` drops the ctrl_sender + break; + }, + Some(ControlRequest::Shutdown) => { + info!("Received shutdown signal. Exiting."); + break; + }, + } } _ = self.interval.tick() => { self.handle_interval_tick().await; diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 64479d0..7a7cc18 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -145,10 +145,18 @@ impl NTPSource { _ = self.clock_disruption_receiver.changed() => { self.handle_disruption(); } - _ = self.ctrl_receiver.recv() => { + ctrl_req = self.ctrl_receiver.recv() => { // Ctrl logic here. - // Currently we breakout of the loop if we receive a control event. - break; + match ctrl_req { + None => { + // this select can happen if `SourceIO` drops the ctrl_sender + break; + }, + Some(ControlRequest::Shutdown) => { + info!("Received shutdown signal. Exiting."); + break; + }, + } } _ = self.interval.tick() => { self.handle_interval_tick().await; diff --git a/clock-bound/src/daemon/io/vmclock.rs b/clock-bound/src/daemon/io/vmclock.rs index a09d230..16e0f65 100644 --- a/clock-bound/src/daemon/io/vmclock.rs +++ b/clock-bound/src/daemon/io/vmclock.rs @@ -143,10 +143,18 @@ impl VMClock { Err(e) => debug!(?e, "Failed to sample the VMClock.") } } - _ = self.ctrl_receiver.recv() => { + ctrl_req = self.ctrl_receiver.recv() => { // Ctrl logic here. - // Currently we breakout of the loop if we receive a control event. - break; + match ctrl_req { + None => { + // this select can happen if `SourceIO` drops the ctrl_sender + break; + }, + Some(ControlRequest::Shutdown) => { + info!("Received shutdown signal. Exiting."); + break; + }, + } } } } diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index 5f968be..afdda62 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -52,6 +52,7 @@ async fn setup() -> (Receiver, Arc, SourceIO) { }; let selected_clock = Arc::new(SelectedClockSource::default()); + let mut sourceio = SourceIO::construct(selected_clock.clone(), daemon_info); sourceio.create_link_local(sender).await; sourceio.spawn_all(); From addfd9ad45a8603b5f737a26300899527c219ff8 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Fri, 14 Nov 2025 15:40:51 -0500 Subject: [PATCH 121/177] Adds integration test for vmclock runner (#138) Add integration test for vmclock runner. --- .github/workflows/vmclock.yml | 49 +++++++++++++++++++++++++++++++++++ Cargo.lock | 10 +++++++ Cargo.toml | 1 + test/vmclock/Cargo.toml | 25 ++++++++++++++++++ test/vmclock/Makefile.toml | 8 ++++++ test/vmclock/README.md | 38 +++++++++++++++++++++++++++ test/vmclock/src/main.rs | 42 ++++++++++++++++++++++++++++++ 7 files changed, 173 insertions(+) create mode 100644 .github/workflows/vmclock.yml create mode 100644 test/vmclock/Cargo.toml create mode 100644 test/vmclock/Makefile.toml create mode 100644 test/vmclock/README.md create mode 100644 test/vmclock/src/main.rs diff --git a/.github/workflows/vmclock.yml b/.github/workflows/vmclock.yml new file mode 100644 index 0000000..13b4116 --- /dev/null +++ b/.github/workflows/vmclock.yml @@ -0,0 +1,49 @@ +name: VMClock + +permissions: + contents: read + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run cargo build + run: cargo build --bin vmclock-test --release + + - name: Upload vmclock-test artifact + uses: actions/upload-artifact@v4 + with: + name: vmclock-test + path: target/release/vmclock-test + + VMClock_Tests: + name: VMClock Tests + needs: build + runs-on: + - codebuild-StagingClockBound-${{ github.run_id }}-${{ github.run_attempt }} + buildspec-override:true + + steps: + - name: Download coverage artifact + uses: actions/download-artifact@v5 + with: + name: vmclock-test + + - run: ls + - run: echo "Change permissions of artifact." + - run: chmod 755 vmclock-test + - run: echo "Run vmclock test!" + - run: ./vmclock-test diff --git a/Cargo.lock b/Cargo.lock index 321bb1b..c84908a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2217,6 +2217,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "vmclock" +version = "2.0.3" +dependencies = [ + "clock-bound", + "rand 0.9.2", + "tokio", + "tracing-subscriber", +] + [[package]] name = "vmclock-updater" version = "2.0.3" diff --git a/Cargo.toml b/Cargo.toml index 4701ea3..58dcdab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "test/vmclock-updater", "test/clock-bound-adjust-clock", "test/clock-bound-adjust-clock-test", + "test/vmclock" ] resolver = "3" diff --git a/test/vmclock/Cargo.toml b/test/vmclock/Cargo.toml new file mode 100644 index 0000000..8ff3b5c --- /dev/null +++ b/test/vmclock/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "vmclock" +description = "A test program that attempts to sample from the vmclock shared memory file." +license = "Apache-2.0" +publish = false + +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +keywords.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "vmclock-test" +path = "src/main.rs" + +[dependencies] +clock-bound = { version = "2.0", path = "../../clock-bound", features = [ + "daemon", +] } +rand = "0.9.2" +tokio = { version = "1.47.1", features = ["macros", "rt"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/vmclock/Makefile.toml b/test/vmclock/Makefile.toml new file mode 100644 index 0000000..e3eec52 --- /dev/null +++ b/test/vmclock/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in test/vmclock" +''' diff --git a/test/vmclock/README.md b/test/vmclock/README.md new file mode 100644 index 0000000..103dae9 --- /dev/null +++ b/test/vmclock/README.md @@ -0,0 +1,38 @@ +# Test program: vmclock-test + +This directory contains the source code for a test program written to validate +the implementation of the VMCock runner. The VMClock runner loads the VMClock +shared memory file and polls the file for clock disruption events. + + +## Prerequisites + +This program must be run on an AWS instance supports the vmclock device. + +## Building with Cargo + +Run the following command to build the test program. + +```sh +cargo build --release +``` + +## Running the program after a Cargo build + +Run the following commands to run the test program. + +```sh +cd target/release/ +./vmclock-test +``` + + +The output should look something like the following: + +```sh +$ ./vmclock-test + +Attempting to create vmclock runner using /dev/vmclock0 file. +VMClock running initiation and polling test PASSED. + +``` diff --git a/test/vmclock/src/main.rs b/test/vmclock/src/main.rs new file mode 100644 index 0000000..0d2b898 --- /dev/null +++ b/test/vmclock/src/main.rs @@ -0,0 +1,42 @@ +//! VMClock test executable +//! +//! This executable tests the vmclock runner. It tests that the runner is able to access, and load +//! the vmclock shared memory file. + +use clock_bound::daemon::io::SourceIO; +use clock_bound::daemon::io::ntp::DaemonInfo; +use clock_bound::daemon::selected_clock::SelectedClockSource; +use clock_bound::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; + +use rand::{RngCore, rng}; +use std::sync::Arc; +use tokio::time::Duration; +use tracing_subscriber::EnvFilter; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + let daemon_info = DaemonInfo { + major_version: 2, + minor_version: 100, + startup_id: rng().next_u64(), + }; + + let selected_clock = Arc::new(SelectedClockSource::default()); + let mut sourceio = SourceIO::construct(selected_clock.clone(), daemon_info); + + println!("Attempting to create vmclock runner using {VMCLOCK_SHM_DEFAULT_PATH:?} file."); + sourceio + .create_vmclock(VMCLOCK_SHM_DEFAULT_PATH) + .await + .unwrap(); + sourceio.spawn_all(); + + // Allow the runner to poll the shared memory file for a while. + tokio::time::sleep(Duration::from_secs(3)).await; + + println!("VMClock running initiation and polling test PASSED."); +} From 679d272ecdbf0675cef23274ad0a2afd1056a6b9 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 14 Nov 2025 16:10:30 -0500 Subject: [PATCH 122/177] [csa] enable the PHC in the clock sync algorithm (#131) This adds the merged in ff::phc module to the clock sync algorithm struct as an option. Reason for an option is that not every instance type supports the ptp hardware clock. Also add a sanity check unit test using ff-tester --- clock-bound-ff-tester/src/lib.rs | 1 + clock-bound-ff-tester/src/sim_ll.rs | 2 +- clock-bound-ff-tester/src/sim_phc.rs | 185 ++++++++++++++++++ clock-bound/src/daemon.rs | 12 +- .../src/daemon/clock_sync_algorithm.rs | 26 ++- .../src/daemon/clock_sync_algorithm/source.rs | 4 +- .../clock_sync_algorithm/source/ntp_source.rs | 4 +- .../daemon/clock_sync_algorithm/source/phc.rs | 49 +++++ clock-bound/src/daemon/event/phc.rs | 4 +- 9 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 clock-bound-ff-tester/src/sim_phc.rs create mode 100644 clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs diff --git a/clock-bound-ff-tester/src/lib.rs b/clock-bound-ff-tester/src/lib.rs index 1e9fd0c..bee7821 100644 --- a/clock-bound-ff-tester/src/lib.rs +++ b/clock-bound-ff-tester/src/lib.rs @@ -7,3 +7,4 @@ pub mod simulation; pub mod time; pub mod sim_ll; +pub mod sim_phc; diff --git a/clock-bound-ff-tester/src/sim_ll.rs b/clock-bound-ff-tester/src/sim_ll.rs index 682204f..23653c3 100644 --- a/clock-bound-ff-tester/src/sim_ll.rs +++ b/clock-bound-ff-tester/src/sim_ll.rs @@ -106,7 +106,7 @@ fn tester_event_to_link_local(e: &Event) -> RoutableEvent { } // now-ish. Oct 27 2025 -const NOW_ISH: TrueInstant = TrueInstant::from_days(365 * 55 + 299); +pub const NOW_ISH: TrueInstant = TrueInstant::from_days(365 * 55 + 299); #[cfg(test)] mod tests { diff --git a/clock-bound-ff-tester/src/sim_phc.rs b/clock-bound-ff-tester/src/sim_phc.rs new file mode 100644 index 0000000..846e8da --- /dev/null +++ b/clock-bound-ff-tester/src/sim_phc.rs @@ -0,0 +1,185 @@ +//! Code dedicated to generating phc simulations and testing them against clockbound +//! +//! This is a constrained environment where there is only phc data + +use clock_bound::daemon::{ + clock_parameters::ClockParameters, + clock_sync_algorithm::ClockSyncAlgorithm, + event, + receiver_stream::RoutableEvent, + time::{Duration, Instant, tsc::Frequency}, +}; +use statrs::distribution::Dirac; + +use crate::{ + events::{ + Scenario, + v1::{Event, EventKind}, + }, + sim_ll::NOW_ISH, + simulation::{ + self, + delay::{Delay, TimeUnit}, + generator::GeneratorExt, + oscillator::{FullModel, Oscillator}, + phc::round_trip_delays::VariableRoundTripDelays, + }, + time::{CbBridge, EstimateDuration, TrueDuration}, +}; + +pub struct TestPhc { + pub algorithm: ClockSyncAlgorithm, +} + +impl TestPhc { + pub fn new(algorithm: ClockSyncAlgorithm) -> Self { + Self { algorithm } + } + + /// Run a whole scenario against a clock sync algorithm + /// + /// Returns a list of outputs from the feed fn + pub fn run(&mut self, scenario: &Scenario) -> Vec> { + let Scenario::V1(scenario) = scenario; + scenario + .events + .iter() + .map(tester_phc_event_to_cb) + .map(|e| self.algorithm.feed(e.clone()).cloned()) + .collect() + } + + pub fn feed_phc(&mut self, event: &Event) -> Option<&ClockParameters> { + let phc = tester_phc_event_to_cb(event); + self.algorithm.feed(phc) + } +} + +#[expect(clippy::missing_panics_doc, reason = "unwraps won't panic")] +pub fn perfect_symmetric(scenario_duration: TrueDuration) -> Scenario { + let oscillator = Oscillator::create_simple() + .clock_frequency(Frequency::from_ghz(3.3)) + .start_time(NOW_ISH) + .duration(scenario_duration) + .call(); + + let full_model = FullModel::calculate_from_oscillator(oscillator); + + let network_delays = VariableRoundTripDelays::builder() + .backward_network(Box::new(Delay::new( + Dirac::new(2.0).unwrap(), + TimeUnit::Micros, + ))) + .forward_network(Box::new(Delay::new( + Dirac::new(2.0).unwrap(), + TimeUnit::Micros, + ))) + .build(); + + let clock_error_bound = Box::new(Delay::new(Dirac::new(20.0).unwrap(), TimeUnit::Micros)); + + let mut generator = simulation::phc::variable_delay_source::Generator::builder() + .poll_period(EstimateDuration::from_millis(500)) + .id("phc".into()) + .oscillator(&full_model) + .network_delays(network_delays) + .clock_error_bounds(clock_error_bound) + .build(); + + generator.create_scenario(full_model) +} + +fn tester_phc_event_to_cb(e: &Event) -> RoutableEvent { + let EventKind::Phc(p) = &e.variants else { + panic!("Phc event expected") + }; + + let event = event::Phc::builder() + .tsc_pre(e.client_tsc_pre_time) + .tsc_post(e.client_tsc_post_time) + .data(event::PhcData { + time: Instant::from_estimate(p.phc_time), + clock_error_bound: Duration::from_estimate(p.clock_error_bound.unwrap()), + }) + .build() + .unwrap(); + + RoutableEvent::Phc(event) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use clock_bound::daemon::{ + clock_sync_algorithm::{ + Selector, + source::{self, LinkLocal}, + }, + selected_clock::SelectedClockSource, + time::tsc::Skew, + }; + + use super::*; + + #[test] + fn perfect_does_not_panic() { + let skew = Skew::from_ppm(15.0); + let mut tester = TestPhc::new( + ClockSyncAlgorithm::builder() + .link_local(LinkLocal::new(skew)) + .ntp_sources(vec![]) + .phc(source::Phc::new("path".into(), skew)) + .selected_clock(Arc::new(SelectedClockSource::default())) + .selector(Selector::new(skew)) + .build(), + ); + + let scenario = perfect_symmetric(TrueDuration::from_secs(1024)); + let _ = tester.run(&scenario); + } + + #[test] + fn alg_is_perfect_under_perfect_conditions() { + let skew = Skew::from_ppm(15.0); + let mut tester = TestPhc::new( + ClockSyncAlgorithm::builder() + .link_local(LinkLocal::new(skew)) + .ntp_sources(vec![]) + .phc(source::Phc::new("path".into(), skew)) + .selected_clock(Arc::new(SelectedClockSource::default())) + .selector(Selector::new(skew)) + .build(), + ); + let scenario = perfect_symmetric(TrueDuration::from_secs(1024)); + let Scenario::V1(scenario) = &scenario; + let mut events = scenario.events.iter().enumerate(); + let (_, first_event) = events.next().unwrap(); + let first_output = tester.feed_phc(first_event); + assert!(first_output.is_none()); + + for (idx, event) in events { + let output = tester.feed_phc(event); + let param = output.unwrap(); + + let tsc_midpoint = event + .client_tsc_pre_time + .midpoint(event.client_tsc_post_time); + let EventKind::Phc(phc) = &event.variants else { + panic!("Expected PHC event, found {event:?}"); + }; + + assert_eq!(param.tsc_count, tsc_midpoint, "Failure at idx {idx}"); + assert!( + approx::abs_diff_eq!( + param.time.into_estimate().as_picos() as i64, + phc.phc_time.as_picos() as i64, + epsilon = 10 + ), + "Failure at idx {idx}. parameters not expected: {param:#?}\nexpected {}\ngot{}", + phc.phc_time.get(), + param.time.get() + ); + } + } +} diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index a047aa4..cb5db53 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -23,11 +23,9 @@ pub mod selected_clock; use std::sync::Arc; use crate::daemon::{ - clock_sync_algorithm::{ClockSyncAlgorithm, Selector, source::NTPSource}, - io::{ - ClockDisruptionEvent, - ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, - }, + clock_sync_algorithm::{ClockSyncAlgorithm, Selector, source::NtpSource}, + io::ClockDisruptionEvent, + io::ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, receiver_stream::{ReceiverStream, RoutableEvent}, selected_clock::SelectedClockSource, time::tsc::Skew, @@ -96,7 +94,7 @@ impl Daemon { MAX_DISPERSION_GROWTH, )) .ntp_sources( - clock_sync_algorithm::source::NTPSource::create_time_aws_sources( + clock_sync_algorithm::source::NtpSource::create_time_aws_sources( MAX_DISPERSION_GROWTH, ), ) @@ -242,7 +240,7 @@ impl Daemon { /// > 2. `receiver_vec`: Vector of `NTPSourceReceiver` fn init_ntp_source_buffers( ntp_source_buffer_size: usize, - ntp_source_vec: &Vec, + ntp_source_vec: &Vec, ) -> (Vec, Vec) { let mut sender_vec: Vec = Vec::new(); let mut receiver_vec: Vec = Vec::new(); diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 636d392..7a53e47 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -42,7 +42,11 @@ pub struct ClockSyncAlgorithm { /// The link-local reference clock's ff algorithm link_local: source::LinkLocal, // A Vector of ff algorithms for ntp source reference clocks - pub ntp_sources: Vec, + pub ntp_sources: Vec, + /// The PHC device + /// + /// Optional since not every instance supports this + phc: Option, /// Shared reference to the current selected clock source selected_clock: Arc, /// Selector. Chooses the best clock source @@ -98,8 +102,10 @@ impl ClockSyncAlgorithm { RoutableEvent::NtpSource(sender_address, event) => { Self::feed_ntp_source(&mut self.ntp_sources, sender_address, event) } - RoutableEvent::Phc(_data) => { - todo!("Implement PHC IO event delivery") + RoutableEvent::Phc(event) => { + // unwrap: feeding a phc event without PHC built is a bug + let phc = self.phc.as_mut().unwrap(); + Self::feed_phc(phc, event) } }; let (clock_parameters, source_info) = alg_output?; @@ -141,7 +147,7 @@ impl ClockSyncAlgorithm { /// Feed event into ntp source fn feed_ntp_source( - ntp_sources: &mut [source::NTPSource], + ntp_sources: &mut [source::NtpSource], sender_address: SocketAddr, event: event::Ntp, ) -> Option<(&ClockParameters, SourceInfo)> { @@ -154,6 +160,14 @@ impl ClockSyncAlgorithm { .map(|params| (params, SourceInfo::NtpSource(sender_address, stratum))) } + /// Feed event into the phc + fn feed_phc( + phc: &mut source::Phc, + event: event::Phc, + ) -> Option<(&ClockParameters, SourceInfo)> { + phc.feed(event).map(|params| (params, SourceInfo::Phc)) + } + fn update_selected_clock(selected_clock: &Arc, source_info: SourceInfo) { // associated method to help borrow checker match source_info { @@ -179,6 +193,7 @@ impl ClockSyncAlgorithm { let Self { link_local, ntp_sources, + phc, selected_clock, selector, } = self; @@ -188,6 +203,9 @@ impl ClockSyncAlgorithm { for source in ntp_sources { source.handle_disruption(); } + if let Some(phc) = phc { + phc.handle_disruption(); + } selector.handle_disruption(); tracing::info!("Handled clock disruption event"); } diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source.rs b/clock-bound/src/daemon/clock_sync_algorithm/source.rs index 1385e93..af577d4 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source.rs @@ -9,6 +9,8 @@ mod link_local; mod ntp_source; +mod phc; pub use link_local::LinkLocal; -pub use ntp_source::NTPSource; +pub use ntp_source::NtpSource; +pub use phc::Phc; diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs index eaf891b..1b9222a 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs @@ -13,12 +13,12 @@ use crate::daemon::time::tsc::Skew; /// /// Wraps around an NTP feed-forward clock-sync algorithm #[derive(Debug, Clone, PartialEq)] -pub struct NTPSource { +pub struct NtpSource { source_address: SocketAddr, inner: ff::Ntp, } -impl NTPSource { +impl NtpSource { const POLL_INTERVAL: Duration = Duration::from_secs(16); /// Create a new NTP Source reference clock source diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs new file mode 100644 index 0000000..84f792e --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs @@ -0,0 +1,49 @@ +//! PHC source + +use std::path::PathBuf; + +use crate::daemon::{ + clock_parameters::ClockParameters, + clock_sync_algorithm::ff, + event, + time::{Duration, tsc::Skew}, +}; + +/// A PHC from the Amazon Time Sync Service +/// +/// Wraps around a PHC feed-forward clock sync algorithm +#[derive(Debug, Clone, PartialEq)] +pub struct Phc { + device_path: PathBuf, + inner: ff::Phc, +} + +impl Phc { + const POLL_INTERVAL: Duration = Duration::from_millis(500); + + /// Create a new PHC reference clock source + pub fn new(device_path: PathBuf, max_dispersion: Skew) -> Self { + Self { + device_path, + inner: ff::Phc::new(Self::POLL_INTERVAL, max_dispersion), + } + } + + /// Feed an event into the PHC clock sync algorithm + #[tracing::instrument(level = "info", skip_all, fields(source = %self.device_path.display()))] + pub fn feed(&mut self, event: event::Phc) -> Option<&ClockParameters> { + self.inner.feed(event) + } + + /// Handle a disruption event + /// + /// This clears the internal `ff` and any other state related to the local system's hardware + pub fn handle_disruption(&mut self) { + // Destructure pattern makes handling new fields mandatory + let Self { + device_path: _, + inner, + } = self; + inner.handle_disruption(); + } +} diff --git a/clock-bound/src/daemon/event/phc.rs b/clock-bound/src/daemon/event/phc.rs index 3dd59da..e286b0c 100644 --- a/clock-bound/src/daemon/event/phc.rs +++ b/clock-bound/src/daemon/event/phc.rs @@ -88,12 +88,12 @@ impl Phc { /// Calculate the clock error bound of this event at the time of the event /// - /// This is different from the clock error bound that would be reported to a user outside of hte daemon. + /// This is different from the clock error bound that would be reported to a user outside of the daemon. /// /// First because this is a sans-IO input, there is not concept of reading this "after" the event comes in. /// Because of this, there is no additional value added to the root-dispersion. /// - /// Second, the round trip time needs a calculations of hte period to be able to convert hte TSC rtt into a + /// Second, the round trip time needs a calculations of the period to be able to convert the TSC rtt into a /// duration of time. /// /// Third, there is no "system clock offset" value. That is a parameter exclusive to modifying the system clock, which this component does not do. From 6c92d7617f2b4a9ef0934cd5e0dcd7ce6e80f478 Mon Sep 17 00:00:00 2001 From: Ryan Luu Date: Fri, 14 Nov 2025 17:37:33 -0500 Subject: [PATCH 123/177] [phc] Update PHC IO source polling interval from 1 second to 500 milliseconds. (#144) * [phc] Update PHC IO source polling interval from 1 second to 500 milliseconds. * Update the `phc-test` program to validate this updated polling interval. * Small fix to phc-test README.md. --- clock-bound/src/daemon/io/phc.rs | 2 +- test/phc/README.md | 21 ++++++++++++------ test/phc/src/main.rs | 37 ++++++++++++++++++++++++-------- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/clock-bound/src/daemon/io/phc.rs b/clock-bound/src/daemon/io/phc.rs index 17017e5..8b7194b 100644 --- a/clock-bound/src/daemon/io/phc.rs +++ b/clock-bound/src/daemon/io/phc.rs @@ -22,7 +22,7 @@ use tokio::{ }; use tracing::{debug, error, info, warn}; -const PHC_SOURCE_INTERVAL_DURATION: std::time::Duration = tokio::time::Duration::from_secs(1); +const PHC_SOURCE_INTERVAL_DURATION: std::time::Duration = tokio::time::Duration::from_millis(500); /// Timeout for a single read of the PHC device. const PHC_SOURCE_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_millis(20); diff --git a/test/phc/README.md b/test/phc/README.md index cc7baa9..38197c1 100644 --- a/test/phc/README.md +++ b/test/phc/README.md @@ -45,24 +45,33 @@ The output should look something like the following: ```sh [ec2-user@ip-172-31-31-154 ~]$ ./phc-test -Lets get a PHC sample! -It looks like we got an PHC sample +Lets get PHC samples! +PHC event received. Ok( Phc { tsc_pre: TscCount( - 834121827837145, + 1425415815539165, ), tsc_post: TscCount( - 834121827896745, + 1425415815545741, ), data: PhcData { time: Instant( - 1762897830.385_289_585, + 1763144203.339_967_033, ), clock_error_bound: Duration( - 0.000_016_998, + 0.000_016_188, ), }, }, ) +500 msec + +[...] + +Expected poll interval duration: 500ms +Actual poll interval duration (i.e. measured amount): 499.986555ms +Margin of error allowed for poll interval duration: 30ms +Poll interval duration difference between actual and expected: 13.445µs +PHC test is successful. ``` diff --git a/test/phc/src/main.rs b/test/phc/src/main.rs index 56a8010..4a03140 100644 --- a/test/phc/src/main.rs +++ b/test/phc/src/main.rs @@ -1,7 +1,7 @@ //! PHC test executable. //! //! This executable tests that the PHC runner is able to read timestamps and error bounds, -//! and that the polling rate is roughly once a second. +//! and that the rate of receiving PHC events closely matches the expected PHC polling rate. use clock_bound::daemon::io::SourceIO; use clock_bound::daemon::selected_clock::SelectedClockSource; @@ -19,11 +19,9 @@ async fn main() { .with_env_filter(EnvFilter::from_default_env()) .init(); - println!("Lets get a PHC sample!"); + println!("Lets get PHC samples!"); let (phc_sender, phc_receiver) = async_ring_buffer::create(1); - let mut start = time::Instant::now(); - let daemon_info = DaemonInfo { major_version: 2, minor_version: 100, @@ -35,9 +33,9 @@ async fn main() { sourceio.spawn_all(); let max_events_to_receive = 11; - let mut total_polling_duration = time::Duration::from_secs(0); let mut num_events_received = 0; + let mut start = time::Instant::now(); for i in 0..max_events_to_receive { // PHC events are expected to be received periodically. @@ -50,7 +48,7 @@ async fn main() { let now = time::Instant::now(); let duration = now - start; println!( - "It looks like we got an PHC sample \n{phc_event:#?}\n{:?} msec", + "PHC event received.\n{phc_event:#?}\n{:?} msec", duration.as_millis() ); @@ -62,7 +60,28 @@ async fn main() { start = now; } - let polling_rate = total_polling_duration / num_events_received; - println!("Polling rate avg: {polling_rate:?}"); - assert!(polling_rate.abs_diff(time::Duration::from_secs(1)) < time::Duration::from_millis(100)); + + let expected_poll_interval_duration = time::Duration::from_millis(500); + println!("Expected poll interval duration: {expected_poll_interval_duration:?}"); + + let actual_poll_interval_duration = total_polling_duration / num_events_received; + println!("Actual poll interval duration (i.e. measured): {actual_poll_interval_duration:?}"); + + let poll_interval_duration_margin_of_error_allowed = time::Duration::from_millis(30); + println!( + "Margin of error allowed for poll interval duration: {poll_interval_duration_margin_of_error_allowed:?}" + ); + + let poll_interval_difference_from_expected = + actual_poll_interval_duration.abs_diff(expected_poll_interval_duration); + println!( + "Poll interval duration difference between actual and expected: {poll_interval_difference_from_expected:?}" + ); + + assert!( + poll_interval_difference_from_expected < poll_interval_duration_margin_of_error_allowed + ); + + // Test is successful because all the assertions passed. + println!("PHC test is successful."); } From 3dc1f18b5d23cb07642b8150a01bb1a797e5bac8 Mon Sep 17 00:00:00 2001 From: Ryan Luu Date: Fri, 14 Nov 2025 17:39:40 -0500 Subject: [PATCH 124/177] [phc] GitHub action workflow for running phc-test program (#137) - Add GitHub action workflow that runs the `phc-test` program. - Remove the old "Hello Phc" workflow that existed previously because this new workflow preforms the same functionality and more. --- .github/workflows/hello_phc.yml | 17 --------- .github/workflows/phc.yml | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 17 deletions(-) delete mode 100644 .github/workflows/hello_phc.yml create mode 100644 .github/workflows/phc.yml diff --git a/.github/workflows/hello_phc.yml b/.github/workflows/hello_phc.yml deleted file mode 100644 index 2945cbf..0000000 --- a/.github/workflows/hello_phc.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Hello Phc - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - Hello-Phc: - runs-on: - - codebuild-StagingClockBound-${{ github.run_id }}-${{ github.run_attempt }} - buildspec-override:true - steps: - - run: echo "Hello World!" - - run: ls /dev/ - - run: ls /dev/ptp_ena \ No newline at end of file diff --git a/.github/workflows/phc.yml b/.github/workflows/phc.yml new file mode 100644 index 0000000..e364d1a --- /dev/null +++ b/.github/workflows/phc.yml @@ -0,0 +1,67 @@ +name: PHC + +permissions: + contents: read + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run cargo build + run: cargo build --bin phc-test --release + + - name: Upload phc-test artifact + uses: actions/upload-artifact@v4 + with: + name: phc-test + path: target/release/phc-test + + PHC_Tests: + name: PHC Tests + needs: build + runs-on: + - codebuild-StagingClockBound-${{ github.run_id }}-${{ github.run_attempt }} + buildspec-override:true + + steps: + - name: Download coverage artifact + uses: actions/download-artifact@v5 + with: + name: phc-test + + - run: echo "Getting details about the host environment." + - run: whoami + - run: cat /etc/os-release + - run: uname -a + - run: lsmod + - run: ls -al /dev/ + - run: ip addr + + - run: echo "Validating that the host has an ENA driver installed." + - run: lsmod | grep ena + + - run: echo "Validating that the host has a PTP device." + - run: ls -al /dev/ptp* + + - run: echo "Changing permissions of PTP devices to be readable by all." + - run: sudo chmod a+r /dev/ptp* + - run: ls -al /dev/ptp* + + - run: echo "Changing permissions of the test artifact to be executable." + - run: chmod a+x phc-test + + - run: echo "Running the PHC test!" + - run: ./phc-test From 1da346dd863395714f65d187314edbc1ff48178d Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Fri, 14 Nov 2025 15:03:49 -0800 Subject: [PATCH 125/177] Use an enum to map ClockErrorBound layout version (#146) This patch changes the builder of the ClockErrorBound to take an enum as a parameter (instead of a u16). Failures to parse a supported version from the shared memory segment are delegated to the TryFrom conversion. --- clock-bound-ffi/src/lib.rs | 4 +-- clock-bound/src/client.rs | 10 +++--- .../daemon/clock_state/clock_state_writer.rs | 14 ++++----- clock-bound/src/shm.rs | 31 ++++++++++++++++--- clock-bound/src/shm/reader.rs | 8 ++--- clock-bound/src/shm/writer.rs | 5 ++- 6 files changed, 46 insertions(+), 26 deletions(-) diff --git a/clock-bound-ffi/src/lib.rs b/clock-bound-ffi/src/lib.rs index 3665bd2..4869f78 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -344,7 +344,7 @@ pub unsafe extern "C" fn clockbound_now( mod t_ffi { use super::*; use byteorder::{LittleEndian, NativeEndian, WriteBytesExt}; - use clock_bound::shm::{ClockErrorBound, ClockErrorBoundGeneric}; + use clock_bound::shm::{ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion}; use std::ffi::CString; use std::fs::OpenOptions; use std::io::Write; @@ -365,7 +365,7 @@ mod t_ffi { $version:literal, $generation:literal) => { // Build a default ClockErrorBound struct (layout version 2) - let ceb = ClockErrorBoundGeneric::builder().build(2).unwrap(); + let ceb = ClockErrorBoundGeneric::builder().build(ClockErrorBoundLayoutVersion::V2); // Convert the ceb struct into a slice so we can write it all out, fairly magic. // Definitely needs the #[repr(C)] layout. diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index 33e2525..9ebcd38 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -270,8 +270,8 @@ pub enum ClockBoundErrorKind { #[cfg(test)] mod lib_tests { use super::*; - use crate::shm::ClockErrorBoundGeneric; use crate::shm::{ClockErrorBound, ShmWrite, ShmWriter}; + use crate::shm::{ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion}; use crate::vmclock::shm::{VMClockClockStatus, VMClockShmBody}; use crate::vmclock::shm_writer::{VMClockShmWrite, VMClockShmWriter}; @@ -299,8 +299,7 @@ mod lib_tests { // Build a default ClockErrorBound layout version 2 let ceb = ClockErrorBoundGeneric::builder() .clock_disruption_support_enabled(true) - .build(2) - .unwrap(); + .build(ClockErrorBoundLayoutVersion::V2); // Convert the ceb struct into a slice so we can write it all out, fairly magic. // Definitely needs the #[repr(C)] layout. @@ -690,7 +689,7 @@ mod lib_tests { let mut writer = ShmWriter::new(Path::new(clockbound_shm_path)).expect("Failed to create a writer"); - let ceb = ClockErrorBoundGeneric::builder().build(2).unwrap(); + let ceb = ClockErrorBoundGeneric::builder().build(ClockErrorBoundLayoutVersion::V2); writer.write(&ceb); let mut clockbound = @@ -714,8 +713,7 @@ mod lib_tests { .max_drift_ppb(1_000_000_000) .clock_status(ClockStatus::Synchronized) .clock_disruption_support_enabled(true) - .build(2) - .unwrap(); + .build(ClockErrorBoundLayoutVersion::V2); writer.write(&ceb); // Validate now has Result with an error. diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index c5bf3b9..d70d95f 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -10,7 +10,10 @@ use crate::{ instant::Utc, }, }, - shm::{ClockErrorBound, ClockErrorBoundGeneric, ClockStatus, ShmWrite, ShmWriter}, + shm::{ + ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ClockStatus, + ShmWrite, ShmWriter, + }, }; /// The drift rate/maximal frequency error in parts-per-billion @@ -147,11 +150,9 @@ impl ClockStateWriter { ) .clock_status(clock_status) .clock_disruption_support_enabled(self.clock_disruption_support_enabled) - .build(2); + .build(ClockErrorBoundLayoutVersion::V2); - if let Ok(ceb) = ceb { - self.shm_writer.write(&ceb); - } + self.shm_writer.write(&ceb); } } @@ -234,8 +235,7 @@ mod tests { .max_drift_ppb(max_drift_ppb) .clock_status(clock_status) .clock_disruption_support_enabled(clock_disruption_support_enabled) - .build(2) - .unwrap(); + .build(ClockErrorBoundLayoutVersion::V2); let mut shm_writer = MockShmWriter::new(); shm_writer .expect_write() diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 95496aa..934ebc7 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -147,14 +147,13 @@ impl ClockErrorBoundGenericBui /// /// Take the layout version number as a parameter, it is a u16 to ease casting of the earlier /// version of the `SHMHeader`. - #[expect(clippy::missing_errors_doc, reason = "todo")] - pub fn build(self, layout_version: u16) -> Result { + pub fn build(self, layout_version: ClockErrorBoundLayoutVersion) -> ClockErrorBound { // Build the ClockErrorBoundGeneric object let ceb = self.build_internal(); // Build the specific version of the ClockErrorBound match layout_version { - 2 => Ok(ClockErrorBound::V2(ClockErrorBoundV2::new( + ClockErrorBoundLayoutVersion::V2 => ClockErrorBound::V2(ClockErrorBoundV2::new( ceb.as_of, ceb.void_after, ceb.bound_nsec, @@ -162,7 +161,31 @@ impl ClockErrorBoundGenericBui ceb.max_drift_ppb, ceb.clock_status, ceb.clock_disruption_support_enabled, - ))), + )), + } + } +} + +#[derive(Copy, Clone)] +pub enum ClockErrorBoundLayoutVersion { + V2, +} + +impl TryFrom for ClockErrorBoundLayoutVersion { + type Error = ShmError; + fn try_from(value: u8) -> Result { + match value { + 2 => Ok(ClockErrorBoundLayoutVersion::V2), + _ => Err(ShmError::SegmentVersionNotSupported), + } + } +} + +impl TryFrom for ClockErrorBoundLayoutVersion { + type Error = ShmError; + fn try_from(value: u16) -> Result { + match value { + 2 => Ok(ClockErrorBoundLayoutVersion::V2), _ => Err(ShmError::SegmentVersionNotSupported), } } diff --git a/clock-bound/src/shm/reader.rs b/clock-bound/src/shm/reader.rs index c64c581..43a39fc 100644 --- a/clock-bound/src/shm/reader.rs +++ b/clock-bound/src/shm/reader.rs @@ -8,7 +8,7 @@ use std::sync::atomic; use crate::shm::shm_header::{CLOCKBOUND_SHM_SUPPORTED_VERSION, ShmHeader}; use crate::{ - shm::{ClockErrorBound, ClockErrorBoundGeneric, ShmError}, + shm::{ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ShmError}, syserror, }; @@ -188,6 +188,7 @@ impl ShmReader { // SAFETY: `self.version` has been validated when creating the reader let shm_version = unsafe { &*version }; let shm_version = shm_version.load(atomic::Ordering::Acquire); + let shm_version = ClockErrorBoundLayoutVersion::try_from(shm_version)?; Ok(ShmReader { _marker: std::marker::PhantomData, @@ -195,7 +196,7 @@ impl ShmReader { version, generation, ceb_shm, - snapshot_ceb: ClockErrorBoundGeneric::builder().build(shm_version)?, + snapshot_ceb: ClockErrorBoundGeneric::builder().build(shm_version), snapshot_gen: 0, }) } @@ -340,8 +341,7 @@ mod t_reader { .max_drift_ppb($max_drift) .clock_status(ClockStatus::Synchronized) .clock_disruption_support_enabled(true) - .build(2) - .unwrap(); + .build(ClockErrorBoundLayoutVersion::V2); // Convert the ceb struct into a slice so we can write it all out, fairly magic. // Definitely needs the #[repr(C)] layout. diff --git a/clock-bound/src/shm/writer.rs b/clock-bound/src/shm/writer.rs index 2ad725a..4740400 100644 --- a/clock-bound/src/shm/writer.rs +++ b/clock-bound/src/shm/writer.rs @@ -312,7 +312,7 @@ mod t_writer { /// afterwards. use tempfile::NamedTempFile; - use crate::shm::{ClockErrorBoundGeneric, ClockStatus}; + use crate::shm::{ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ClockStatus}; macro_rules! clockerrorbound { () => { @@ -324,8 +324,7 @@ mod t_writer { .max_drift_ppb(100) .clock_status(ClockStatus::Synchronized) .clock_disruption_support_enabled(true) - .build(2) - .unwrap() + .build(ClockErrorBoundLayoutVersion::V2) }; } From 7a45a5565af12f271536b050737eaca946e6a696 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Mon, 17 Nov 2025 12:34:08 -0500 Subject: [PATCH 126/177] fixing link local integration test flaky-ness (#151) link local integration tests are flaky because each sub test spawned a new link local runner. The frequency with which these runners hit the link local address infrequently pushed the polling rate above the throttling threshold. This commit refactored the integration test so that only 1 runner is constructed. --- test/link-local/src/main.rs | 57 ++++++++++++++----------------------- 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index afdda62..6b9fef6 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -35,10 +35,11 @@ async fn main() { .with_env_filter(EnvFilter::from_default_env()) .init(); - test_normal_polling_rate().await; - test_startup_polling_rate().await; - test_clock_disruption_polling_rate().await; - test_polls_with_selected_clock_source_combos().await; + let (receiver, selected_clock, mut sourceio) = setup().await; + test_startup_polling_rate(&receiver).await; + test_normal_polling_rate(&receiver).await; + test_clock_disruption_polling_rate(&receiver, &mut sourceio).await; + test_polls_with_selected_clock_source_combos(&receiver, &selected_clock).await; } /// Set up link local source io for testing @@ -60,30 +61,20 @@ async fn setup() -> (Receiver, Arc, SourceIO) { } /// Set up link local source io for testing -async fn setup_with_vmclock( - vmclock_shm_path: &str, -) -> (Receiver, Arc, SourceIO) { - let (link_local_receiver, selected_clock, mut sourceio) = setup().await; - +async fn setup_with_vmclock(vmclock_shm_path: &str, sourceio: &mut SourceIO) { sourceio.create_vmclock(vmclock_shm_path).await.unwrap(); sourceio.spawn_all(); - - (link_local_receiver, selected_clock, sourceio) } /// Test normal polling -async fn test_normal_polling_rate() { +async fn test_normal_polling_rate(receiver: &Receiver) { println!("Testing normal polling rate ..."); - let (receiver, _selected_clock, _sourceio) = setup().await; - - // Wait for burst mode to end before testing normal polling rate - tokio::time::sleep(Duration::from_secs(3)).await; - // Clear any burst mode packets from buffer + // Clear any previous packets from buffer while let Ok(Ok(_)) = timeout(Duration::from_millis(100), receiver.recv()).await {} // Poll the link local receiver - let polling_rate = measure_polling_rate(&receiver, Duration::from_secs(20)).await; + let polling_rate = measure_polling_rate(receiver, Duration::from_secs(20)).await; println!("Polling rate avg: {polling_rate:?}"); assert!(polling_rate.abs_diff(Duration::from_secs(2)) < time::Duration::from_millis(100)); @@ -91,10 +82,9 @@ async fn test_normal_polling_rate() { } /// Test startup polling -async fn test_startup_polling_rate() { +async fn test_startup_polling_rate(receiver: &Receiver) { println!("Testing startup polling rate ..."); - let (receiver, _selected_clock, _sourceio) = setup().await; - let polling_rate = measure_polling_rate(&receiver, time::Duration::from_secs(1)).await; + let polling_rate = measure_polling_rate(receiver, time::Duration::from_secs(1)).await; println!("Burst Polling rate avg: {polling_rate:?}"); assert!( @@ -105,7 +95,7 @@ async fn test_startup_polling_rate() { } /// Test clock disruption polling -async fn test_clock_disruption_polling_rate() { +async fn test_clock_disruption_polling_rate(receiver: &Receiver, sourceio: &mut SourceIO) { println!("Testing normal polling rate ..."); let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); @@ -118,16 +108,13 @@ async fn test_clock_disruption_polling_rate() { let mut vmclock_content = VMClockContent::default(); write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); - let (receiver, _selected_clock, _sourceio) = setup_with_vmclock(vmclock_shm_path).await; + setup_with_vmclock(vmclock_shm_path, sourceio).await; - // Wait for burst mode, initiated at startup to end - tokio::time::sleep(Duration::from_secs(3)).await; - - // Clear any burst mode packets from buffer + // Clear any previous packets from buffer while let Ok(Ok(_)) = timeout(Duration::from_millis(100), receiver.recv()).await {} // Base line polling rate. - let pre_base_line_polling_rate = measure_polling_rate(&receiver, Duration::from_secs(10)).await; + let pre_base_line_polling_rate = measure_polling_rate(receiver, Duration::from_secs(10)).await; println!("Base line Polling rate avg: {pre_base_line_polling_rate:?}"); // Simulate a clock disruption event @@ -140,8 +127,8 @@ async fn test_clock_disruption_polling_rate() { write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); // Clock disruption polling rate - let polling_duration = Duration::from_millis(950) - write_time.elapsed().unwrap(); - let clock_disruption_polling_rate = measure_polling_rate(&receiver, polling_duration).await; + let polling_duration = Duration::from_secs(1) - write_time.elapsed().unwrap(); + let clock_disruption_polling_rate = measure_polling_rate(receiver, polling_duration).await; println!("Clock disruption polling rate avg: {clock_disruption_polling_rate:?}"); println!("Clock disruption polling duration: {polling_duration:?}"); assert!( @@ -150,8 +137,7 @@ async fn test_clock_disruption_polling_rate() { ); // Base line polling rate. - let post_base_line_polling_rate = - measure_polling_rate(&receiver, Duration::from_secs(10)).await; + let post_base_line_polling_rate = measure_polling_rate(receiver, Duration::from_secs(10)).await; println!("Base line Polling rate avg: {post_base_line_polling_rate:?}"); // The pre and post clock disruption event polling rates should be the same. @@ -200,7 +186,10 @@ async fn measure_polling_rate(receiver: &Receiver, sampling_period: Duratio } /// Test polling with varied selected clock source and stratum combinations -async fn test_polls_with_selected_clock_source_combos() { +async fn test_polls_with_selected_clock_source_combos( + receiver: &Receiver, + selected_clock: &SelectedClockSource, +) { println!("Testing polling with all selected clock source combinations ..."); let combinations = generate_selected_clock_combos(); @@ -209,8 +198,6 @@ async fn test_polls_with_selected_clock_source_combos() { combinations.len() ); - let (receiver, selected_clock, _sourceio) = setup().await; - for (source, stratum) in combinations { match source.clone() { ClockSource::Init => {} // Default state From cb77425a57fec1df68f70da25ba240b83b2d033c Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:12:05 -0500 Subject: [PATCH 127/177] update the daemon to create and use PHC This commit updates the top-level daemon logic to attempt to create and use a PHC device. If the PHC IO creation fails, we treat it as non-fatal and continue initialization and startup of the daemon without this source (excluding it from the clock sync algorithm). --- clock-bound/src/daemon.rs | 64 +++++++++++++++++++++++++----------- clock-bound/src/daemon/io.rs | 5 +++ 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index cb5db53..d765fb7 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -39,7 +39,7 @@ use tokio_util::task::TaskTracker; use rand::{RngCore, rng}; use tokio::sync::watch; use tokio_util::sync::CancellationToken; -use tracing::info; +use tracing::{info, warn}; /// The maximum dispersion growth every second /// @@ -89,39 +89,63 @@ impl Daemon { }; let selected_clock = Arc::new(SelectedClockSource::default()); + + // Initialize IO components. + let mut io_front_end = io::SourceIO::construct(selected_clock.clone(), daemon_info); + let clock_disruption_receiver = io_front_end.clock_disruption_receiver(); + + // Initialize link-local event buffer and IO component. + let (link_local_tx, link_local_rx) = async_ring_buffer::create(2); + io_front_end.create_link_local(link_local_tx).await; + + // Initialize ntp source event buffer and IO component. + let ntp_sources = + clock_sync_algorithm::source::NtpSource::create_time_aws_sources(MAX_DISPERSION_GROWTH); + let (ntp_source_event_senders, ntp_source_event_receivers) = + Self::init_ntp_source_buffers(2, &ntp_sources); + for source in ntp_source_event_senders { + io_front_end.create_ntp_source(source).await; + } + + // Initialize PHC event buffer and IO component. + let (phc_tx, phc_rx) = async_ring_buffer::create(2); + io_front_end.create_phc(phc_tx).await; + + // Note: Failure to create a PHC IO component here is considered non-fatal, + // we will continue without using the device as an clock sync input. + let phc = if io_front_end.phc_exists() { + Some(clock_sync_algorithm::source::Phc::new( + std::path::PathBuf::new(), //TODO: propagate actual device path. + MAX_DISPERSION_GROWTH, + )) + } else { + warn!("PHC source failed creation. Continuing without it."); + None + }; + let phc_rx = if io_front_end.phc_exists() { + Some(phc_rx) + } else { + None + }; + + // Initialize clock sync algorithm. let clock_sync_algorithm = ClockSyncAlgorithm::builder() .link_local(clock_sync_algorithm::source::LinkLocal::new( MAX_DISPERSION_GROWTH, )) - .ntp_sources( - clock_sync_algorithm::source::NtpSource::create_time_aws_sources( - MAX_DISPERSION_GROWTH, - ), - ) + .ntp_sources(ntp_sources) + .maybe_phc(phc) .selected_clock(selected_clock.clone()) .selector(Selector::new(MAX_DISPERSION_GROWTH)) .build(); - // Initializing async ring buffers for IO event delivery - let (link_local_tx, link_local_rx) = async_ring_buffer::create(2); - let (ntp_source_event_senders, ntp_source_event_receivers) = - Self::init_ntp_source_buffers(2, &clock_sync_algorithm.ntp_sources); - // Initializing receiver stream with IO ring buffer receivers let receiver_stream: ReceiverStream = ReceiverStream::builder() .link_local(link_local_rx) .ntp_sources(ntp_source_event_receivers.into_iter().collect()) + .maybe_phc(phc_rx) .build(); - let mut io_front_end = io::SourceIO::construct(selected_clock.clone(), daemon_info); - let clock_disruption_receiver = io_front_end.clock_disruption_receiver(); - - // Initialize IO components. - io_front_end.create_link_local(link_local_tx).await; - for source in ntp_source_event_senders { - io_front_end.create_ntp_source(source).await; - } - Self { io_front_end, clock_sync_algorithm, diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 7111ce6..b3b3e39 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -184,6 +184,11 @@ impl SourceIO { info!("Source PHC update complete."); } + /// Returns true if a PHC source exists (has been created). + pub fn phc_exists(&self) -> bool { + self.phc.is_some() + } + /// Initializes the IO task for sampling the VMClock shared memory file. /// /// # Errors From c4a46539fa3427d4b6a2fdf3f99b409ac3dcfcc7 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:05:12 -0500 Subject: [PATCH 128/177] add missing phc shutdown handler The shutdown handler and signal was missing from the PHC IO component. This commit adds these to restore graceful shutdown for this component. --- clock-bound/src/daemon/io.rs | 12 ++++++++++++ clock-bound/src/daemon/io/phc.rs | 14 +++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index b3b3e39..3d23fab 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -318,6 +318,18 @@ impl SourceIO { } } + // Shutdown PHC source + if let Some(Source { + state: _, + ctrl_sender, + }) = &mut self.phc + { + match ctrl_sender.send(ControlRequest::Shutdown).await { + Ok(()) => info!("Successfully sent shutdown signal to phc source."), + Err(e) => warn!(?e, "Failed to send shutdown signal to phc source."), + } + } + // Shutdown vmclock source if let Some(Source { state: _, diff --git a/clock-bound/src/daemon/io/phc.rs b/clock-bound/src/daemon/io/phc.rs index 8b7194b..87c7a02 100644 --- a/clock-bound/src/daemon/io/phc.rs +++ b/clock-bound/src/daemon/io/phc.rs @@ -533,10 +533,18 @@ impl Phc { // Clock Disruption logic here Self::handle_disruption(); } - _ = self.ctrl_receiver.recv() => { + ctrl_req = self.ctrl_receiver.recv() => { // Ctrl logic here. - // Currently we breakout of the loop if we receive a control event. - break; + match ctrl_req { + None => { + // this select can happen if `SourceIO` drops the ctrl_sender + break; + }, + Some(ControlRequest::Shutdown) => { + info!("Received shutdown signal. Exiting."); + break; + }, + } } _ = self.interval.tick() => { self.handle_interval_tick().await; From 0547ba5493ef4531c929ea784213d9f78a2af107 Mon Sep 17 00:00:00 2001 From: TKGgunter Date: Mon, 17 Nov 2025 15:36:55 -0500 Subject: [PATCH 129/177] Create vmclock runner and adds instance type check to vmclock runner (#140) * Create vmclock runner and adds instance type check to vmclock runner This commit creates the vmclock runner when running the daemon and adds an instance type check to the vmclock runner to exit if run on a metal instance. * Restructured sourceio during vmclock creation sourceio now checks the instance type and addresses any potential problems directly rather than push errors to the daemon. * Updated integration test to address build failure. * vmclock integration test fmt update and comments. Comments added to address potential IMDS issues. --- clock-bound/src/daemon.rs | 4 +++ clock-bound/src/daemon/io.rs | 45 ++++++++++++++++++++++++------- clock-bound/src/daemon/io/imds.rs | 13 +++++++-- test/link-local/src/main.rs | 2 +- test/vmclock/src/main.rs | 5 +--- 5 files changed, 52 insertions(+), 17 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index d765fb7..4ef7215 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -30,6 +30,7 @@ use crate::daemon::{ selected_clock::SelectedClockSource, time::tsc::Skew, }; +use crate::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use crate::daemon::{ async_ring_buffer::Sender, clock_parameters::ClockParameters, clock_state::ClockState, @@ -107,6 +108,9 @@ impl Daemon { io_front_end.create_ntp_source(source).await; } + // Initialize vmclock IO component. + io_front_end.create_vmclock(VMCLOCK_SHM_DEFAULT_PATH).await; + // Initialize PHC event buffer and IO component. let (phc_tx, phc_rx) = async_ring_buffer::create(2); io_front_end.create_phc(phc_tx).await; diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 3d23fab..3cd615d 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -19,6 +19,7 @@ use crate::daemon::selected_clock::SelectedClockSource; use crate::daemon::{async_ring_buffer, event}; mod imds; +use imds::InstanceType; mod link_local; use link_local::LinkLocal; @@ -191,26 +192,51 @@ impl SourceIO { /// Initializes the IO task for sampling the VMClock shared memory file. /// - /// # Errors - /// - If the vmclock shared memory file could not be found. - /// - If the vmclock shared memory file is malformed. - pub async fn create_vmclock( - &mut self, - vmclock_shm_path: &str, - ) -> Result<(), vmclock::VMClockConstructionError> { + /// A VMClock object will not be created if the daemon is running on a unsupported instance + /// type, the shared memory file is not found or if IMDS fails to provide instance metadata. + /// + /// # Panics + /// - When the vmclock device can not be constructed using the provided shared memory path. + pub async fn create_vmclock(&mut self, vmclock_shm_path: &str) { info!("Creating link local source."); - debug!(?self.vmclock, "Current source entry status."); + info!(?self.vmclock, "Current source entry status."); if self.vmclock.is_none() { self.vmclock = { let (ctrl_sender, ctrl_receiver) = mpsc::channel::(1); + // Not all instance types are able to use vmclock. Before attempting to create the + // vmclock object the instance is first determined to be viable. + + // NOTE: There are scenarios when the vmclock should be created, but + // is not -- IMDS timeouts, droplet live updates and IMDS queries too close instance + // launch will result in no vmclock runner, even when the user might expect + // it to. + // + // Future work is required to disambiguate and handle these failure modes as well as identify + // and handle vmclock runner creation on non EC2 instances. + let instance_type = match InstanceType::get_from_imds().await { + Ok(it) => it, + Err(e) => { + warn!(?e, "EC2 instance type not determined. VMClock not enabled."); + return; + } + }; + + // Metal instances do not benefit from the vmclock and the vmclock will not be + // enabled on this instance types. + if instance_type.is_metal() { + info!("EC2 metal instance type determined. Not enabling VMClock"); + return; + } + info!("EC2 non-metal instance type determined. Enabling VMClock."); let vmclock = VMClock::construct( vmclock_shm_path, ctrl_receiver, self.clock_disruption_channels.sender.clone(), ) - .await?; + .await + .unwrap_or_else(|_| panic!("vmclock device not found {vmclock_shm_path}")); let source = Source { state: SourceState::Initialized(vmclock), @@ -219,7 +245,6 @@ impl SourceIO { Some(source) }; } - Ok(()) } // Creates a new [`watch::Receiver`] connected to the clock distribution watch [`watch::Sender`]. diff --git a/clock-bound/src/daemon/io/imds.rs b/clock-bound/src/daemon/io/imds.rs index 16653a3..eba52f6 100644 --- a/clock-bound/src/daemon/io/imds.rs +++ b/clock-bound/src/daemon/io/imds.rs @@ -23,16 +23,24 @@ pub enum InstanceTypeError { /// Instance type meta data used to determine instance capabilities. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct InstanceType(String); +pub struct InstanceType(pub String); impl InstanceType { // Instance metadata service const IMDS_ORIGIN: &'static str = "http://169.254.169.254"; const IMDS_TIMEOUT_DURATION: Duration = Duration::from_secs(1); + #[cfg(test)] + pub async fn get_from_imds() -> Result { + // Setting InstanceType explicitly to prevent unit tests from attempting to reach out to + // imds. + Ok(InstanceType("m7i.xlarge".to_string())) + } + /// Request and parse info using the [IMDSv2 API] /// /// [IMDSv2 API]: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html + #[cfg(not(test))] pub async fn get_from_imds() -> Result { let client = reqwest::Client::new(); @@ -84,7 +92,8 @@ impl InstanceType { mod test { use super::*; - #[ignore = "works only on ec2. Run manually if desired"] + // This tests does NOT reach out to IMDS. Instead it uses `get_from_imds` specific to the test + // binary which returns a hard coded value, `m7i.xlarge`. #[tokio::test] async fn from_imds() { let instance_type = InstanceType::get_from_imds().await.unwrap(); diff --git a/test/link-local/src/main.rs b/test/link-local/src/main.rs index 6b9fef6..275549e 100644 --- a/test/link-local/src/main.rs +++ b/test/link-local/src/main.rs @@ -62,7 +62,7 @@ async fn setup() -> (Receiver, Arc, SourceIO) { /// Set up link local source io for testing async fn setup_with_vmclock(vmclock_shm_path: &str, sourceio: &mut SourceIO) { - sourceio.create_vmclock(vmclock_shm_path).await.unwrap(); + sourceio.create_vmclock(vmclock_shm_path).await; sourceio.spawn_all(); } diff --git a/test/vmclock/src/main.rs b/test/vmclock/src/main.rs index 0d2b898..0154cab 100644 --- a/test/vmclock/src/main.rs +++ b/test/vmclock/src/main.rs @@ -29,10 +29,7 @@ async fn main() { let mut sourceio = SourceIO::construct(selected_clock.clone(), daemon_info); println!("Attempting to create vmclock runner using {VMCLOCK_SHM_DEFAULT_PATH:?} file."); - sourceio - .create_vmclock(VMCLOCK_SHM_DEFAULT_PATH) - .await - .unwrap(); + sourceio.create_vmclock(VMCLOCK_SHM_DEFAULT_PATH).await; sourceio.spawn_all(); // Allow the runner to poll the shared memory file for a while. From a6f73a8319b32e7f1f7a37213fcc3d558850c15d Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 17 Nov 2025 15:44:44 -0500 Subject: [PATCH 130/177] Add support for period_max_error (#152) The clock error bound value stored in the shm shows the clock error bound at the time of writing the memory. However, the client's clock error bound grows at now() calls made after the shm writing. The clock error bound grows with the max_drift_ppb field. This value contains errors from: 1. hardware oscillator drift, currently fixed to 15ppm based off of specs and anecdotal measurements. 2. Error from the fact that the period estimate is only an estimate. This is an artifact from the error of the measurements made. The shm writer of the daemon now writes the above 2 fields into the max_drift_ppb field. --- .../daemon/clock_state/clock_state_writer.rs | 37 ++-- .../src/daemon/clock_sync_algorithm/ff.rs | 7 +- .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 38 +---- .../src/daemon/clock_sync_algorithm/ff/phc.rs | 36 +--- clock-bound/src/daemon/event/ntp.rs | 161 +++++++++++++++--- clock-bound/src/daemon/event/phc.rs | 112 +++++++++++- clock-bound/src/daemon/io/phc.rs | 40 +++-- clock-bound/src/daemon/time/tsc.rs | 25 ++- 8 files changed, 323 insertions(+), 133 deletions(-) diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index d70d95f..3171109 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -7,7 +7,7 @@ use crate::{ clock_parameters::ClockParameters, time::{ Clock, Duration, Instant, clocks::MonotonicCoarse, inner::ClockOffsetAndRtt, - instant::Utc, + instant::Utc, tsc::Skew, }, }, shm::{ @@ -16,12 +16,6 @@ use crate::{ }, }; -/// The drift rate/maximal frequency error in parts-per-billion -/// for the `ClockBound` clock. The error bounds returned from the client -/// expand by this * the duration since `as_of`. -#[expect(unused)] -const FREQUENCY_ERROR_PPB: u32 = 15_000; - /// Newtype wrapper around `ShmWriter` so we can implement `Send` + `Sync`, /// and thus construct this as part of an async task. /// `ShmWriter` itself does not implement these because of its usage of a raw @@ -81,7 +75,16 @@ impl ClockStateWrite for ClockStateWriter { // For the sake of backwards compatibility, we will have our initial/alpha release continue to work // using `CLOCK_MONOTONIC_COARSE`. let as_of = MonotonicCoarse.get_time(); - self.write_shm(as_of, bound_nsec, clock_status); + let software_skew = + Skew::from_period_and_error(clock_parameters.period, clock_parameters.period_max_error); + let Some(software_skew_ppb) = software_skew.to_ppb() else { + tracing::error!( + "Software skew is too large to be expressed as a ppb, skipping writing to SHM" + ); + return; + }; + let max_drift_ppb = self.max_drift_ppb + software_skew_ppb; + self.write_shm(as_of, bound_nsec, clock_status, max_drift_ppb); } /// Handle a clock disruption event @@ -105,7 +108,7 @@ impl ClockStateWrite for ClockStateWriter { "Writing `ClockStatus::Disrupted` to SHM with 0 `bound_nsec` and `ClockStatus::Disrupted`" ); // We're writing that we're disrupted anyways, so the `bound_nsec` value should be useless here, 0 is ok - self.write_shm(as_of, 0, ClockStatus::Disrupted); + self.write_shm(as_of, 0, ClockStatus::Disrupted, 0); tracing::info!("Handled clock disruption event"); } } @@ -127,7 +130,13 @@ impl ClockStateWriter { } } - fn write_shm(&mut self, as_of: Instant, bound_nsec: i64, clock_status: ClockStatus) { + fn write_shm( + &mut self, + as_of: Instant, + bound_nsec: i64, + clock_status: ClockStatus, + max_drift_ppb: u32, + ) { let ceb = ClockErrorBoundGeneric::builder() .as_of( // Unwrap safety: unlikely to fail for any value for the distant future, @@ -145,8 +154,7 @@ impl ClockStateWriter { // TODO: It may be worthwhile to add to this max drift ppb base the following // components: // - any slew rate for phase correction, since kernel clocks are used on client side - // - error inherent to our frequency calculation e.g. `period_max_error` - self.max_drift_ppb, + max_drift_ppb, ) .clock_status(clock_status) .clock_disruption_support_enabled(self.clock_disruption_support_enabled) @@ -224,7 +232,7 @@ mod tests { fn test_write_shm(#[case] clock_status: ClockStatus) { let as_of = MonotonicCoarse.get_time(); let bound_nsec = 1234; - let max_drift_ppb = 234; + let max_drift_ppb = 15_000; let disruption_marker = 345; let clock_disruption_support_enabled = true; let expected_ceb = ClockErrorBoundGeneric::builder() @@ -248,7 +256,7 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer.write_shm(as_of, bound_nsec, clock_status); + clock_state_writer.write_shm(as_of, bound_nsec, clock_status, max_drift_ppb); } #[test] @@ -274,7 +282,6 @@ mod tests { == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) && ceb.bound_nsec() == 2250 && ceb.disruption_marker() == disruption_marker - && ceb.max_drift_ppb() == max_drift_ppb && ceb.clock_status() == clock_status && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled }) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs index 9ec0205..27d4725 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs @@ -16,11 +16,12 @@ pub use uncorrected_clock::UncorrectedClock; use crate::daemon::time::{Duration, tsc::Period}; /// Used as the output for `calculate_local_period_and_error` methods -struct LocalPeriodAndError { +#[derive(Debug, Clone, PartialEq)] +pub struct LocalPeriodAndError { /// period calculation - period_local: Period, + pub period_local: Period, /// period error - error: Period, + pub error: Period, } /// Output from `calculate_theta` methods diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index b97b54f..1e41479 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -284,10 +284,7 @@ impl Ntp { // these values in search. That kind of approach is TBD. // // TODO explore this a bit more. - let ref_clock_diff = newest.data().server_send_time - oldest.data().server_send_time; - let tsc_diff = newest.tsc_post() - oldest.tsc_post(); - tracing::trace!(?ref_clock_diff, ?tsc_diff, "estimate period inputs"); - let p_estimate = ref_clock_diff / tsc_diff; + let p_estimate = oldest.calculate_period(newest); let k = p_estimate * TscDiff::new(newest.tsc_post().get()); let k = newest.data().server_send_time - k; @@ -306,10 +303,6 @@ impl Ntp { /// Calculate the local period and associated error /// /// Returns `None` if `local` has less than 2 data points - #[expect( - clippy::cast_precision_loss, - reason = "Needed an escape hatch. Error units weren't lining up" - )] fn calculate_local_period_and_error( local: &event_buffer::Local, ) -> Option { @@ -320,38 +313,15 @@ impl Ntp { let old = local.as_ref().min_rtt_in_quarter(Quarter::Oldest).unwrap(); let new = local.as_ref().min_rtt_in_quarter(Quarter::Newest).unwrap(); - let period_local = new.calculate_period_backward(old); + let local_period_and_error = old.calculate_period_with_error(new); - // unwrap okay, local is not empty - let min = local.as_ref().min_rtt().unwrap(); - let min_rtt = min.rtt(); - let new_sample_error = new.rtt() - min_rtt; - let old_sample_error = old.rtt() - min_rtt; - - assert!(new_sample_error.get() >= 0); - assert!(old_sample_error.get() >= 0); - - // We have used this somewhere else, but I don't see the math for this. - // units also come out as GHz... (ticks / nanoseconds) - // seems sus... - // - // Currently this value is not used within the FF algorithm nor client, but we need to address this calculation - // in the future - let error = ((new_sample_error + old_sample_error).get() as f64) - / (new.data().server_recv_time - old.data().server_recv_time).as_nanos() as f64; - let error = Period::from_seconds(error); tracing::debug!( ?old, ?new, - min_rtt = ?min, - %period_local, - %error, + ?local_period_and_error, "Calculated local period and error" ); - Some(LocalPeriodAndError { - period_local, - error, - }) + Some(local_period_and_error) } /// Calculate the theta value, which is the time correction to be applied diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs index 630f7ac..e88cd1d 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs @@ -288,8 +288,7 @@ impl Phc { // these values in search. That kind of approach is TBD. // // TODO explore this a bit more. - let p_estimate = - (newest.data().time - oldest.data().time) / (newest.tsc_post() - oldest.tsc_post()); + let p_estimate = oldest.calculate_period(newest); let k = p_estimate * TscDiff::new(newest.tsc_post().get()); let k = newest.data().time - k; @@ -308,10 +307,6 @@ impl Phc { /// Calculate the local period and associated error /// /// Returns `None` if `local` has less than 2 data points - #[expect( - clippy::cast_precision_loss, - reason = "Needed an escape hatch. Error units weren't lining up" - )] fn calculate_local_period_and_error( local: &event_buffer::Local, ) -> Option { @@ -322,38 +317,15 @@ impl Phc { let old = local.as_ref().min_rtt_in_quarter(Quarter::Oldest).unwrap(); let new = local.as_ref().min_rtt_in_quarter(Quarter::Newest).unwrap(); - let period_local = new.calculate_period_backward(old); + let local_period_and_error = old.calculate_period_with_error(new); - // unwrap okay, local is not empty - let min = local.as_ref().min_rtt().unwrap(); - let min_rtt = min.rtt(); - let new_sample_error = new.rtt() - min_rtt; - let old_sample_error = old.rtt() - min_rtt; - - assert!(new_sample_error.get() >= 0); - assert!(old_sample_error.get() >= 0); - - // We have used this somewhere else, but I don't see the math for this. - // units also come out as GHz... (ticks / nanoseconds) - // seems sus... - // - // Currently this value is not used within the FF algorithm nor client, but we need to address this calculation - // in the future - let error = ((new_sample_error + old_sample_error).get() as f64) - / (new.data().time - old.data().time).as_nanos() as f64; - let error = Period::from_seconds(error); tracing::debug!( ?old, ?new, - min_rtt = ?min, - %period_local, - %error, + ?local_period_and_error, "Calculated local period and error" ); - Some(LocalPeriodAndError { - period_local, - error, - }) + Some(local_period_and_error) } /// Calculate the theta value, which is the time correction to be applied diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index 965756d..2c3cf88 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -6,7 +6,7 @@ use std::{ use super::TscRtt; use crate::daemon::{ - clock_sync_algorithm::ff::UncorrectedClock, + clock_sync_algorithm::ff::{LocalPeriodAndError, UncorrectedClock}, time::{Duration, Instant, TscCount, tsc::Period}, }; @@ -74,7 +74,7 @@ impl Ntp { self.system_clock.as_ref() } - /// Calculate a period by using 2 NTP events using return path + /// Calculate a period by using 2 NTP events using midpoints /// /// NTP traffic is characterized in ClockBound with each exchange having a /// - `tsc_pre`: The TSC reading before sending the NTP packet @@ -82,21 +82,68 @@ impl Ntp { /// - `server_send_system_time`: The server's system time after sending the NTP packet /// - `tsc_post`: The TSC reading after sending the NTP packet /// - /// The "backward" path here means using the `server_send_system_time` and `tsc_post` from - /// two events to calculate the TSC period. - /// /// # Panics - /// - Panics if events share the same `tsc_post` (happens if the events are the same). - /// - Panics if events share the same `server_send_time` (also happens if the events are the same) - pub fn calculate_period_backward(&self, other: &Self) -> Period { - let (old, new) = if self.tsc_post < other.tsc_post { - (self, other) - } else { - (other, self) - }; + /// - Panics if events share the same tsc midpoint (happens if the events are the same). + /// - Panics if events share the same server time midpoint (also happens if the events are the same) + pub fn calculate_period(&self, other: &Self) -> Period { + let self_server_midpoint = self + .data() + .server_recv_time + .midpoint(self.data().server_send_time); + let self_tsc_midpoint = self.tsc_midpoint(); + + let other_server_midpoint = other + .data() + .server_recv_time + .midpoint(other.data().server_send_time); + let other_tsc_midpoint = other.tsc_midpoint(); + + (self_server_midpoint - other_server_midpoint) / (self_tsc_midpoint - other_tsc_midpoint) + } - (new.data().server_send_time - old.data().server_send_time) - / (new.tsc_post() - old.tsc_post()) + /// Calculate the period along with the error in the period estimation + /// + /// When calculating the period, the error in the measurement has a direct relationship with + /// the reference clock's clock error bound and network RTT, and an inverse relationship + /// with the time between the two events. + /// + /// Over the steady state, the FF algorithm will use data points which are minutes apart. This will + /// make the reference clock's clock error bound and RTT values statistically insignificant. + /// + /// However, after a disruption event the effects from the clock error bound and RTT can become more pronounced. + /// This calculation stays honest with that. + pub fn calculate_period_with_error(&self, other: &Self) -> LocalPeriodAndError { + // This is the server reported clock error bound. Includes neither peer delay nor local dispersion + let other_server_ceb = other.data().root_dispersion + other.data().root_delay / 2; + let self_server_ceb = self.data().root_dispersion + self.data().root_delay / 2; + + let self_server_midpoint = self + .data() + .server_recv_time + .midpoint(self.data().server_send_time); + let other_server_midpoint = other + .data() + .server_recv_time + .midpoint(other.data().server_send_time); + + let period_bound_one = ((self_server_midpoint - self_server_ceb) + - (other_server_midpoint + other_server_ceb)) + / (self.tsc_post - other.tsc_pre); + let period_bound_two = ((self_server_midpoint + self_server_ceb) + - (other_server_midpoint - other_server_ceb)) + / (self.tsc_pre - other.tsc_post); + + let period = self.calculate_period(other); + + let period_error_one = (period_bound_one.get() - period.get()).abs(); + let period_error_two = (period_bound_two.get() - period.get()).abs(); + + let error = Period::from_seconds(period_error_one.max(period_error_two)); + + LocalPeriodAndError { + period_local: period, + error, + } } /// Calculate the clock error bound of this event at the time of the event @@ -387,13 +434,14 @@ mod tests { assert!(matches!(Stratum::try_from(255), Err(TryFromU8Error))); } - fn create_ntp_event(pre: TscCount, post: TscCount, server_send_time: Instant) -> Ntp { + fn create_ntp_event(pre: TscCount, post: TscCount, server_time: Instant) -> Ntp { + let server_duration = Duration::from_micros(40); Ntp::builder() .tsc_pre(pre) .tsc_post(post) .ntp_data(NtpData { - server_recv_time: Instant::from_nanos(0), // Not used in calculation - server_send_time: server_send_time, + server_recv_time: server_time - (server_duration / 2), + server_send_time: server_time + (server_duration / 2), root_delay: Duration::from_nanos(0), // Not used in calculation root_dispersion: Duration::from_nanos(0), // Not used in calculation stratum: Stratum::ONE, // Not used in calculation @@ -487,7 +535,7 @@ mod tests { // Expected period (server_time_diff / tsc_diff = (200000-100000)/(40000-20000) = 5) Period::from_seconds(5.0), )] - fn test_calculate_period_backward( + fn test_calculate_period( #[case] (first_pre, first_post, first_send): (TscCount, TscCount, Instant), #[case] (second_pre, second_post, second_send): (TscCount, TscCount, Instant), #[case] expected_period: Period, @@ -495,9 +543,10 @@ mod tests { let event1 = create_ntp_event(first_pre, first_post, first_send); let event2 = create_ntp_event(second_pre, second_post, second_send); - let period = event1.calculate_period_backward(&event2); + let period = event1.calculate_period(&event2); approx::assert_abs_diff_eq!(period.get(), expected_period.get()); } + #[rstest] #[case( // Zero root delay and dispersion @@ -636,4 +685,76 @@ mod tests { epsilon = 1e-9 ); } + + fn create_ntp_event_with_error( + pre: TscCount, + post: TscCount, + server_time: Instant, + server_time_error: Duration, + ) -> Ntp { + let server_duration = Duration::from_micros(40); + Ntp::builder() + .tsc_pre(pre) + .tsc_post(post) + .ntp_data(NtpData { + server_recv_time: server_time - (server_duration / 2), + server_send_time: server_time + (server_duration / 2), + root_delay: Duration::from_nanos(0), // Kinda not used in calculation + root_dispersion: server_time_error, + stratum: Stratum::ONE, // Not used in calculation + }) + .build() + .unwrap() + } + + // grabbed data from atss + #[rstest] + #[case::first_two_burst( + // First event + (TscCount::new(1369766986771638), TscCount::new(1369766987268186), Instant::from_femtos(1763156375539567199000000), Duration::from_femtos(15_258_789_063)), + // Second event + (TscCount::new(1369767115896036), TscCount::new(1369767116312166), Instant::from_femtos(1763156375589220795000000), Duration::from_femtos(15_258_789_063)), + Period::from_seconds(3.84660556685218820E-10), + Period::from_seconds(1.601932955446111e-12), + )] + #[case::longer_term( + // First event + (TscCount::new(1372612880990286), TscCount::new(1372612881496636), Instant::from_femtos(1763157470124988894000000), Duration::from_femtos(30517578125)), + // Second event + (TscCount::new(1372984678771576), TscCount::new(1372984679237314), Instant::from_femtos(1763157613125523830000000), Duration::from_femtos(15258789063)), + Period::from_seconds(3.846191396030325e-10), + Period::from_seconds(6.259276438100348e-16), + )] + #[case::backward( + // Second event + (TscCount::new(1369767115896036), TscCount::new(1369767116312166), Instant::from_femtos(1763156375589220795000000), Duration::from_femtos(15_258_789_063)), + // First event + (TscCount::new(1369766986771638), TscCount::new(1369766987268186), Instant::from_femtos(1763156375539567199000000), Duration::from_femtos(15_258_789_063)), + Period::from_seconds(3.84660556685218820E-10), + Period::from_seconds(1.601932955446111e-12), + )] + + fn test_calculate_period_with_error( + #[case] (first_pre, first_post, first_send, first_ceb): ( + TscCount, + TscCount, + Instant, + Duration, + ), + #[case] (second_pre, second_post, second_send, second_ceb): ( + TscCount, + TscCount, + Instant, + Duration, + ), + #[case] expected_period: Period, + #[case] expected_period_error: Period, + ) { + let event1 = create_ntp_event_with_error(first_pre, first_post, first_send, first_ceb); + let event2 = create_ntp_event_with_error(second_pre, second_post, second_send, second_ceb); + + let res = event1.calculate_period_with_error(&event2); + approx::assert_abs_diff_eq!(res.period_local.get(), expected_period.get()); + approx::assert_abs_diff_eq!(res.error.get(), expected_period_error.get()); + } } diff --git a/clock-bound/src/daemon/event/phc.rs b/clock-bound/src/daemon/event/phc.rs index e286b0c..1a2c972 100644 --- a/clock-bound/src/daemon/event/phc.rs +++ b/clock-bound/src/daemon/event/phc.rs @@ -1,7 +1,7 @@ //! PHC Time synchronization events use crate::daemon::{ - clock_sync_algorithm::ff::UncorrectedClock, + clock_sync_algorithm::ff::{LocalPeriodAndError, UncorrectedClock}, time::{Duration, Instant, TscCount, tsc::Period}, }; @@ -71,19 +71,49 @@ impl Phc { self.system_clock.as_ref() } - /// Calculate a period by using 2 PHC events using the return path + /// Calculate a period by using 2 PHC events using tsc midpoints /// /// PHC reads are characterized in ClockBound with each read having /// - `tsc_pre`: The TSC reading before reading the PHC device /// - `time`: The PHC time reading /// - `tsc_post`: The TSC reading after reading the PHC device /// - /// The "backward" path here means using the `tsc_post` and `time` from the events - /// /// # Panics /// Panics if the events are the same (specifically if the `tsc_post` values are equal) - pub fn calculate_period_backward(&self, other: &Self) -> Period { - (self.data().time - other.data().time) / (self.tsc_post() - other.tsc_post()) + pub fn calculate_period(&self, other: &Self) -> Period { + (self.data().time - other.data().time) / (self.tsc_midpoint() - other.tsc_midpoint()) + } + + /// Calculate the period along with the error in the period estimation + /// + /// When calculating the period, the error in the measurement has a direct relationship with + /// the reference clock's clock error bound and network RTT, and an inverse relationship + /// with the time between the two events. + /// + /// Over the steady state, the FF algorithm will use data points which are minutes apart. This will + /// make the reference clock's clock error bound and RTT values statistically insignificant. + /// + /// However, after a disruption event the effects from the clock error bound and RTT can become more pronounced. + /// This calculation stays honest with that. + pub fn calculate_period_with_error(&self, other: &Self) -> LocalPeriodAndError { + let period_bound_one = ((self.data.time - self.data.clock_error_bound) + - (other.data.time + other.data.clock_error_bound)) + / (self.tsc_post - other.tsc_pre); + let period_bound_two = ((self.data.time + self.data.clock_error_bound) + - (other.data.time - other.data.clock_error_bound)) + / (self.tsc_pre - other.tsc_post); + + let period = self.calculate_period(other); + + let period_error_one = (period_bound_one.get() - period.get()).abs(); + let period_error_two = (period_bound_two.get() - period.get()).abs(); + + let error = Period::from_seconds(period_error_one.max(period_error_two)); + + LocalPeriodAndError { + period_local: period, + error, + } } /// Calculate the clock error bound of this event at the time of the event @@ -260,7 +290,7 @@ mod tests { let event1 = create_phc_event(first_pre, first_post, first_send); let event2 = create_phc_event(second_pre, second_post, second_send); - let period = event1.calculate_period_backward(&event2); + let period = event1.calculate_period(&event2); approx::assert_abs_diff_eq!(period.get(), expected_period.get()); } @@ -387,4 +417,72 @@ mod tests { epsilon = 1e-9 ); } + + fn create_phc_event_with_error( + pre: TscCount, + post: TscCount, + server_time: Instant, + error: Duration, + ) -> Phc { + Phc::builder() + .tsc_pre(pre) + .tsc_post(post) + .data(PhcData { + time: server_time, + clock_error_bound: error, + }) + .build() + .unwrap() + } + + // grabbed data from atss (ntp. But math is the same as phc) + #[rstest] + #[case::first_two( + // First event + (TscCount::new(1369766986771638), TscCount::new(1369766987268186), Instant::from_femtos(1763156375539567199000000), Duration::from_femtos(15_258_789_063)), + // Second event + (TscCount::new(1369767115896036), TscCount::new(1369767116312166), Instant::from_femtos(1763156375589220795000000), Duration::from_femtos(15_258_789_063)), + Period::from_seconds(3.84660556685218820E-10), + Period::from_seconds(1.601932955446111e-12), + )] + #[case::longer_term( + // First event + (TscCount::new(1372612880990286), TscCount::new(1372612881496636), Instant::from_femtos(1763157470124988894000000), Duration::from_femtos(30517578125)), + // Second event + (TscCount::new(1372984678771576), TscCount::new(1372984679237314), Instant::from_femtos(1763157613125523830000000), Duration::from_femtos(15258789063)), + Period::from_seconds(3.846191396030325e-10), + Period::from_seconds(6.259276438100348e-16), + )] + #[case::backward( + // Second event + (TscCount::new(1369767115896036), TscCount::new(1369767116312166), Instant::from_femtos(1763156375589220795000000), Duration::from_femtos(15_258_789_063)), + // First event + (TscCount::new(1369766986771638), TscCount::new(1369766987268186), Instant::from_femtos(1763156375539567199000000), Duration::from_femtos(15_258_789_063)), + Period::from_seconds(3.84660556685218820E-10), + Period::from_seconds(1.601932955446111e-12), + )] + + fn test_calculate_period_with_error( + #[case] (first_pre, first_post, first_send, first_ceb): ( + TscCount, + TscCount, + Instant, + Duration, + ), + #[case] (second_pre, second_post, second_send, second_ceb): ( + TscCount, + TscCount, + Instant, + Duration, + ), + #[case] expected_period: Period, + #[case] expected_period_error: Period, + ) { + let event1 = create_phc_event_with_error(first_pre, first_post, first_send, first_ceb); + let event2 = create_phc_event_with_error(second_pre, second_post, second_send, second_ceb); + + let res = event1.calculate_period_with_error(&event2); + approx::assert_abs_diff_eq!(res.period_local.get(), expected_period.get()); + approx::assert_abs_diff_eq!(res.error.get(), expected_period_error.get()); + } } diff --git a/clock-bound/src/daemon/io/phc.rs b/clock-bound/src/daemon/io/phc.rs index 87c7a02..bca0deb 100644 --- a/clock-bound/src/daemon/io/phc.rs +++ b/clock-bound/src/daemon/io/phc.rs @@ -913,7 +913,7 @@ PCI_SLOT_NAME=23456 twoline", "23456" )] - async fn test_get_pci_slot_name_success( + async fn get_pci_slot_name_success( #[case] file_contents_to_write: &str, #[case] return_value: &str, ) { @@ -931,7 +931,7 @@ twoline", #[tokio::test] #[rstest] #[case::missing_pci_slot_name("no pci slot name")] - async fn test_get_pci_slot_name_failure(#[case] file_contents_to_write: &str) { + async fn get_pci_slot_name_failure(#[case] file_contents_to_write: &str) { let mut test_uevent_file = NamedTempFile::new().expect("create mock uevent file failed"); test_uevent_file .write_all(file_contents_to_write.as_bytes()) @@ -947,7 +947,7 @@ twoline", } #[tokio::test] - async fn test_get_pci_slot_name_file_does_not_exist() { + async fn get_pci_slot_name_file_does_not_exist() { let rt = phc_path_locator::get_pci_slot_name("/does/not/exist").await; assert!(rt.is_err()); } @@ -955,7 +955,7 @@ twoline", #[tokio::test] #[rstest] #[case::happy_path("12345", 12345)] - async fn test_read_phc_error_bound_success( + async fn read_phc_error_bound_success( #[case] file_contents_to_write: &str, #[case] return_value: i64, ) { @@ -975,7 +975,7 @@ twoline", #[tokio::test] #[rstest] #[case::parsing_fail("asdf_not_an_i64")] - async fn test_read_phc_error_bound_bad_file_contents(#[case] file_contents_to_write: &str) { + async fn read_phc_error_bound_bad_file_contents(#[case] file_contents_to_write: &str) { let mut test_phc_error_bound_file = NamedTempFile::new().expect("create mock phc error bound file failed"); test_phc_error_bound_file @@ -994,21 +994,21 @@ twoline", } #[tokio::test] - async fn test_read_phc_error_bound_file_does_not_exist() { + async fn read_phc_error_bound_file_does_not_exist() { let phc_error_bound_reader = PhcClockErrorBoundReader::new("/does/not/exist".into()); let rt = phc_error_bound_reader.read().await; assert!(rt.is_err()); } #[test] - fn test_phc_clock_error_bound_reader_new() { + fn phc_clock_error_bound_reader_new() { let path = PathBuf::from("/test/path"); let reader = PhcClockErrorBoundReader::new(path.clone()); assert_eq!(reader.sysfs_phc_error_bound_path, path); } #[test] - fn test_phc_sample_try_from_success() { + fn phc_sample_try_from_success() { let sample = PhcSample { counter_pre: 100, counter_post: 200, @@ -1026,7 +1026,7 @@ twoline", } #[test] - fn test_phc_sample_try_from_invalid_counter_diff() { + fn phc_sample_try_from_invalid_counter_diff() { let sample = PhcSample { counter_pre: 200, counter_post: 100, @@ -1048,7 +1048,7 @@ twoline", } #[test] - fn test_phc_sample_try_from_invalid_clock_error_bound() { + fn phc_sample_try_from_invalid_clock_error_bound() { let sample = PhcSample { counter_pre: 100, counter_post: 200, @@ -1070,7 +1070,7 @@ twoline", } #[tokio::test] - async fn test_get_network_interfaces_directory_not_found() { + async fn get_network_interfaces_directory_not_found() { let ctx = mock_filesystem::fs_try_exists_context(); ctx.expect().returning(|_| Ok(false)); @@ -1085,7 +1085,7 @@ twoline", } #[tokio::test] - async fn test_get_uevent_file_path_for_network_interface_success() { + async fn get_uevent_file_path_for_network_interface_success() { let ctx = mock_filesystem::fs_try_exists_context(); ctx.expect().returning(|_| Ok(true)); @@ -1099,10 +1099,7 @@ twoline", #[case::ena_driver("DRIVER=ena", true)] #[case::other_driver("DRIVER=e1000e", false)] #[case::multiline_ena("SUBSYSTEM=net\nDRIVER=ena\nOTHER=value", true)] - async fn test_is_ena_network_interface_success( - #[case] file_contents: &str, - #[case] expected: bool, - ) { + async fn is_ena_network_interface_success(#[case] file_contents: &str, #[case] expected: bool) { let mut test_file = NamedTempFile::new().expect("create temp file failed"); test_file .write_all(file_contents.as_bytes()) @@ -1115,7 +1112,7 @@ twoline", } #[tokio::test] - async fn test_is_ena_network_interface_no_driver() { + async fn is_ena_network_interface_no_driver() { let mut test_file = NamedTempFile::new().expect("create temp file failed"); test_file .write_all(b"SUBSYSTEM=net\nOTHER=value") @@ -1131,7 +1128,8 @@ twoline", } #[tokio::test] - async fn test_get_ptp_uevent_file_paths_for_pci_slot_directory_not_found() { + #[ignore = "static mocks require synchronization"] + async fn get_ptp_uevent_file_paths_for_pci_slot_directory_not_found() { let ctx = mock_filesystem::fs_try_exists_context(); ctx.expect().returning(|_| Ok(false)); @@ -1149,7 +1147,7 @@ twoline", #[rstest] #[case::simple_devname("DEVNAME=ptp0", "ptp0")] #[case::multiline_devname("SUBSYSTEM=ptp\nDEVNAME=ptp1\nOTHER=value", "ptp1")] - async fn test_get_ptp_device_name_from_uevent_file_success( + async fn get_ptp_device_name_from_uevent_file_success( #[case] file_contents: &str, #[case] expected: &str, ) { @@ -1167,7 +1165,7 @@ twoline", } #[tokio::test] - async fn test_get_ptp_device_name_from_uevent_file_no_devname() { + async fn get_ptp_device_name_from_uevent_file_no_devname() { let mut test_file = NamedTempFile::new().expect("create temp file failed"); test_file .write_all(b"SUBSYSTEM=ptp\nOTHER=value") @@ -1185,7 +1183,7 @@ twoline", } #[tokio::test] - async fn test_get_phc_clock_error_bound_sysfs_path_not_found() { + async fn get_phc_clock_error_bound_sysfs_path_not_found() { let ctx = mock_filesystem::fs_try_exists_context(); ctx.expect().returning(|_| Ok(false)); diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index eae21dc..f2aa2d0 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -263,6 +263,7 @@ impl MulAssign for Frequency { pub struct Skew(f64); impl Skew { + const PPB: f64 = 1.0e-9; const PPM: f64 = 1.0e-6; const PERCENT: f64 = 0.01; @@ -273,7 +274,20 @@ impl Skew { /// Construct a new skew from parts per billion (ppb) pub const fn from_ppb(skew: f64) -> Self { - Self(skew * Self::PPM / 1_000.0) + Self(skew * Self::PPB) + } + + /// To PPB + /// + /// Returns none if the value is larger than 1 billion part per billion + pub const fn to_ppb(self) -> Option { + let skew = self.0.abs(); + if skew < 1e9 { + #[expect(clippy::cast_sign_loss, reason = "did abs above")] + Some((skew / Self::PPB).round() as u32) + } else { + None + } } /// Construct a new skew from percentage @@ -294,6 +308,15 @@ impl Skew { Self(1.0 - ratio) } + /// Calculate skew from period and associated error + /// + /// Equivalent to + /// `Skew = error (in seconds) / period (in seconds` + pub fn from_period_and_error(period: Period, error: Period) -> Self { + let skew = error.get() / period.get(); + Self(skew) + } + /// In struct timex, freq, ppsfreq, and stabil are ppm (parts per /// million) with a 16-bit fractional part, which means that a value /// of 1 in one of those fields actually means 2^-16 ppm, and From 221fe60d509e4cc88c4fda96e63a73d87e2c0e2c Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 17 Nov 2025 16:44:39 -0500 Subject: [PATCH 131/177] Default log directory is /var/log/clockbound (#155) --- clock-bound/src/bin/clockbound.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clock-bound/src/bin/clockbound.rs b/clock-bound/src/bin/clockbound.rs index 0f8b7f2..b931d05 100644 --- a/clock-bound/src/bin/clockbound.rs +++ b/clock-bound/src/bin/clockbound.rs @@ -9,7 +9,7 @@ use tracing::{error, info, warn}; #[derive(Debug, Parser)] struct Args { - #[clap(long, default_value = "clockbound")] + #[clap(long, default_value = "/var/log/clockbound")] log_dir: PathBuf, } From 562b9778745601dcf56d6b91b068d2fdb237db7c Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Mon, 17 Nov 2025 14:29:04 -0800 Subject: [PATCH 132/177] [shm] Add the SHM v3 layout (#148) * [shm] Add the SHM v3 layout This patch introduces the V3 variant of the ClockErrorBound struct, to represent this new version of the layout. The new layout adds fields specific to the ff-sync algorithm. Client side, this allows to calculate the current time and matching CEB value without relying on the system clock. Note that this patch makes a full copy of the module to read the TSC. This is a temporary duplication of code, which will be reconciled in a future dedicated patch. --- clock-bound/src/shm.rs | 515 ++++++++++++++++++++++++++++++++++++- clock-bound/src/shm/tsc.rs | 99 +++++++ 2 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 clock-bound/src/shm/tsc.rs diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 934ebc7..f6247f5 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -11,10 +11,13 @@ pub mod common; mod reader; mod shm_header; +mod tsc; mod writer; // Re-exports reader and writer. The writer is conditionally included under the "writer" feature. +use common::{CLOCK_MONOTONIC, CLOCK_REALTIME, clock_gettime_safe}; pub use reader::ShmReader; +use tsc::read_timestamp_counter_begin; pub use writer::{ShmWrite, ShmWriter}; use bon::Builder; @@ -24,11 +27,10 @@ use std::error::Error; use std::ffi::CStr; use std::fmt; -use common::{CLOCK_MONOTONIC, CLOCK_REALTIME, clock_gettime_safe}; - pub const CLOCKBOUND_SHM_DEFAULT_PATH: &str = "/var/run/clockbound/shm0"; const CLOCKBOUND_RESTART_GRACE_PERIOD: TimeSpec = TimeSpec::new(5, 0); +const NANOS_PER_SECOND: f64 = 1_000_000_000.0; /// Convenience macro to build a `ShmError::SyscallError` with extra info from errno and custom /// origin information. @@ -60,48 +62,56 @@ pub trait ClockBoundSnapshot { #[derive(Debug, Copy, Clone, PartialEq)] pub enum ClockErrorBound { V2(ClockErrorBoundV2), + V3(ClockErrorBoundV3), } impl ClockErrorBound { pub fn as_of(&self) -> TimeSpec { match self { ClockErrorBound::V2(ceb) => ceb.as_of, + ClockErrorBound::V3(ceb) => ceb.as_of, } } pub fn void_after(&self) -> TimeSpec { match self { ClockErrorBound::V2(ceb) => ceb.void_after, + ClockErrorBound::V3(ceb) => ceb.void_after, } } pub fn bound_nsec(&self) -> i64 { match self { ClockErrorBound::V2(ceb) => ceb.bound_nsec, + ClockErrorBound::V3(ceb) => ceb.bound_nsec, } } pub fn max_drift_ppb(&self) -> u32 { match self { ClockErrorBound::V2(ceb) => ceb.max_drift_ppb, + ClockErrorBound::V3(ceb) => ceb.max_drift_ppb, } } pub fn clock_status(&self) -> ClockStatus { match self { ClockErrorBound::V2(ceb) => ceb.clock_status, + ClockErrorBound::V3(ceb) => ceb.clock_status, } } pub fn disruption_marker(&self) -> u64 { match self { ClockErrorBound::V2(ceb) => ceb.disruption_marker, + ClockErrorBound::V3(ceb) => ceb.disruption_marker, } } pub fn clock_disruption_support_enabled(&self) -> bool { match self { ClockErrorBound::V2(ceb) => ceb.clock_disruption_support_enabled, + ClockErrorBound::V3(ceb) => ceb.clock_disruption_support_enabled, } } } @@ -110,6 +120,7 @@ impl ClockBoundSnapshot for ClockErrorBound { fn now(&self) -> Result { match self { ClockErrorBound::V2(ceb) => ceb.now(), + ClockErrorBound::V3(ceb) => ceb.now(), } } } @@ -120,6 +131,9 @@ impl ClockBoundSnapshot for ClockErrorBound { // function to create the enum variants #[builder(finish_fn(vis = "", name = build_internal))] pub struct ClockErrorBoundGeneric { + #[builder(default)] + as_of_tsc: u64, + #[builder(default = TimeSpec::new(0, 0))] as_of: TimeSpec, @@ -129,6 +143,12 @@ pub struct ClockErrorBoundGeneric { #[builder(default)] bound_nsec: i64, + #[builder(default)] + period: f64, + + #[builder(default)] + period_err: f64, + #[builder(default)] disruption_marker: u64, @@ -162,6 +182,18 @@ impl ClockErrorBoundGenericBui ceb.clock_status, ceb.clock_disruption_support_enabled, )), + ClockErrorBoundLayoutVersion::V3 => ClockErrorBound::V3(ClockErrorBoundV3::new( + ceb.as_of_tsc, + ceb.as_of, + ceb.void_after, + ceb.period, + ceb.period_err, + ceb.bound_nsec, + ceb.disruption_marker, + ceb.max_drift_ppb, + ceb.clock_status, + ceb.clock_disruption_support_enabled, + )), } } } @@ -169,6 +201,7 @@ impl ClockErrorBoundGenericBui #[derive(Copy, Clone)] pub enum ClockErrorBoundLayoutVersion { V2, + V3, } impl TryFrom for ClockErrorBoundLayoutVersion { @@ -176,6 +209,7 @@ impl TryFrom for ClockErrorBoundLayoutVersion { fn try_from(value: u8) -> Result { match value { 2 => Ok(ClockErrorBoundLayoutVersion::V2), + 3 => Ok(ClockErrorBoundLayoutVersion::V3), _ => Err(ShmError::SegmentVersionNotSupported), } } @@ -186,6 +220,7 @@ impl TryFrom for ClockErrorBoundLayoutVersion { fn try_from(value: u16) -> Result { match value { 2 => Ok(ClockErrorBoundLayoutVersion::V2), + 3 => Ok(ClockErrorBoundLayoutVersion::V3), _ => Err(ShmError::SegmentVersionNotSupported), } } @@ -306,7 +341,7 @@ pub struct ClockErrorBoundV2 { /// /// This value is incremented (by an unspecified delta) each time the clock has been disrupted. /// This count value is specific to a particular VM/EC2 instance. - pub disruption_marker: u64, + disruption_marker: u64, /// Maximum drift rate of the clock between updates of the synchronization daemon. The value /// stored in `bound_nsec` should increase by the following to account for the clock drift @@ -322,7 +357,7 @@ pub struct ClockErrorBoundV2 { /// /// This indicates whether or not the ClockBound daemon was started with a /// configuration that supports detecting clock disruptions. - pub clock_disruption_support_enabled: bool, + clock_disruption_support_enabled: bool, /// Padding. _padding: [u8; 7], @@ -510,6 +545,293 @@ impl ClockBoundSnapshot for ClockErrorBoundV2 { } } +/// Structure that holds the `ClockErrorBound` data captured at a specific point in time and valid +/// until a subsequent point in time. +/// +/// The `ClockErrorBound` structure supports calculating the actual bound on clock error at any time, +/// using its `now()` method. The internal fields are not meant to be accessed directly. +/// +/// Note that this version of the layout allow to not use the OS system clock to retrieve the +/// current time or grow the clock error bound. +/// +/// The structure is shared across the Shared Memory segment and has a C representation to enforce +/// this specific layout. +#[repr(C)] +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct ClockErrorBoundV3 { + /// TSC counter value identifying this clock update. + /// + /// The TSC counter timestamp marking the time the clock and the clock error bound where + /// updated last. It represents the same instant as `as_of`. + as_of_tsc: u64, + + /// Timestamp of this clock update. + /// + /// The nanosecond resolution timestamp marking the time the clock and the clock error bound + /// where updated last. This timestamp is derived from `as_of_tsc`. + as_of: TimeSpec, + + /// Time after which this clock update is void. + /// + /// The nanosecond timestamp beyond which the bound on clock error should not be trusted. This + /// is a useful signal that the communication with the synchronization daemon is has failed, + /// for example. + void_after: TimeSpec, + + /// Oscillator period estimate. + /// + /// The period of the oscillator, represented as a fractional part of a second. + period_frac: u64, + + /// Oscillator period estimate error. + /// + /// The error on the estimate of the period of the oscillator, in ppb, represented as a + /// fractional part of a second. + period_err_frac: u64, + + /// Clock Error Bound + /// + /// An absolute upper bound on the accuracy of the feed-forward synchronization clock with + /// regards to true time at the instant represented by `as_of` and `as_of_tsc`. + bound_nsec: i64, + + /// Disruption marker. + /// + /// This value is incremented (by an unspecified delta) each time the clock has been disrupted. + /// This count value is specific to a particular VM/EC2 instance. + disruption_marker: u64, + + /// Maximum drift rate in part-per-billion. + /// + /// Maximum drift rate of the clock between updates of the synchronization daemon. The value + /// stored in `bound_nsec` should increase by the following to account for the clock drift + /// since `bound_nsec` was computed: + /// `bound_nsec += max_drift_ppb * (now - as_of)` + max_drift_ppb: u32, + + /// Clock status. + /// + /// The synchronization daemon status indicates whether the daemon is synchronized, + /// free-running, etc. + clock_status: ClockStatus, + + /// Clock disruption support enabled flag. + /// + /// This indicates whether or not the ClockBound daemon was started with a + /// configuration that supports detecting clock disruptions. + clock_disruption_support_enabled: bool, + + /// Period shift + /// + /// This is a scaling parameter to convert the `period` into a fractional representation with + /// significant digits. + period_shift: u8, + + /// Period error shift + /// + /// This is a scaling parameter to convert the `period_err` into a fractional representation with + /// significant digits. + period_err_shift: u8, + + /// Padding. + _padding: [u8; 5], +} + +impl ClockErrorBoundV3 { + /// Create a new `ClockErrorBound` struct. + #[allow(clippy::too_many_arguments)] + pub fn new( + as_of_tsc: u64, + as_of: TimeSpec, + void_after: TimeSpec, + period: f64, + period_err: f64, + bound_nsec: i64, + disruption_marker: u64, + max_drift_ppb: u32, + clock_status: ClockStatus, + clock_disruption_support_enabled: bool, + ) -> ClockErrorBoundV3 { + // Convert period and period_err into u64 representation + let p_frac = PeriodFrac::from(period); + let p_err_frac = PeriodFrac::from(period_err); + + ClockErrorBoundV3 { + as_of_tsc, + as_of, + void_after, + period_frac: p_frac.frac, + period_err_frac: p_err_frac.frac, + bound_nsec, + disruption_marker, + max_drift_ppb, + clock_status, + clock_disruption_support_enabled, + period_shift: p_frac.shift, + period_err_shift: p_err_frac.shift, + _padding: [0u8; 5], + } + } + + /// Get the oscillator period as a floating point value in seconds. + fn period(&self) -> f64 { + f64::from(PeriodFrac { + frac: self.period_frac, + shift: self.period_shift, + }) + } + + /// Get the oscillator period error as a floating point value. + fn period_err(&self) -> f64 { + f64::from(PeriodFrac { + frac: self.period_err_frac, + shift: self.period_err_shift, + }) + } + + #[allow(clippy::cast_precision_loss)] + #[allow(clippy::cast_possible_truncation)] + fn compute_bound_at_tsc(&self, now_tsc: u64) -> Result { + // Sanity checks: + // - `now()` should operate on a consistent snapshot of the shared memory segment, and + // causality between mono and as_of should be enforced. + // - a extremely high value of the `max_drift_ppb` is a sign of something going wrong + if self.max_drift_ppb >= 1_000_000_000 { + return Err(ShmError::SegmentMalformed); + } + + // Compute the number of TSC cycles between now and the instant the ff-sync clock was + // updated last. This is computed of a TSC value stored in the snapshot, hence this + // duration should never be negative. + let duration_tsc = now_tsc.saturating_sub(self.as_of_tsc); + let duration_nsec = duration_tsc as f64 * self.period() * NANOS_PER_SECOND; + + // Convert the TSC timestamp into seconds with a linear projection. + let now = TimeSpec::nanoseconds( + ((self.as_of.tv_nsec() as f64) + + (NANOS_PER_SECOND * self.as_of.tv_sec() as f64) + + duration_nsec) as i64, + ); + + // Similarly, need to grow the bound on the clock error since the last update. + // This takes into accounts the fact that the ff-sync period is an estimate (polluted by + // measurement noise), and that the underlying oscillator drifts (possibly at a worse + // possible rate) in between consecutive clock adjustments. + let oscillator_err_nsec = duration_nsec * f64::from(self.max_drift_ppb) / NANOS_PER_SECOND; + let p_estimate_err_nsec = duration_nsec * self.period_err() / NANOS_PER_SECOND; + + let updated_bound = TimeSpec::nanoseconds( + (self.bound_nsec as f64 + oscillator_err_nsec + p_estimate_err_nsec) as i64, + ); + + // Build the (earliest, latest) interval within which true time exists. + let earliest = now - updated_bound; + let latest = now + updated_bound; + + // If the ClockErrorBound data has not been updated "recently", the status of the clock + // cannot be guaranteed. Things are ambiguous, the synchronization daemon may be dead, or + // its interaction with the clockbound daemon is broken, or ... In any case, we signal the + // caller that guarantees are gone. We could return an Err here, but choosing to leverage + // ClockStatus instead, and putting the responsibility on the caller to check the clock + // status value being returned. + let duration = TimeSpec::nanoseconds(duration_nsec as i64); + let clock_status = match self.clock_status { + // If the status in the shared memory segment is Unknown or Disrupted, returns that + // status. + ClockStatus::Unknown | ClockStatus::Disrupted => self.clock_status, + + // If the status is Synchronized or FreeRunning, the expectation from the client is + // that the data is useable. However, if the clockbound daemon died or has not update + // the shared memory segment in a while, the status written to the shared memory + // segment may not be reliable anymore. + ClockStatus::Synchronized | ClockStatus::FreeRunning => { + if duration < CLOCKBOUND_RESTART_GRACE_PERIOD { + // Allow for a restart of the daemon, for a short period of time, the status is + // trusted to be correct. + self.clock_status + } else if now < self.void_after { + // Beyond the grace period, for a free running status. + ClockStatus::FreeRunning + } else { + // If beyond void_after, no guarantee is provided anymore. + ClockStatus::Unknown + } + } + }; + + Ok(ClockBoundNowResult { + earliest, + latest, + clock_status, + }) + } +} + +impl ClockBoundSnapshot for ClockErrorBoundV3 { + /// The `ClockErrorBoundV3` implementation of `now()`. + /// + /// This version relies on the system clock to retrieve the current time as well as grow the + /// bound on the clock error at a constant rate. + fn now(&self) -> Result { + let now_tsc = read_timestamp_counter_begin(); + self.compute_bound_at_tsc(now_tsc) + } +} + +struct PeriodFrac { + frac: u64, + shift: u8, +} + +impl PeriodFrac { + /// Calculate the multiplication factor to maximize the number of significant digits when + /// converting the period from a floating point to an integer representation. + /// + /// # Panic: + /// Panic if the period passed is larger that 1 second. + /// + #[allow(clippy::cast_precision_loss)] + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_sign_loss)] + fn calculate_frac_shift(period: f64) -> u8 { + // 1HZ and slower should not be seen. + assert!( + period < 1.0, + "Cannot convert period larger than 1 second: {period}" + ); + + // Protects against the case where a zero period is passed in. + if period == 0_f64 { + return 0_u8; + } + let freq: u64 = (1.0 / period) as u64; + // Cast: at most 64 zeros in a u64, hence can never go over u8::MAX. + (64 - freq.leading_zeros() - 1) as u8 + } +} + +impl From for PeriodFrac { + #[allow(clippy::cast_precision_loss)] + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_sign_loss)] + fn from(value: f64) -> Self { + let shift = PeriodFrac::calculate_frac_shift(value); + // Cast: 64 + 255 unsigned does fit into a i32 without risk of sign error + let scale = 64 + i32::from(shift); + let frac = (value * 2_f64.powi(scale)) as u64; + PeriodFrac { frac, shift } + } +} + +impl From for f64 { + #[allow(clippy::cast_precision_loss)] + #[allow(clippy::cast_possible_truncation)] + fn from(value: PeriodFrac) -> Self { + let denominator = 2_f64.powi(64 + i32::from(value.shift)); + (value.frac as f64) / denominator + } +} + #[cfg(test)] mod t_lib { use super::*; @@ -650,4 +972,189 @@ mod t_lib { assert!(res.is_err()); } + + #[test] + fn test_ceb_v3_new() { + let ceb = ClockErrorBoundV3::new( + 1000, // as_of_tsc + TimeSpec::new(1, 0), // as_of + TimeSpec::new(10, 0), // void_after + 1e-9, // period + 1e-12, // period_err + 5000, // bound_nsec + 42, // disruption_marker + 1000, // max_drift_ppb + ClockStatus::Synchronized, // clock_status + true, // clock_disruption_support_enabled + ); + + assert_eq!(ceb.as_of_tsc, 1000); + assert_eq!(ceb.as_of, TimeSpec::new(1, 0)); + assert_eq!(ceb.void_after, TimeSpec::new(10, 0)); + assert_eq!(ceb.bound_nsec, 5000); + assert_eq!(ceb.disruption_marker, 42); + assert_eq!(ceb.max_drift_ppb, 1000); + assert_eq!(ceb.clock_status, ClockStatus::Synchronized); + assert_eq!(ceb.clock_disruption_support_enabled, true); + + // Test period conversion + let period = ceb.period(); + assert!((period - 1e-9).abs() < 1e-15); + + let period_err = ceb.period_err(); + assert!((period_err - 1e-12).abs() < 1e-18); + } + + #[test] + fn test_ceb_v3_period_conversion() { + let ceb = ClockErrorBoundV3::new( + 0, + TimeSpec::new(0, 0), + TimeSpec::new(10, 0), + 2.5e-9, // 400 MHz + 1e-11, + 1000, + 0, + 1000, + ClockStatus::Synchronized, + true, + ); + + let period = ceb.period(); + let relative_error = (period - 2.5e-9).abs() / 2.5e-9; + assert!(relative_error < 1e-10); + + let period_err = ceb.period_err(); + let relative_error = (period_err - 1e-11).abs() / 1e-11; + assert!(relative_error < 1e-10); + } + + #[test] + fn test_v3_compute_bound_at_tsc_synchronized_status() { + // Create a V3 CEB with known values + let ceb = ClockErrorBoundV3::new( + 1_000_000_000, // as_of_tsc (1 billion cycles) + TimeSpec::new(1, 0), // as_of = 1 second + TimeSpec::new(100, 0), // void_after = 100 seconds + 1e-9, // period = 1 ns (1 GHz clock) + 1e-12, // period_err = 1ps + 10_000, // bound_nsec = 10 microseconds + 0, // disruption_marker + 1000, // max_drift_ppb = 1 ppm + ClockStatus::Synchronized, + true, + ); + + // Simulate reading TSC 2 seconds later (2 billion more cycles at 1 GHz) + let now_tsc = 3_500_000_000; + + let result = ceb.compute_bound_at_tsc(now_tsc).expect("Should succeed"); + + // Expected time: as_of + 2 seconds = 3 seconds + assert_eq!(result.earliest.tv_sec(), 3); // approximately + assert_eq!(result.latest.tv_sec(), 3); // approximately + + // Status should still be Synchronized (within grace period) + assert_eq!(result.clock_status, ClockStatus::Synchronized); + } + + // Assert that typical TSC periods (1Hz to 10 GHz range) are converted into scaled integers + // without a loss of precision. + #[test] + fn test_period_frac_conversion_typical_periods() { + let periods = [1e-3, 1e-6, 1e-7, 1e-8, 1e-9, 2e-9, 5e-9, 1e-10]; + + for &period in &periods { + let frac = PeriodFrac::from(period); + let result: f64 = f64::from(frac); + assert!(result == period); + } + } + + // Assert atypical TSC periods are converted into scaled integers + // with a minimum loss of precision. + #[test] + fn test_period_frac_conversion_edge_cases() { + // Very small period (very high frequency) + let small_period = 1e-25; + let frac = PeriodFrac::from(small_period); + let result: f64 = f64::from(frac); + let relative_error = (result - small_period).abs() / small_period; + assert!(relative_error < 1e-10); + + // Larger period (lower frequency) + let large_period = 0.1; + let frac = PeriodFrac::from(large_period); + let result: f64 = f64::from(frac); + let relative_error = (result - large_period).abs() / large_period; + assert!(relative_error < 1e-10); + } + + // Assert that the conversion panics on non-realistic frequencies. + #[test] + #[should_panic(expected = "Cannot convert period larger than 1 second")] + fn test_period_frac_conversion_panci() { + let large_period = 1.0; + let _ = PeriodFrac::from(large_period); + } + + #[test] + fn test_calculate_frac_shift_typical() { + // For a 1 GHz clock (period = 1e-9), frequency = 1e9 + // 1e9 in binary is about 30 bits, so shift should be around 29 + let period = 1e-9; + let shift = PeriodFrac::calculate_frac_shift(period); + assert!(shift >= 29 && shift <= 30, "shift = {}", shift); + + // For a 2.5 GHz clock (period = 4e-10), frequency = 2.5e9 + // 2.5e9 in binary is about 31 bits + let period = 4e-10; + let shift = PeriodFrac::calculate_frac_shift(period); + assert!(shift >= 30 && shift <= 32, "shift = {}", shift); + } + + #[test] + fn test_calculate_frac_shift_zero() { + // Zero period should return 0 (max of 0 and negative value) + let period = 0.0; + let shift = PeriodFrac::calculate_frac_shift(period); + assert_eq!(shift, 0); + } + + #[test] + fn test_zero_period_frac_conversion() { + // Test that zero period doesn't panic and gives reasonable result + let period = 0.0; + let frac = PeriodFrac::from(period); + assert_eq!(frac.shift, 0); + assert_eq!(frac.frac, 0); + + let result: f64 = f64::from(frac); + assert_eq!(result, 0.0); + } + + #[test] + fn test_precision_maintained() { + // Test that we maintain good precision across conversions + let period = 2.718281828e-9; // Some arbitrary value + let frac = PeriodFrac::from(period); + let result: f64 = f64::from(frac); + + // Should maintain at least 10 significant digits + let relative_error = (result - period).abs() / period; + assert!(relative_error < 1e-10); + } + + #[test] + fn test_frac_representation_property() { + // Test that the fixed-point representation makes sense + let period = 1e-9; + let frac = PeriodFrac::from(period); + + // frac should be non-zero for non-zero period + assert!(frac.frac > 0); + + // shift should be reasonable (not 0 or 255) + assert!(frac.shift > 0 && frac.shift < 64); + } } diff --git a/clock-bound/src/shm/tsc.rs b/clock-bound/src/shm/tsc.rs new file mode 100644 index 0000000..a1d4b95 --- /dev/null +++ b/clock-bound/src/shm/tsc.rs @@ -0,0 +1,99 @@ +//! Module for reading TSC values. +#[cfg_attr(test, mockall::automock)] +pub trait ReadTsc { + fn read_tsc(&self) -> u64; +} +pub struct ReadTscImpl; +impl ReadTsc for ReadTscImpl { + fn read_tsc(&self) -> u64 { + read_timestamp_counter_begin() + } +} + +/// Brackets time-stamp counter read with synchronization barrier instructions. +#[cfg(target_arch = "aarch64")] +#[inline] +pub fn read_timestamp_counter_end() -> u64 { + // aarch64 documentation: https://developer.arm.com/documentation/ddi0601/2021-12/AArch64-Registers/CNTVCT-EL0--Counter-timer-Virtual-Count-register + use std::arch::asm; + + let rv: u64; + unsafe { + asm!("isb; mrs {}, cntvct_el0; isb;", out(reg) rv); + } + rv +} + +/// Brackets time-stamp counter read with synchronization barrier instructions. +#[cfg(target_arch = "aarch64")] +#[inline] +pub fn read_timestamp_counter_begin() -> u64 { + // aarch64 documentation: https://developer.arm.com/documentation/ddi0601/2021-12/AArch64-Registers/CNTVCT-EL0--Counter-timer-Virtual-Count-register + // instruction barrier documentation: https://developer.arm.com/documentation/100941/0101/Barriers + use std::arch::asm; + + let rv: u64; + unsafe { + asm!("isb; mrs {}, cntvct_el0; isb;", out(reg) rv); + } + rv +} + +/// Reads the current value of the processor's time-stamp counter. +#[cfg(target_arch = "x86_64")] +#[inline] +pub fn read_timestamp_counter_begin() -> u64 { + /* + There are a number of options for getting tsc values on x86_64 cpus. + We could get them from the registers ourselves leveraging assembly + ``` + // From: https://oliveryang.net/2015/09/pitfalls-of-TSC-usage/ + static uint64_t rdtsc(void) + { + uint64_t var; + uint32_t hi, lo; + + __asm volatile + ("rdtsc" : "=a" (lo), "=d" (hi)); + + var = ((uint64_t)hi << 32) | lo; + return (var); + } + ``` + + Or we can get them from the llvm libs. + https://doc.rust-lang.org/beta/src/core/stdarch/crates/core_arch/src/x86/rdtsc.rs.html#55 + core::arch::x86_64::_rdtsc; + { + _rdtsc() + } + + I've chosen to get the values from llvm because as I'm confident they are implemented correctly. + */ + // Fencing is discussed in Vol 2B 4-550 of Intel architecture software development manual + use core::arch::x86_64::{_mm_lfence, _rdtsc}; + let tsc; + unsafe { + _mm_lfence(); + tsc = _rdtsc(); + _mm_lfence(); + } + tsc +} + +/// Applies a synchronization barrier then reads the current value of the processor's time-stamp counter. +#[cfg(target_arch = "x86_64")] +#[inline] +pub fn read_timestamp_counter_end() -> u64 { + use core::arch::x86_64::{__rdtscp, _mm_lfence}; + // Fencing is discussed in Vol 2B 4-552 of Intel architecture software development manual + // `__rdtscp` writes the IA32_TSC_AUX value to `aux`. IA32_TSC_AUX is usually the cpu id, but + // the meaning depends on the operating system. Currently, we do not use this value. + let mut aux = 0u32; + let tsc; + unsafe { + tsc = __rdtscp(&raw mut aux); + _mm_lfence(); + } + tsc +} From cad77c4386f7c63a16562d7bcb862e9a0700bebf Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Mon, 17 Nov 2025 14:33:51 -0800 Subject: [PATCH 133/177] [authors] Update the list of authors in Cargo.toml (#156) Get the right list of people. --- Cargo.toml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 58dcdab..894a421 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,17 +17,18 @@ resolver = "3" [workspace.package] authors = [ - "Jacob Wisniewski ", - "Julien Ridoux ", - "Tam Phan ", - "Ryan Luu ", - "Wenhao Piao ", - "Thoth Gunter ", - "Shamik Chakraborty ", + "Jacob Wisniewski ", + "Jennifer Solidum ", + "Julien Ridoux ", "Mohammed Kabir ", - "Myles Neloms ", + "Myles Neloms ", "Nick Matthews ", - "Jennifer Solidum ", + "Ryan Luu ", + "Shamik Chakraborty ", + "Tam Phan ", + "Thoth Gunter ", + "Wei-Han Huang ", + "Wenhao Piao ", ] categories = [ "date-and-time" ] edition = "2024" From cddeed2a12ba26d3949b409222c8dd5075cbfe24 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Mon, 17 Nov 2025 15:04:32 -0800 Subject: [PATCH 134/177] [shm] Add path to new location of the SHM segment (#149) Moving forward, the clockbound daemon will write a SHM segment to more than one location to be backward compatible with previous generation clients. The daemon will have to write to specific locations identifying breaking changes in layout compatibility. On the other side, the latest client is compatible with the latest version it knows, only. This patch introduce new constant default path, and distinguish between the versioned path explicitly known by the daemon, and the default used by the clients. Note that the daemon is not writing to both V0 and V1 paths yet, hence the clients currently default to V0, which will have to be changed. --- clock-bound/src/client.rs | 6 +++--- clock-bound/src/daemon/clock_state.rs | 5 +++-- clock-bound/src/shm.rs | 4 +++- examples/client/rust/src/main.rs | 4 ++-- test/clock-bound-vmclock-client-test/src/main.rs | 4 ++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index 9ebcd38..a09509a 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -1,6 +1,6 @@ //! A client library to communicate with ClockBound daemon. This client library is written in pure Rust. //! -pub use crate::shm::CLOCKBOUND_SHM_DEFAULT_PATH; +pub use crate::shm::CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH; pub use crate::shm::ClockStatus; use crate::shm::ShmReader; use crate::shm::{ClockBoundNowResult, ClockBoundSnapshot, ClockErrorBound, ShmError}; @@ -29,7 +29,7 @@ impl ClockBoundClient { /// # Errors /// Returns [`ClockBoundError`] if the shared memory segments cannot be open or accessed. pub fn new() -> Result { - Self::new_with_path(CLOCKBOUND_SHM_DEFAULT_PATH) + Self::new_with_path(CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH) } /// Creates and returns a new `ClockBoundClient`. @@ -635,7 +635,7 @@ mod lib_tests { #[ignore = "can fail if daemon has run previously with root privs"] fn test_new_sanity_check() { let result = ClockBoundClient::new(); - if Path::new(CLOCKBOUND_SHM_DEFAULT_PATH).exists() { + if Path::new(CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH).exists() { assert!(result.is_ok()); } else { assert!(result.is_err()); diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index c678982..90fcd1b 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -18,7 +18,7 @@ use crate::daemon::clock_state::clock_state_writer::{ClockStateWrite, SafeShmWri use crate::daemon::io::tsc::ReadTscImpl; use crate::daemon::time::ClockExt; use crate::daemon::time::clocks::{ClockBound, RealTime}; -use crate::shm::{CLOCKBOUND_SHM_DEFAULT_PATH, ShmWriter}; +use crate::shm::{CLOCKBOUND_SHM_DEFAULT_PATH_V0, ShmWriter}; /// The whole `ClockState` component struct. /// This encompasses both `ClockAdjust` component which interfaces @@ -56,7 +56,8 @@ impl ClockState { clock_params_receiver: Receiver, cancellation_token: CancellationToken, ) -> Self { - let shm_writer = ShmWriter::new(std::path::Path::new(CLOCKBOUND_SHM_DEFAULT_PATH)).unwrap(); + let shm_writer = + ShmWriter::new(std::path::Path::new(CLOCKBOUND_SHM_DEFAULT_PATH_V0)).unwrap(); let safe_shm_writer = SafeShmWriter::new(shm_writer); let clock_state_writer: ClockStateWriter = ClockStateWriter::builder() .clock_disruption_support_enabled(true) diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index f6247f5..002dd0b 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -27,7 +27,9 @@ use std::error::Error; use std::ffi::CStr; use std::fmt; -pub const CLOCKBOUND_SHM_DEFAULT_PATH: &str = "/var/run/clockbound/shm0"; +pub const CLOCKBOUND_SHM_DEFAULT_PATH_V0: &str = "/var/run/clockbound/shm0"; +pub const CLOCKBOUND_SHM_DEFAULT_PATH_V1: &str = "/var/run/clockbound/shm1"; +pub const CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH: &str = CLOCKBOUND_SHM_DEFAULT_PATH_V0; const CLOCKBOUND_RESTART_GRACE_PERIOD: TimeSpec = TimeSpec::new(5, 0); const NANOS_PER_SECOND: f64 = 1_000_000_000.0; diff --git a/examples/client/rust/src/main.rs b/examples/client/rust/src/main.rs index 74632a7..72e8a30 100644 --- a/examples/client/rust/src/main.rs +++ b/examples/client/rust/src/main.rs @@ -5,7 +5,7 @@ clippy::cast_precision_loss )] use clock_bound::client::{ - CLOCKBOUND_SHM_DEFAULT_PATH, ClockBoundClient, ClockBoundError, ClockStatus, + CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH, ClockBoundClient, ClockBoundError, ClockStatus, VMCLOCK_SHM_DEFAULT_PATH, }; use nix::sys::time::TimeSpec; @@ -13,7 +13,7 @@ use std::process; fn main() { let mut clockbound = match ClockBoundClient::new_with_paths( - CLOCKBOUND_SHM_DEFAULT_PATH, + CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH, VMCLOCK_SHM_DEFAULT_PATH, ) { Ok(c) => c, diff --git a/test/clock-bound-vmclock-client-test/src/main.rs b/test/clock-bound-vmclock-client-test/src/main.rs index 3dd5095..709ac4b 100644 --- a/test/clock-bound-vmclock-client-test/src/main.rs +++ b/test/clock-bound-vmclock-client-test/src/main.rs @@ -1,5 +1,5 @@ use clock_bound::client::{ - CLOCKBOUND_SHM_DEFAULT_PATH, ClockBoundClient, ClockBoundError, ClockStatus, + CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH, ClockBoundClient, ClockBoundError, ClockStatus, VMCLOCK_SHM_DEFAULT_PATH, }; use std::process; @@ -8,7 +8,7 @@ use std::time::Duration; fn main() { let mut clockbound = match ClockBoundClient::new_with_paths( - CLOCKBOUND_SHM_DEFAULT_PATH, + CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH, VMCLOCK_SHM_DEFAULT_PATH, ) { Ok(c) => c, From b3f204a8ece536c2568949e2c39976339d4fa39a Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Tue, 18 Nov 2025 10:32:17 -0500 Subject: [PATCH 135/177] [io] Exit task if VMClock senders all close. (#154) This scenario can happen if a bug occurs during ClockState or ClockSyncAlgorithm, and VMClock is NOT enabled. The SourceIO is the only thing which holds the disruption receiver in this case. If the Daemon actor panics, then the handle_disruption call for every component gets spammed in a loop due to recv always returning an error until the application eventually closes. A better solution to have is to not store a disruption channel in source IO, and optionally include the disruption marker in the sourceio components based on support --- clock-bound/src/daemon/io/link_local.rs | 6 +++++- clock-bound/src/daemon/io/ntp_source.rs | 6 +++++- clock-bound/src/daemon/io/phc.rs | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index dc55377..d8d4d05 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -149,7 +149,11 @@ impl LinkLocal { loop { tokio::select! { biased; // priority order is disruption, commands, and ticks - _ = self.clock_disruption_receiver.changed() => { + val = self.clock_disruption_receiver.changed() => { + if let Err(e) = val { + tracing::error!(?e, "Clock disruption receiver dropped."); + break; + } // Clock Disruption logic here self.handle_disruption(); info!("Received clock disruption signal. Entering Burst mode."); diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index 7a7cc18..aecde11 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -142,7 +142,11 @@ impl NTPSource { loop { tokio::select! { biased; // priority order is disruption, commands, and ticks - _ = self.clock_disruption_receiver.changed() => { + val = self.clock_disruption_receiver.changed() => { + if let Err(e) = val { + tracing::error!(?e, "Clock disruption receiver dropped."); + break; + } self.handle_disruption(); } ctrl_req = self.ctrl_receiver.recv() => { diff --git a/clock-bound/src/daemon/io/phc.rs b/clock-bound/src/daemon/io/phc.rs index bca0deb..6acc74d 100644 --- a/clock-bound/src/daemon/io/phc.rs +++ b/clock-bound/src/daemon/io/phc.rs @@ -529,7 +529,11 @@ impl Phc { loop { tokio::select! { biased; // priority order is disruption, commands, and ticks - _ = self.clock_disruption_receiver.changed() => { + val = self.clock_disruption_receiver.changed() => { + if let Err(e) = val { + tracing::error!(?e, "Clock disruption receiver dropped."); + break; + } // Clock Disruption logic here Self::handle_disruption(); } From b1003ea2f803d01eee8dca43ee134166e8940ef8 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Tue, 18 Nov 2025 11:27:09 -0800 Subject: [PATCH 136/177] [shm] logic to parse the old and new version field semantic (#150) * [shm] logic to parse the old and new version field semantic This is a bit of an ugly implementation to manage the interpretation of the SHM header version field across daemon version. Previous version of the ShmHeader defines the version field as a u16. The new version splits it into two u8 of minimum supported version, and current version of the layout written by the daemon. A better implementation would be to version the ShmHeader with an enum, but this may force a few too many changes at once. Instead this patch adds a few branches to apply the correct semantic, based on the version. This works, but won't be pretty when the number of versions increase. Last this patch focuses primarily on the client side of the change. The ShmWriter has to be refactored next to accept a specific path to write to, with the logic to track the minimum version and the current one. --- clock-bound-ffi/src/lib.rs | 9 +- clock-bound/src/client.rs | 26 ++++- clock-bound/src/shm/reader.rs | 155 +++++++++++++++++++++++++----- clock-bound/src/shm/shm_header.rs | 23 +++-- clock-bound/src/shm/writer.rs | 16 ++- 5 files changed, 184 insertions(+), 45 deletions(-) diff --git a/clock-bound-ffi/src/lib.rs b/clock-bound-ffi/src/lib.rs index 4869f78..3c84878 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -508,7 +508,14 @@ mod t_ffi { .write(true) .open(clockbound_shm_path) .expect("open clockbound file failed"); - write_clockbound_memory_segment!(clockbound_shm_file, 0x414D5A4E, 0x43420200, 800, 2, 10); + write_clockbound_memory_segment!( + clockbound_shm_file, + 0x414D5A4E, + 0x43420200, + 800, + 0x0303, + 10 + ); let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index a09509a..061f997 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -499,7 +499,14 @@ mod lib_tests { .write(true) .open(clockbound_shm_path) .expect("open clockbound file failed"); - write_clockbound_memory_segment!(clockbound_shm_file, 0x414D5A4E, 0x43420200, 800, 2, 10); + write_clockbound_memory_segment!( + clockbound_shm_file, + 0x414D5A4E, + 0x43420200, + 800, + 0x0303, + 10 + ); let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); @@ -643,6 +650,9 @@ mod lib_tests { } #[test] + // FIXME: this will fail until the writer is upgraded + // https://github.com/aws/private-clock-bound-staging/pull/158 + #[ignore = "daemon version mismatch"] fn test_now_clock_error_bound_now_error() { let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); @@ -651,7 +661,15 @@ mod lib_tests { .write(true) .open(clockbound_shm_path) .expect("open clockbound file failed"); - write_clockbound_memory_segment!(clockbound_shm_file, 0x414D5A4E, 0x43420200, 800, 2, 10); + // Writing an older version of the shared memory segmeth, that the writer should overwrite + write_clockbound_memory_segment!( + clockbound_shm_file, + 0x414D5A4E, + 0x43420200, + 800, + 0x0002, + 10 + ); let vmclock_shm_tempfile = NamedTempFile::new().expect("create vmclock file failed"); let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); @@ -689,7 +707,7 @@ mod lib_tests { let mut writer = ShmWriter::new(Path::new(clockbound_shm_path)).expect("Failed to create a writer"); - let ceb = ClockErrorBoundGeneric::builder().build(ClockErrorBoundLayoutVersion::V2); + let ceb = ClockErrorBoundGeneric::builder().build(ClockErrorBoundLayoutVersion::V3); writer.write(&ceb); let mut clockbound = @@ -713,7 +731,7 @@ mod lib_tests { .max_drift_ppb(1_000_000_000) .clock_status(ClockStatus::Synchronized) .clock_disruption_support_enabled(true) - .build(ClockErrorBoundLayoutVersion::V2); + .build(ClockErrorBoundLayoutVersion::V3); writer.write(&ceb); // Validate now has Result with an error. diff --git a/clock-bound/src/shm/reader.rs b/clock-bound/src/shm/reader.rs index 43a39fc..83b1d73 100644 --- a/clock-bound/src/shm/reader.rs +++ b/clock-bound/src/shm/reader.rs @@ -4,9 +4,9 @@ use errno::{Errno, errno}; use std::ffi::{CStr, c_void}; use std::mem::size_of; use std::ptr; -use std::sync::atomic; +use std::sync::atomic::{self, AtomicU16}; -use crate::shm::shm_header::{CLOCKBOUND_SHM_SUPPORTED_VERSION, ShmHeader}; +use crate::shm::shm_header::{CLOCKBOUND_SHM_LATEST_VERSION, ShmHeader}; use crate::{ shm::{ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ShmError}, syserror, @@ -161,6 +161,86 @@ impl ShmReader { /// returned. #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn new(path: &CStr) -> Result { + // Map the segment, with explicit pointers to fields + let (mmap_guard, version, generation, ceb_shm) = ShmReader::map_segment(path)?; + + // Atomically read the current version in the shared memory segment + // SAFETY: `self.version` has been validated when creating the reader + let shm_version = unsafe { &*version }; + let shm_version = shm_version.load(atomic::Ordering::Acquire); + + // FIXME: the ShmHeader should be versioned behind an enum instead. + let min_version = shm_version >> 8; + let max_version = shm_version & 0x00ff; + if CLOCKBOUND_SHM_LATEST_VERSION < min_version + || CLOCKBOUND_SHM_LATEST_VERSION > max_version + { + return Err(ShmError::SegmentVersionNotSupported); + } + let shm_version = ClockErrorBoundLayoutVersion::try_from(CLOCKBOUND_SHM_LATEST_VERSION)?; + + Ok(ShmReader { + _marker: std::marker::PhantomData, + _guard: mmap_guard, + version, + generation, + ceb_shm, + snapshot_ceb: ClockErrorBoundGeneric::builder().build(shm_version), + snapshot_gen: 0, + }) + } + + /// Open a ClockBound shared memory segment for reading. + /// + /// On error, returns an appropriate `Errno`. If the content of the segment + /// is uninitialized, unparseable, or otherwise malformed, EPROTO will be + /// returned. + #[expect(clippy::missing_errors_doc, reason = "todo")] + pub fn new_with_max_version_unchecked(path: &CStr) -> Result<(ShmReader, u16), ShmError> { + // Map the segment, with explicit pointers to fields + let (mmap_guard, version, generation, ceb_shm) = ShmReader::map_segment(path)?; + + // Atomically read the current version from the shared memory segment + // SAFETY: `self.version` has been validated when creating the reader + let shm_version = unsafe { &*version }; + let shm_version = shm_version.load(atomic::Ordering::Acquire); + let max_version = shm_version & 0x00ff; + + let current_version = + ClockErrorBoundLayoutVersion::try_from(CLOCKBOUND_SHM_LATEST_VERSION)?; + + Ok(( + ShmReader { + _marker: std::marker::PhantomData, + _guard: mmap_guard, + version, + generation, + ceb_shm, + snapshot_ceb: ClockErrorBoundGeneric::builder().build(current_version), + snapshot_gen: 0, + }, + max_version, + )) + } + + /// Open and map the ClockBound shared memory segment. + /// + /// Make sure the file can be open, and that the raw pointers into memory are set. + /// + /// # Errors + /// + /// Returns `ShmError` if the path is not found, the file cannot be open or sanity checks fail. + fn map_segment( + path: &CStr, + ) -> Result< + ( + MmapGuard, + *const AtomicU16, + *const AtomicU16, + *const ClockErrorBound, + ), + ShmError, + > { let fdguard = FdGuard::new(path)?; let mmap_guard = MmapGuard::new(&fdguard)?; @@ -184,21 +264,7 @@ impl ShmReader { cursor = unsafe { cursor.add(size_of::()) }; let ceb_shm = ptr::addr_of!(*cursor.cast::()); - // Atomically read the current version in the shared memory segment - // SAFETY: `self.version` has been validated when creating the reader - let shm_version = unsafe { &*version }; - let shm_version = shm_version.load(atomic::Ordering::Acquire); - let shm_version = ClockErrorBoundLayoutVersion::try_from(shm_version)?; - - Ok(ShmReader { - _marker: std::marker::PhantomData, - _guard: mmap_guard, - version, - generation, - ceb_shm, - snapshot_ceb: ClockErrorBoundGeneric::builder().build(shm_version), - snapshot_gen: 0, - }) + Ok((mmap_guard, version, generation, ceb_shm)) } /// Return a consistent snapshot of the shared memory segment. @@ -226,10 +292,18 @@ impl ShmReader { // returned to the caller to take appropriate action (e.g. assert clock status). if version == 0 { return Ok(&self.snapshot_ceb); - } else if version != CLOCKBOUND_SHM_SUPPORTED_VERSION { - eprintln!( - "ClockBound shared memory segment has version {version:?} which is not supported by this software." - ); + } + + // It is possible the daemon has been upgraded and restarted. It is meant to be backward + // compatible when writing to the default path to the shared memory segment. That is, the + // current version written by the daemon may have incremented, but the minimum version + // supported must ours or lower. In the other direction, if the clockbound daemon was + // downgraded, also have to report errors, since expected features may be missing. + let min_version = version >> 8; + let max_version = version & 0x00ff; + if CLOCKBOUND_SHM_LATEST_VERSION < min_version + || CLOCKBOUND_SHM_LATEST_VERSION < max_version + { return Err(ShmError::SegmentVersionNotSupported); } @@ -376,7 +450,36 @@ mod t_reader { /// Assert that the reader can map a file. #[test] - fn test_reader_new() { + fn test_reader_new_shm_v2() { + let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); + let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); + let clockbound_shm_path = clockbound_shm_temppath.to_str().unwrap(); + let mut clockbound_shm_file = OpenOptions::new() + .write(true) + .open(clockbound_shm_path) + .expect("open clockbound file failed"); + write_memory_segment!( + clockbound_shm_file, + 0x414D5A4E, + 0x43420200, + 400, + 0x0002, + 10, + (0, 0), + (0, 0), + 123, + 0 + ); + let path = CString::new(clockbound_shm_path).expect("CString failed"); + + // This should fail with a version mismatch error + let res = ShmReader::new(&path); + assert!(res.is_err()); + } + + /// Assert that the reader can map a file. + #[test] + fn test_reader_new_shm_v3() { let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); let clockbound_shm_path = clockbound_shm_temppath.to_str().unwrap(); @@ -389,7 +492,7 @@ mod t_reader { 0x414D5A4E, 0x43420200, 400, - 2, + 0x0303, 10, (0, 0), (0, 0), @@ -404,7 +507,7 @@ mod t_reader { let generation = unsafe { &*reader.generation }; let ceb = unsafe { *reader.ceb_shm }; - assert_eq!(version.load(atomic::Ordering::Relaxed), 2); + assert_eq!(version.load(atomic::Ordering::Relaxed), 0x0303); assert_eq!(generation.load(atomic::Ordering::Relaxed), 10); assert_eq!(ceb.bound_nsec(), 123); } @@ -459,7 +562,7 @@ mod t_reader { 0x414D5A4E, 0x43420200, 400, - 2, + 0x0303, 10, (0, 0), (0, 0), @@ -470,7 +573,7 @@ mod t_reader { let path = CString::new(clockbound_shm_path).expect("CString failed"); let mut reader = ShmReader::new(&path).expect("Failed to create ShmReader"); let version = unsafe { &*reader.version }; - assert_eq!(version.load(atomic::Ordering::Relaxed), 2); + assert_eq!(version.load(atomic::Ordering::Relaxed), 0x0303); // Assert that snapshot works without an error with this supported version. let result = reader.snapshot(); diff --git a/clock-bound/src/shm/shm_header.rs b/clock-bound/src/shm/shm_header.rs index b94eec5..aa42732 100644 --- a/clock-bound/src/shm/shm_header.rs +++ b/clock-bound/src/shm/shm_header.rs @@ -9,6 +9,7 @@ pub const SHM_MAGIC: [u32; 2] = [0x414D_5A4E, 0x4342_0200]; /// Version of the ClockBound shared memory segment layout that is supported by this /// implementation of ClockBound. pub const CLOCKBOUND_SHM_SUPPORTED_VERSION: u16 = 2_u16; +pub const CLOCKBOUND_SHM_LATEST_VERSION: u16 = 3_u16; /// Header structure to the Shared Memory segment where the `ClockErrorBound` data is kept. /// @@ -72,7 +73,15 @@ impl ShmHeader { /// Check whether the header is marked with a valid version fn has_valid_version(&self) -> bool { let version = self.version.load(atomic::Ordering::Relaxed); - version > 0 + + // FIXME: the ShmHeader should be versioned behind an enum instead. + let min_version = version >> 8; + let cur_version = version & 0x00ff; + match cur_version { + 2 => min_version == 0, + 3 => min_version == 3, + _ => false, + } } /// Check whether the header is initialized @@ -94,23 +103,13 @@ impl ShmHeader { } if !self.has_valid_version() { - return Err(ShmError::SegmentNotInitialized); + return Err(ShmError::SegmentVersionNotSupported); } if !self.is_initialized() { return Err(ShmError::SegmentNotInitialized); } - // Check if the ClockBound shared memory segment has a version that is - // supported by this implementation of ClockBound. - let version = self.version.load(atomic::Ordering::Relaxed); - if version != CLOCKBOUND_SHM_SUPPORTED_VERSION { - eprintln!( - "ClockBound shared memory segment has version {version:?} which is not supported by this software." - ); - return Err(ShmError::SegmentVersionNotSupported); - } - if !self.is_well_formed() { return Err(ShmError::SegmentMalformed); } diff --git a/clock-bound/src/shm/writer.rs b/clock-bound/src/shm/writer.rs index 4740400..f9a4271 100644 --- a/clock-bound/src/shm/writer.rs +++ b/clock-bound/src/shm/writer.rs @@ -128,8 +128,14 @@ impl ShmWriter { let path_cstring = CString::new(path.as_os_str().as_bytes()) .map_err(|_| ShmError::SegmentNotInitialized)?; - match ShmReader::new(path_cstring.as_c_str()) { - Ok(_reader) => Ok(()), + match ShmReader::new_with_max_version_unchecked(path_cstring.as_c_str()) { + Ok((_reader, max_version)) => { + if max_version == CLOCKBOUND_SHM_SUPPORTED_VERSION { + Ok(()) + } else { + Err(ShmError::SegmentVersionNotSupported) + } + } Err(err) => Err(err), } } @@ -341,6 +347,9 @@ mod t_writer { /// Assert that a new memory mapped segment is created it does not exist. #[test] + // FIXME: this will fail until the writer is upgraded + // https://github.com/aws/private-clock-bound-staging/pull/158 + #[ignore = "daemon version mismatch"] fn test_writer_create_new_if_not_exist() { let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); @@ -365,6 +374,9 @@ mod t_writer { /// Assert that an existing memory mapped segment is wiped clean if dirty. #[test] + // FIXME: this will fail until the writer is upgraded + // https://github.com/aws/private-clock-bound-staging/pull/158 + #[ignore = "daemon version mismatch"] fn test_writer_wipe_clean_on_new() { let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); From e35950f003cde8f2db663233a620e737f1bb0bd3 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Tue, 18 Nov 2025 15:19:26 -0500 Subject: [PATCH 137/177] Metrics for clock offsets (#120) This commit adds metric emission of offsets between CLOCK_REALTIME, CLOCK_MONOTONIC_RAW, and ClockBound clock if parameters are supplied in ClockState on a 1 second interval. This should allow us to find if any of the clocks significantly disagree and which of them might be acting uncharacteristically. --- clock-bound/src/daemon/clock_state.rs | 32 +++++++++++++++++++++++++- clock-bound/src/daemon/subscriber.rs | 19 ++++++++++++++- clock-bound/src/daemon/time/inner.rs | 3 ++- clock-bound/src/daemon/time/instant.rs | 5 +++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 90fcd1b..6215e1e 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -16,8 +16,9 @@ use crate::daemon::clock_state::clock_adjust::{ClockAdjust, ClockAdjuster}; use crate::daemon::clock_state::clock_state_writer::ClockStateWriter; use crate::daemon::clock_state::clock_state_writer::{ClockStateWrite, SafeShmWriter}; use crate::daemon::io::tsc::ReadTscImpl; +use crate::daemon::subscriber::CLOCK_METRICS_TARGET; use crate::daemon::time::ClockExt; -use crate::daemon::time::clocks::{ClockBound, RealTime}; +use crate::daemon::time::clocks::{ClockBound, MonotonicRaw, RealTime}; use crate::shm::{CLOCKBOUND_SHM_DEFAULT_PATH_V0, ShmWriter}; /// The whole `ClockState` component struct. @@ -81,9 +82,14 @@ impl ClockState { } pub async fn run(&mut self) { + let mut clock_offset_metric_interval = + tokio::time::interval(tokio::time::Duration::from_secs(1)); info!("Starting run for ClockState"); loop { tokio::select! { + _ = clock_offset_metric_interval.tick() => { + self.emit_clock_offsets(); + }, now = self.interval.tick() => { self.handle_tick(now); }, @@ -101,6 +107,30 @@ impl ClockState { info!("ClockState runner exiting."); } + fn emit_clock_offsets(&self) { + let realtime_to_monotonic_raw = RealTime.get_offset_and_rtt(&MonotonicRaw); + tracing::info!( + target: CLOCK_METRICS_TARGET, + realtime_to_monotonic_raw = serde_json::to_string(&realtime_to_monotonic_raw).unwrap(), + "metrics" + ); + if let Some(parameters) = &self.clock_parameters { + let clockbound_clock = ClockBound::new(parameters.clone(), ReadTscImpl); + let clockbound_to_realtime = clockbound_clock.get_offset_and_rtt(&RealTime); + let clockbound_to_monotonic_raw = clockbound_clock.get_offset_and_rtt(&MonotonicRaw); + tracing::info!( + target: CLOCK_METRICS_TARGET, + clockbound_to_realtime = serde_json::to_string(&clockbound_to_realtime).unwrap(), + "metrics" + ); + tracing::info!( + target: CLOCK_METRICS_TARGET, + clockbound_to_monotonic_raw = serde_json::to_string(&clockbound_to_monotonic_raw).unwrap(), + "metrics" + ); + } + } + fn handle_tick(&mut self, now: tokio::time::Instant) { if let Some(parameters) = &self.clock_parameters { self.clock_adjuster.handle_clock_parameters(now, parameters); diff --git a/clock-bound/src/daemon/subscriber.rs b/clock-bound/src/daemon/subscriber.rs index 79ea2ab..fd68bef 100644 --- a/clock-bound/src/daemon/subscriber.rs +++ b/clock-bound/src/daemon/subscriber.rs @@ -13,6 +13,7 @@ use tracing_subscriber::{ }; pub const PRIMER_TARGET: &str = "clock_bound::primer"; +pub const CLOCK_METRICS_TARGET: &str = "clock_bound::clock_metrics"; /// Initialize the tracing subscriber /// @@ -26,6 +27,15 @@ pub fn init(log_directory: impl AsRef) { .with_writer(primer_writer) .with_filter(filter_fn(|md| md.target().starts_with(PRIMER_TARGET))); + let clock_metrics_writer = + tracing_appender::rolling::never(&log_directory, "clock_metrics.log"); + let clock_metrics_layer = tracing_subscriber::fmt::layer() + .json() + .with_writer(clock_metrics_writer) + .with_filter(filter_fn(|md| { + md.target().starts_with(CLOCK_METRICS_TARGET) + })); + let log_layer = tracing_subscriber::fmt::layer() .with_writer(std::io::stdout) // this is the default, just making it explicit .with_filter( @@ -33,16 +43,23 @@ pub fn init(log_directory: impl AsRef) { .with_default_directive(Level::INFO.into()) .from_env_lossy(), ) - .with_filter(filter_fn(|md| !md.target().starts_with(PRIMER_TARGET))); + .with_filter(filter_fn(|md| !md.target().starts_with(PRIMER_TARGET))) + .with_filter(filter_fn(|md| { + !md.target().starts_with(CLOCK_METRICS_TARGET) + })); tracing_subscriber::registry() // this is the default logging layer .with(log_layer) // and this is the primer reproducibility layer .with(primer_layer) + // and this is the clock metrics layer + .with(clock_metrics_layer) .init(); tracing::info!("Initialized tracing subscriber"); let primer_log_file = PathBuf::from(log_directory.as_ref()).join("primer.log"); tracing::info!(primer_log_file = %primer_log_file.display(), "Primer log file"); + let clock_metrics_log_file = PathBuf::from(log_directory.as_ref()).join("clock_metrics.log"); + tracing::info!(clock_metrics_log_file = %clock_metrics_log_file.display(), "Clock metrics log file"); } diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index 1ee592b..b92d0c8 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -12,6 +12,7 @@ use std::{ use libc::timeval; use nix::sys::time::TimeSpec; +use serde::{Deserialize, Serialize}; /// Abstraction used to reuse basic time arithmetic, but allow for different types based on its usage pub trait Type {} @@ -31,7 +32,7 @@ pub trait FemtoType: Type { /// reads and the `other_clock_t2` reads. /// The comparison is bounded by the round-trip-time of this measurement, and thus is useful for /// determining the quality of the sample or bounding the clock error. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ClockOffsetAndRtt { /// Offset offset: Diff, diff --git a/clock-bound/src/daemon/time/instant.rs b/clock-bound/src/daemon/time/instant.rs index 78e0826..f9d8e7f 100644 --- a/clock-bound/src/daemon/time/instant.rs +++ b/clock-bound/src/daemon/time/instant.rs @@ -1,10 +1,13 @@ //! A simplified time type for `ClockBound` use nix::sys::time::TimeSpec; +use serde::{Deserialize, Serialize}; use super::inner::{Diff, Time}; /// Marker type to signify a time as a timestamp -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, +)] pub struct Utc; impl super::inner::Type for Utc {} From 5265574cf4984d5c5138fbabcdb03de0136c6e55 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Tue, 18 Nov 2025 12:35:55 -0800 Subject: [PATCH 138/177] [writer] Add a second writer to write to supported SHM paths (#158) This patch adds a second, dedicated writer, to the ClockStateWriter struct so that both supported locations of shared memory segments are written to. The path to */shm0 path is where the layout version 2 is written. Whereas a layout version 3 is written to */shm1. This patch leaves a few FIXME that will be addressed separately to keep a narrow scope with that change. --- clock-bound/src/client.rs | 8 +- clock-bound/src/daemon/clock_state.rs | 55 +++++- .../daemon/clock_state/clock_state_writer.rs | 186 +++++++++++++++--- clock-bound/src/shm.rs | 9 + clock-bound/src/shm/reader.rs | 21 +- clock-bound/src/shm/writer.rs | 76 ++++--- 6 files changed, 292 insertions(+), 63 deletions(-) diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index 061f997..b91a5b3 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -704,8 +704,12 @@ mod lib_tests { }; write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); - let mut writer = - ShmWriter::new(Path::new(clockbound_shm_path)).expect("Failed to create a writer"); + let mut writer = ShmWriter::new( + Path::new(clockbound_shm_path), + ClockErrorBoundLayoutVersion::V2, + ClockErrorBoundLayoutVersion::V2, + ) + .expect("Failed to create a writer"); let ceb = ClockErrorBoundGeneric::builder().build(ClockErrorBoundLayoutVersion::V3); writer.write(&ceb); diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 6215e1e..7f172a3 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -2,6 +2,7 @@ pub mod clock_adjust; pub mod clock_state_writer; +use std::path::Path; use tokio_util::sync::CancellationToken; use tracing::info; @@ -19,7 +20,10 @@ use crate::daemon::io::tsc::ReadTscImpl; use crate::daemon::subscriber::CLOCK_METRICS_TARGET; use crate::daemon::time::ClockExt; use crate::daemon::time::clocks::{ClockBound, MonotonicRaw, RealTime}; -use crate::shm::{CLOCKBOUND_SHM_DEFAULT_PATH_V0, ShmWriter}; +use crate::shm::{ + CLOCKBOUND_SHM_DEFAULT_PATH_V0, CLOCKBOUND_SHM_DEFAULT_PATH_V1, ClockErrorBoundLayoutVersion, + ShmWriter, +}; /// The whole `ClockState` component struct. /// This encompasses both `ClockAdjust` component which interfaces @@ -57,12 +61,31 @@ impl ClockState { clock_params_receiver: Receiver, cancellation_token: CancellationToken, ) -> Self { - let shm_writer = - ShmWriter::new(std::path::Path::new(CLOCKBOUND_SHM_DEFAULT_PATH_V0)).unwrap(); - let safe_shm_writer = SafeShmWriter::new(shm_writer); + // Build two writers, each writing to a specific shared memory segment path. + // + // FIXME: given these path are const strings, would be worth looking into moving the + // creation of these writers to the ClockStateWriter::new() method. + // + let shm_writer_0 = ShmWriter::new( + Path::new(CLOCKBOUND_SHM_DEFAULT_PATH_V0), + ClockErrorBoundLayoutVersion::V2, + ClockErrorBoundLayoutVersion::V2, + ) + .unwrap(); + let safe_shm_writer_0 = SafeShmWriter::new(shm_writer_0); + + let shm_writer_1 = ShmWriter::new( + Path::new(CLOCKBOUND_SHM_DEFAULT_PATH_V1), + ClockErrorBoundLayoutVersion::V3, + ClockErrorBoundLayoutVersion::V3, + ) + .unwrap(); + let safe_shm_writer_1 = SafeShmWriter::new(shm_writer_1); + let clock_state_writer: ClockStateWriter = ClockStateWriter::builder() .clock_disruption_support_enabled(true) - .shm_writer(safe_shm_writer) + .shm_writer_0(safe_shm_writer_0) + .shm_writer_1(safe_shm_writer_1) .max_drift_ppb(MAX_DISPERSION_GROWTH_PPB) .disruption_marker(0) .build(); @@ -179,10 +202,16 @@ impl ClockState { clock_parameters, cancellation_token: _, } = self; + + // Update the clock status on the shared memory segments + if let Some(params) = clock_parameters { + clock_state_writer.handle_disruption(params, new_disruption_marker); + } + *clock_parameters = None; clock_params_receiver.handle_disruption(); clock_adjuster.handle_disruption(new_disruption_marker); - clock_state_writer.handle_disruption(new_disruption_marker); + tracing::info!("Handled clock disruption event"); } } @@ -245,6 +274,14 @@ mod tests { let disruption_marker = 123; let cancellation_token = CancellationToken::new(); let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); + let clock_parameters = ClockParameters { + tsc_count: TscCount::new(1000), + time: Instant::from_nanos(1000), + clock_error_bound: Duration::from_nanos(1000), + period: Period::from_seconds(1e-9), + period_max_error: Period::from_seconds(1e-11), + }; + let expected_clock_parameters = clock_parameters.clone(); mock_clock_adjuster .expect_handle_disruption() .once() @@ -254,7 +291,9 @@ mod tests { mock_clock_state_writer .expect_handle_disruption() .once() - .with(eq(disruption_marker)) + .withf(move |param: &ClockParameters, marker: &u64| { + *param == expected_clock_parameters && *marker == 123 + }) .return_const(()); let (_tx, rx) = async_ring_buffer::create(1); let mut clock_state = ClockState::new( @@ -263,6 +302,8 @@ mod tests { rx, cancellation_token, ); + clock_state.clock_parameters = Some(clock_parameters); + clock_state.handle_disruption(disruption_marker); } diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index 3171109..3cf012a 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -37,7 +37,10 @@ unsafe impl Sync for SafeShmWriter {} pub struct ClockStateWriter { clock_disruption_support_enabled: bool, - shm_writer: T, + // Writer to the */shm0 memory segment path + shm_writer_0: T, + // Writer to the */shm1 memory segment path + shm_writer_1: T, max_drift_ppb: u32, disruption_marker: u64, } @@ -50,7 +53,7 @@ pub trait ClockStateWrite: Send + Sync { clock_status: ClockStatus, clock_realtime_offset_and_rtt: ClockOffsetAndRtt, ); - fn handle_disruption(&mut self, new_disruption_marker: u64); + fn handle_disruption(&mut self, clock_parameters: &ClockParameters, new_disruption_marker: u64); } impl ClockStateWrite for ClockStateWriter { @@ -64,6 +67,11 @@ impl ClockStateWrite for ClockStateWriter { clock_status: ClockStatus, clock_realtime_offset_and_rtt: ClockOffsetAndRtt, ) { + // Write to the shared memory segment shm1 first... + self.write_shm1(clock_parameters, clock_status); + + // .. and then write to the legacy shared memory segment shm0, of which the client relies + // on the system clock.. let bound = get_bound(clock_parameters, clock_realtime_offset_and_rtt); // Unwrap safety: sane error bound should be less than `i64::MAX` let bound_nsec = i64::try_from(bound.as_nanos()).unwrap(); @@ -75,6 +83,10 @@ impl ClockStateWrite for ClockStateWriter { // For the sake of backwards compatibility, we will have our initial/alpha release continue to work // using `CLOCK_MONOTONIC_COARSE`. let as_of = MonotonicCoarse.get_time(); + + // FIXME: revisit whether we want to add this to the max_drift_ppb, since that is not + // exactly the previous behavior. If not, this would simplify this block, avoiding to have + // the writer logic implement something that should be in the ff-sync one. let software_skew = Skew::from_period_and_error(clock_parameters.period, clock_parameters.period_max_error); let Some(software_skew_ppb) = software_skew.to_ppb() else { @@ -84,7 +96,7 @@ impl ClockStateWrite for ClockStateWriter { return; }; let max_drift_ppb = self.max_drift_ppb + software_skew_ppb; - self.write_shm(as_of, bound_nsec, clock_status, max_drift_ppb); + self.write_shm0(as_of, bound_nsec, clock_status, max_drift_ppb); } /// Handle a clock disruption event @@ -92,23 +104,37 @@ impl ClockStateWrite for ClockStateWriter { /// Call this function after the system detects a VMClock disruption event. /// /// It will go through and clear the state (like startup). - fn handle_disruption(&mut self, new_disruption_marker: u64) { + fn handle_disruption( + &mut self, + clock_parameters: &ClockParameters, + new_disruption_marker: u64, + ) { // Use the destructure pattern to get a mutable reference to each item. // // This makes it a compilation error if we add a new field to Self without handling it here let Self { clock_disruption_support_enabled: _, - shm_writer: _, + shm_writer_0: _, + shm_writer_1: _, max_drift_ppb: _, disruption_marker, } = self; *disruption_marker = new_disruption_marker; + + // Write shm1 + self.write_shm1(clock_parameters, ClockStatus::Disrupted); + + // Write to shm0 let as_of = MonotonicCoarse.get_time(); info!( "Writing `ClockStatus::Disrupted` to SHM with 0 `bound_nsec` and `ClockStatus::Disrupted`" ); + + // FIXME: the behavior when writing a V2 layout to shm0 is to only overwrite the clock + // status only. Comment on line below and code to be fixed. // We're writing that we're disrupted anyways, so the `bound_nsec` value should be useless here, 0 is ok - self.write_shm(as_of, 0, ClockStatus::Disrupted, 0); + self.write_shm0(as_of, 0, ClockStatus::Disrupted, 0); + tracing::info!("Handled clock disruption event"); } } @@ -118,19 +144,24 @@ impl ClockStateWriter { #[builder] pub fn new( clock_disruption_support_enabled: bool, - shm_writer: T, + shm_writer_0: T, + shm_writer_1: T, max_drift_ppb: u32, disruption_marker: u64, ) -> Self { Self { clock_disruption_support_enabled, - shm_writer, + shm_writer_0, + shm_writer_1, max_drift_ppb, disruption_marker, } } - fn write_shm( + /// Write out to the ClockBound daemon shared memory segment shm0. + /// + /// Writes the latest supported layout version (V2) of the shared memory segment. + fn write_shm0( &mut self, as_of: Instant, bound_nsec: i64, @@ -159,8 +190,35 @@ impl ClockStateWriter { .clock_status(clock_status) .clock_disruption_support_enabled(self.clock_disruption_support_enabled) .build(ClockErrorBoundLayoutVersion::V2); + self.shm_writer_0.write(&ceb); + } + + /// Write out to the ClockBound daemon shared memory segment shm1. + /// + /// Writes the latest supported layout version (V3) of the shared memory segment. + fn write_shm1(&mut self, clock_parameters: &ClockParameters, clock_status: ClockStatus) { + let bound_nsec = i64::try_from(clock_parameters.clock_error_bound.as_nanos()).unwrap(); - self.shm_writer.write(&ceb); + // Unwrap safety: unlikely to fail for any value for the distant future, + // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds + let as_of = TimeSpec::try_from(clock_parameters.time).unwrap(); + let void_after = as_of + TimeSpec::new(1000, 0); + + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_sign_loss)] + let ceb = ClockErrorBoundGeneric::builder() + .as_of_tsc(clock_parameters.tsc_count.get() as u64) + .as_of(as_of) + .void_after(void_after) + .bound_nsec(bound_nsec) + .period(clock_parameters.period.get()) + .period_err(clock_parameters.period_max_error.get()) + .disruption_marker(self.disruption_marker) + .max_drift_ppb(self.max_drift_ppb) + .clock_status(clock_status) + .clock_disruption_support_enabled(self.clock_disruption_support_enabled) + .build(ClockErrorBoundLayoutVersion::V3); + self.shm_writer_1.write(&ceb); } } @@ -235,7 +293,7 @@ mod tests { let max_drift_ppb = 15_000; let disruption_marker = 345; let clock_disruption_support_enabled = true; - let expected_ceb = ClockErrorBoundGeneric::builder() + let expected_ceb_v2 = ClockErrorBoundGeneric::builder() .as_of(TimeSpec::try_from(as_of).unwrap()) .void_after(TimeSpec::try_from(as_of + Duration::from_secs(1000)).unwrap()) .bound_nsec(bound_nsec) @@ -244,19 +302,50 @@ mod tests { .clock_status(clock_status) .clock_disruption_support_enabled(clock_disruption_support_enabled) .build(ClockErrorBoundLayoutVersion::V2); - let mut shm_writer = MockShmWriter::new(); - shm_writer + let mut shm_writer_0 = MockShmWriter::new(); + shm_writer_0 + .expect_write() + .withf(move |ceb: &ClockErrorBound| expected_ceb_v2 == *ceb) + .times(1) + .return_const(()); + + let clock_parameters = create_test_clock_parameters() + .tsc_count(1_000_000) + .clock_error_bound_nanos(500) + .time_nanos(3_000_000_000) + .call(); + + let expected_ceb_v3 = ClockErrorBoundGeneric::builder() + .as_of_tsc(1_000_000) + .as_of(TimeSpec::try_from(Instant::from_nanos(3_000_000_000)).unwrap()) + .void_after( + TimeSpec::try_from(Instant::from_nanos(3_000_000_000) + Duration::from_secs(1000)) + .unwrap(), + ) + .period(1e-9) + .period_err(1e-11) + .bound_nsec(500) + .disruption_marker(disruption_marker) + .max_drift_ppb(max_drift_ppb) + .clock_status(clock_status) + .clock_disruption_support_enabled(clock_disruption_support_enabled) + .build(ClockErrorBoundLayoutVersion::V3); + let mut shm_writer_1 = MockShmWriter::new(); + shm_writer_1 .expect_write() - .withf(move |ceb: &ClockErrorBound| expected_ceb == *ceb) + .withf(move |ceb: &ClockErrorBound| expected_ceb_v3 == *ceb) .times(1) .return_const(()); + let mut clock_state_writer = ClockStateWriter::builder() .clock_disruption_support_enabled(clock_disruption_support_enabled) - .shm_writer(shm_writer) + .shm_writer_0(shm_writer_0) + .shm_writer_1(shm_writer_1) .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer.write_shm(as_of, bound_nsec, clock_status, max_drift_ppb); + clock_state_writer.write_shm0(as_of, bound_nsec, clock_status, max_drift_ppb); + clock_state_writer.write_shm1(&clock_parameters, clock_status); } #[test] @@ -264,7 +353,7 @@ mod tests { let clock_parameters = create_test_clock_parameters() .clock_error_bound_nanos(1000) .tsc_count(1000) - .time_nanos(1_000_000_000) + .time_nanos(123_000) .call(); let clock_realtime_offset_and_rtt = create_test_clock_offset_and_rtt() .offset_nanos(1000) @@ -274,8 +363,9 @@ mod tests { let max_drift_ppb = 0; let disruption_marker = 0; let clock_status = ClockStatus::Synchronized; - let mut shm_writer = MockShmWriter::new(); - shm_writer + + let mut shm_writer_0 = MockShmWriter::new(); + shm_writer_0 .expect_write() .withf(move |ceb: &ClockErrorBound| { ceb.void_after() @@ -287,9 +377,25 @@ mod tests { }) .times(1) .return_const(()); + + let mut shm_writer_1 = MockShmWriter::new(); + shm_writer_1 + .expect_write() + .withf(move |ceb: &ClockErrorBound| { + ceb.void_after() + == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) + && ceb.bound_nsec() == 1000 + && ceb.disruption_marker() == disruption_marker + && ceb.clock_status() == clock_status + && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled + }) + .times(1) + .return_const(()); + let mut clock_state_writer = ClockStateWriter::builder() .clock_disruption_support_enabled(clock_disruption_support_enabled) - .shm_writer(shm_writer) + .shm_writer_0(shm_writer_0) + .shm_writer_1(shm_writer_1) .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); @@ -315,11 +421,14 @@ mod tests { let clock_disruption_support_enabled = false; let max_drift_ppb = 0; let disruption_marker = 0; - let mut shm_writer = MockShmWriter::new(); - shm_writer.expect_write().never(); + let mut shm_writer_0 = MockShmWriter::new(); + shm_writer_0.expect_write().never(); + let mut shm_writer_1 = MockShmWriter::new(); + shm_writer_1.expect_write().never(); let mut clock_state_writer = ClockStateWriter::builder() .clock_disruption_support_enabled(clock_disruption_support_enabled) - .shm_writer(shm_writer) + .shm_writer_0(shm_writer_0) + .shm_writer_1(shm_writer_1) .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); @@ -370,8 +479,14 @@ mod tests { let max_drift_ppb = 0; let initial_disruption_marker = 0; let final_disruption_marker = 1; - let mut shm_writer = MockShmWriter::new(); - shm_writer + let clock_parameters = create_test_clock_parameters() + .clock_error_bound_nanos(1000) + .tsc_count(1000) + .time_nanos(123_000) + .call(); + + let mut shm_writer_0 = MockShmWriter::new(); + shm_writer_0 .expect_write() .withf(move |ceb: &ClockErrorBound| { ceb.void_after() @@ -384,9 +499,26 @@ mod tests { }) .times(1) .return_const(()); + + let mut shm_writer_1 = MockShmWriter::new(); + shm_writer_1 + .expect_write() + .withf(move |ceb: &ClockErrorBound| { + ceb.void_after() + == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) + && ceb.bound_nsec() == 1000 + && ceb.disruption_marker() == final_disruption_marker + && ceb.max_drift_ppb() == max_drift_ppb + && ceb.clock_status() == ClockStatus::Disrupted + && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled + }) + .times(1) + .return_const(()); + let mut clock_state_writer = ClockStateWriter::builder() .clock_disruption_support_enabled(clock_disruption_support_enabled) - .shm_writer(shm_writer) + .shm_writer_0(shm_writer_0) + .shm_writer_1(shm_writer_1) .max_drift_ppb(max_drift_ppb) .disruption_marker(initial_disruption_marker) .build(); @@ -394,7 +526,7 @@ mod tests { clock_state_writer.disruption_marker, initial_disruption_marker ); - clock_state_writer.handle_disruption(final_disruption_marker); + clock_state_writer.handle_disruption(&clock_parameters, final_disruption_marker); assert_eq!( clock_state_writer.disruption_marker, final_disruption_marker diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 002dd0b..bbc4916 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -228,6 +228,15 @@ impl TryFrom for ClockErrorBoundLayoutVersion { } } +impl From for u16 { + fn from(value: ClockErrorBoundLayoutVersion) -> Self { + match value { + ClockErrorBoundLayoutVersion::V2 => 2, + ClockErrorBoundLayoutVersion::V3 => 3, + } + } +} + /// Result of the `ClockBoundClient::now()` function. #[derive(PartialEq, Clone, Debug)] pub struct ClockBoundNowResult { diff --git a/clock-bound/src/shm/reader.rs b/clock-bound/src/shm/reader.rs index 83b1d73..a0e2793 100644 --- a/clock-bound/src/shm/reader.rs +++ b/clock-bound/src/shm/reader.rs @@ -8,7 +8,10 @@ use std::sync::atomic::{self, AtomicU16}; use crate::shm::shm_header::{CLOCKBOUND_SHM_LATEST_VERSION, ShmHeader}; use crate::{ - shm::{ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ShmError}, + shm::{ + ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ClockErrorBoundV2, + ClockErrorBoundV3, ShmError, + }, syserror, }; @@ -254,9 +257,23 @@ impl ShmReader { let version = unsafe { ptr::addr_of!((*cursor.cast::()).version) }; let generation = unsafe { ptr::addr_of!((*cursor.cast::()).generation) }; + // Atomically read the current version in the shared memory segment + // SAFETY: `self.version` has been validated when creating the reader + let shm_version = unsafe { &*version }; + let shm_version = shm_version.load(atomic::Ordering::Acquire); + + // FIXME: temporary workaround waiting for https://github.com/aws/private-clock-bound-staging/pull/150 + // to be pulled in + let shm_version = ClockErrorBoundLayoutVersion::try_from(shm_version & 0x00ff)?; + // Move to the end of the header and map the ClockErrorBound data, but only if the segment // size allows it and matches our expectation. - if mmap_guard.segsize < size_of::() + size_of::() { + let layout_size = match shm_version { + ClockErrorBoundLayoutVersion::V2 => size_of::(), + ClockErrorBoundLayoutVersion::V3 => size_of::(), + }; + + if mmap_guard.segsize < size_of::() + layout_size { return Err(ShmError::SegmentMalformed); } diff --git a/clock-bound/src/shm/writer.rs b/clock-bound/src/shm/writer.rs index f9a4271..2731212 100644 --- a/clock-bound/src/shm/writer.rs +++ b/clock-bound/src/shm/writer.rs @@ -13,8 +13,10 @@ use std::io::Write; use std::os::unix::ffi::OsStrExt; use crate::shm::reader::ShmReader; -use crate::shm::shm_header::{CLOCKBOUND_SHM_SUPPORTED_VERSION, SHM_MAGIC, ShmHeader}; -use crate::shm::{ClockErrorBound, ShmError}; +use crate::shm::shm_header::{CLOCKBOUND_SHM_LATEST_VERSION, SHM_MAGIC, ShmHeader}; +use crate::shm::{ + ClockErrorBound, ClockErrorBoundLayoutVersion, ClockErrorBoundV2, ClockErrorBoundV3, ShmError, +}; /// Trait that a writer to the shared memory segment has to implement. pub trait ShmWrite { @@ -62,12 +64,14 @@ impl ShmWriter { /// happened. That's a warm reboot-like scenario. /// 3. A variation of 2., but where the layout is being changed (a version bump). This is /// analog to a cold boot. - /// - /// TODO: implement scenario 3 once the readers support a version bump. #[expect(clippy::missing_errors_doc, reason = "todo")] - pub fn new(path: &Path) -> std::io::Result { + pub fn new( + path: &Path, + minimum_version: ClockErrorBoundLayoutVersion, + current_version: ClockErrorBoundLayoutVersion, + ) -> std::io::Result { // Determine the size of the segment. - let segsize = ShmWriter::segment_size(); + let segsize = ShmWriter::segment_size(current_version); // Use the ShmReader to assert the state of the segment. If the segment does not exist or // cannot be read correctly, wipe it clean. Note that there is a strong assumption here @@ -103,19 +107,25 @@ impl ShmWriter { }; // Update the memory segment with bound on clock error data and write the layout version. - // - If the segment was wiped clean, this defines the memory layout. It is still not useable - // by readers, until the next `update()` is successful. - // - If the segment existed and was valid, the version is over-written, and with a single - // version defined today, this overwrites the same value and the segment is readily - // available to the existing readers. - // - // TODO: remove the hard coded version 1 below, manage a change of version, and update the - // comment above since the no-op assumption won't hold true anymore with more than one - // version. + // - If the segment was wiped clean, this defines the memory layout. It is still not + // useable by readers, until the next `update()` is successful. + // - If the segment existed and was valid, the version is over-written. The minimum_version + // SHOULD NOT change when writing to a given path. + // - It is possible for the current version to increment, if the daemon has been upgraded + // and the new version is backward compatible with the miminum one. + let (min_ver, cur_ver): (u16, u16) = match current_version { + // FIXME: the version 2 of the layout does not support minimum_version + // interpretation of the u16 field. Set that to zero deliberately until the + // implementation changes. + ClockErrorBoundLayoutVersion::V2 => (0, current_version.into()), + ClockErrorBoundLayoutVersion::V3 => (minimum_version.into(), current_version.into()), + }; + let version_value = (min_ver << 8) | (cur_ver & 0x00ff); + // SAFETY: segment has been validated to be usable, can use pointers. unsafe { let version = &*writer.version; - version.store(CLOCKBOUND_SHM_SUPPORTED_VERSION, atomic::Ordering::Relaxed); + version.store(version_value, atomic::Ordering::Relaxed); } Ok(writer) @@ -130,7 +140,7 @@ impl ShmWriter { match ShmReader::new_with_max_version_unchecked(path_cstring.as_c_str()) { Ok((_reader, max_version)) => { - if max_version == CLOCKBOUND_SHM_SUPPORTED_VERSION { + if max_version == CLOCKBOUND_SHM_LATEST_VERSION { Ok(()) } else { Err(ShmError::SegmentVersionNotSupported) @@ -141,9 +151,13 @@ impl ShmWriter { } /// Return a segment size which is large enough to store everything we need. - fn segment_size() -> usize { + fn segment_size(version: ClockErrorBoundLayoutVersion) -> usize { // Need to hold the header and the bound on clock error data. - let size = size_of::() + size_of::(); + let size = size_of::() + + match version { + ClockErrorBoundLayoutVersion::V2 => size_of::(), + ClockErrorBoundLayoutVersion::V3 => size_of::(), + }; // Round up to have 64 bit alignment. Not absolutely required but convenient. Currently, // the size of the data shared is almost two order of magnitude smaller than the minimum @@ -358,8 +372,12 @@ mod t_writer { // Create and wipe the memory segment let ceb = clockerrorbound!(); - let mut writer = - ShmWriter::new(Path::new(clockbound_shm_path)).expect("Failed to create a writer"); + let mut writer = ShmWriter::new( + Path::new(clockbound_shm_path), + ClockErrorBoundLayoutVersion::V2, + ClockErrorBoundLayoutVersion::V2, + ) + .expect("Failed to create a writer"); writer.write(&ceb); // Read it back into a snapshot @@ -392,8 +410,12 @@ mod t_writer { // Create and wipe the memory segment let ceb = clockerrorbound!(); - let mut writer = - ShmWriter::new(Path::new(clockbound_shm_path)).expect("Failed to create a writer"); + let mut writer = ShmWriter::new( + Path::new(clockbound_shm_path), + ClockErrorBoundLayoutVersion::V2, + ClockErrorBoundLayoutVersion::V2, + ) + .expect("Failed to create a writer"); writer.write(&ceb); // Read it back into a snapshot @@ -416,8 +438,12 @@ mod t_writer { // Create a clean memory segment let ceb = clockerrorbound!(); - let mut writer = - ShmWriter::new(Path::new(clockbound_shm_path)).expect("Failed to create a writer"); + let mut writer = ShmWriter::new( + Path::new(clockbound_shm_path), + ClockErrorBoundLayoutVersion::V2, + ClockErrorBoundLayoutVersion::V2, + ) + .expect("Failed to create a writer"); // Push two updates to the shared memory segment, the generation moves from 0, to 2, to 4 writer.write(&ceb); From b8a4eda911c6d7f7e91e972dc48e21e99d6cdebd Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Tue, 18 Nov 2025 14:16:02 -0800 Subject: [PATCH 139/177] [shm] Point clients to shm1 and the v3 CEB layout by default_clients_to_v3 (#159) This patch makes clients read from the latest shm1 file by default, and using the ClockErrorBound layout V3. Also fix a couple of unit tests that were disabled while the writer was being updated. --- clock-bound-ffi/include/clockbound.h | 2 +- clock-bound/src/shm.rs | 2 +- clock-bound/src/shm/writer.rs | 14 ++++---------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/clock-bound-ffi/include/clockbound.h b/clock-bound-ffi/include/clockbound.h index fe01fd0..ad01c15 100644 --- a/clock-bound-ffi/include/clockbound.h +++ b/clock-bound-ffi/include/clockbound.h @@ -5,7 +5,7 @@ #include #define CLOCKBOUND_ERROR_DETAIL_SIZE 128 -#define CLOCKBOUND_SHM_DEFAULT_PATH "/var/run/clockbound/shm0" +#define CLOCKBOUND_SHM_DEFAULT_PATH "/var/run/clockbound/shm1" #define VMCLOCK_SHM_DEFAULT_PATH "/dev/vmclock0" /* diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index bbc4916..b2b385a 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -29,7 +29,7 @@ use std::fmt; pub const CLOCKBOUND_SHM_DEFAULT_PATH_V0: &str = "/var/run/clockbound/shm0"; pub const CLOCKBOUND_SHM_DEFAULT_PATH_V1: &str = "/var/run/clockbound/shm1"; -pub const CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH: &str = CLOCKBOUND_SHM_DEFAULT_PATH_V0; +pub const CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH: &str = CLOCKBOUND_SHM_DEFAULT_PATH_V1; const CLOCKBOUND_RESTART_GRACE_PERIOD: TimeSpec = TimeSpec::new(5, 0); const NANOS_PER_SECOND: f64 = 1_000_000_000.0; diff --git a/clock-bound/src/shm/writer.rs b/clock-bound/src/shm/writer.rs index 2731212..3d9050d 100644 --- a/clock-bound/src/shm/writer.rs +++ b/clock-bound/src/shm/writer.rs @@ -361,9 +361,6 @@ mod t_writer { /// Assert that a new memory mapped segment is created it does not exist. #[test] - // FIXME: this will fail until the writer is upgraded - // https://github.com/aws/private-clock-bound-staging/pull/158 - #[ignore = "daemon version mismatch"] fn test_writer_create_new_if_not_exist() { let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); @@ -374,8 +371,8 @@ mod t_writer { let ceb = clockerrorbound!(); let mut writer = ShmWriter::new( Path::new(clockbound_shm_path), - ClockErrorBoundLayoutVersion::V2, - ClockErrorBoundLayoutVersion::V2, + ClockErrorBoundLayoutVersion::V3, + ClockErrorBoundLayoutVersion::V3, ) .expect("Failed to create a writer"); writer.write(&ceb); @@ -392,9 +389,6 @@ mod t_writer { /// Assert that an existing memory mapped segment is wiped clean if dirty. #[test] - // FIXME: this will fail until the writer is upgraded - // https://github.com/aws/private-clock-bound-staging/pull/158 - #[ignore = "daemon version mismatch"] fn test_writer_wipe_clean_on_new() { let clockbound_shm_tempfile = NamedTempFile::new().expect("create clockbound file failed"); let clockbound_shm_temppath = clockbound_shm_tempfile.into_temp_path(); @@ -412,8 +406,8 @@ mod t_writer { let ceb = clockerrorbound!(); let mut writer = ShmWriter::new( Path::new(clockbound_shm_path), - ClockErrorBoundLayoutVersion::V2, - ClockErrorBoundLayoutVersion::V2, + ClockErrorBoundLayoutVersion::V3, + ClockErrorBoundLayoutVersion::V3, ) .expect("Failed to create a writer"); writer.write(&ceb); From 2908152f5cfa5f335f78b47335467d314b8453e7 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:35:06 -0500 Subject: [PATCH 140/177] add a generic testing client This commit adds a testing library that exposes a generic clock-bound client that essentially wraps client library calls from the existing clock-bound-client (v2) crate and calls to the new clock-bound client crate feature. This effectively allows us to test both old and new versions of the client. This commit also adds a basic client testing binary: clock-bound-now. Co-authored-by: Tom Phan Co-authored-by: Nick Matthews --- Cargo.lock | 71 ++++++++++ Cargo.toml | 3 +- test/clock-bound-client-generic/Cargo.toml | 35 +++++ test/clock-bound-client-generic/Makefile.toml | 8 ++ test/clock-bound-client-generic/README.md | 3 + test/clock-bound-client-generic/src/lib.rs | 123 ++++++++++++++++++ test/clock-bound-now/Cargo.toml | 34 +++++ test/clock-bound-now/Makefile.toml | 8 ++ test/clock-bound-now/README.md | 33 +++++ test/clock-bound-now/src/main.rs | 26 ++++ 10 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 test/clock-bound-client-generic/Cargo.toml create mode 100644 test/clock-bound-client-generic/Makefile.toml create mode 100644 test/clock-bound-client-generic/README.md create mode 100644 test/clock-bound-client-generic/src/lib.rs create mode 100644 test/clock-bound-now/Cargo.toml create mode 100644 test/clock-bound-now/Makefile.toml create mode 100644 test/clock-bound-now/README.md create mode 100644 test/clock-bound-now/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index c84908a..12d615c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,6 +219,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -318,6 +319,35 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "clock-bound-client" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb3c3c254b59d38185b423b73aaca289e0e5a39a6c4441d64d7ff404c71519b4" +dependencies = [ + "clock-bound-shm", + "clock-bound-vmclock", + "errno", + "nix", +] + +[[package]] +name = "clock-bound-client-generic" +version = "2.0.3" +dependencies = [ + "anyhow", + "chrono", + "clap", + "clock-bound", + "clock-bound-client", + "nix", + "serde", + "serde_json", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + [[package]] name = "clock-bound-ff-tester" version = "2.0.3" @@ -356,6 +386,47 @@ dependencies = [ "tempfile", ] +[[package]] +name = "clock-bound-now" +version = "2.0.3" +dependencies = [ + "chrono", + "clap", + "clock-bound-client-generic", + "serde", + "serde_json", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "clock-bound-shm" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9929c408b1b2d4d12670c2df2cfc6ed1880edfad0f77319cea3dc86c82de094b" +dependencies = [ + "byteorder", + "errno", + "libc", + "nix", +] + +[[package]] +name = "clock-bound-vmclock" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6c6a3b05d0e05a61e3dedd1b30b5be49995f75b5bef1b014ddeaaca464e419" +dependencies = [ + "byteorder", + "clock-bound-shm", + "errno", + "libc", + "nix", + "tracing", + "tracing-subscriber", +] + [[package]] name = "clock-bound-vmclock-client-example" version = "2.0.3" diff --git a/Cargo.toml b/Cargo.toml index 894a421..26db9b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,8 @@ members = [ "test/vmclock-updater", "test/clock-bound-adjust-clock", "test/clock-bound-adjust-clock-test", - "test/vmclock" + "test/vmclock", + "test/clock-bound-now", ] resolver = "3" diff --git a/test/clock-bound-client-generic/Cargo.toml b/test/clock-bound-client-generic/Cargo.toml new file mode 100644 index 0000000..af58170 --- /dev/null +++ b/test/clock-bound-client-generic/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "clock-bound-client-generic" +description = "A Rust library that facilitates ClockBound v2 and v3 clients" +license = "MIT OR Apache-2.0" +publish = false + +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +keywords.workspace = true +readme.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +clock-bound = { version = "2.0", path = "../../clock-bound", features = [ + "client", +] } +clock-bound-client = { version = "2.0" } + +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4.5.31", features = ["derive"] } +tracing = "0.1" +tracing-appender = { version = "0.2", optional = true } +tracing-subscriber = { version = "0.3", features = [ + "std", + "fmt", + "json", + "registry", +] } +serde = "1.0" +serde_json = "1.0.145" +anyhow = "1.0.100" +nix = "0.26" diff --git a/test/clock-bound-client-generic/Makefile.toml b/test/clock-bound-client-generic/Makefile.toml new file mode 100644 index 0000000..b8b38e6 --- /dev/null +++ b/test/clock-bound-client-generic/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in test/clock-bound-client-generic" +''' diff --git a/test/clock-bound-client-generic/README.md b/test/clock-bound-client-generic/README.md new file mode 100644 index 0000000..0b94779 --- /dev/null +++ b/test/clock-bound-client-generic/README.md @@ -0,0 +1,3 @@ +# Test library: clock-bound-client-generic + +This directory contains the source code for a test library that wraps ClockBound v2 and v3 clients. \ No newline at end of file diff --git a/test/clock-bound-client-generic/src/lib.rs b/test/clock-bound-client-generic/src/lib.rs new file mode 100644 index 0000000..e0ad988 --- /dev/null +++ b/test/clock-bound-client-generic/src/lib.rs @@ -0,0 +1,123 @@ +//! A testing library that exposes a "generic" clock bound client enum that wraps both ClockBound v2 and v3 clients. +//! +//! This library intentionally imports the `clock_bound_client` v2.0 crate to fully simulate/validate backward compatibility +//! between v2.0 clients and v3.0 daemon. + +use anyhow::{Result, anyhow}; +use clap::ValueEnum; +use nix::sys::time::TimeSpec; + +use clock_bound::client as clock_bound_client_v3; +use clock_bound::shm as clockbound_shm_v3; +use clock_bound_client as clock_bound_client_v2; + +const NSECS_PER_SEC: i64 = 1_000_000_000; + +#[repr(C)] +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum ClockStatus { + Unknown = 0, + Synchronized = 1, + FreeRunning = 2, + Disrupted = 3, +} +impl From for ClockStatus { + fn from(value: clock_bound_client_v2::ClockStatus) -> Self { + match value { + clock_bound_client_v2::ClockStatus::Unknown => Self::Unknown, + clock_bound_client_v2::ClockStatus::Synchronized => Self::Synchronized, + clock_bound_client_v2::ClockStatus::FreeRunning => Self::FreeRunning, + clock_bound_client_v2::ClockStatus::Disrupted => Self::Disrupted, + } + } +} +impl From for ClockStatus { + fn from(value: clock_bound_client_v3::ClockStatus) -> Self { + match value { + clock_bound_client_v3::ClockStatus::Unknown => Self::Unknown, + clock_bound_client_v3::ClockStatus::Synchronized => Self::Synchronized, + clock_bound_client_v3::ClockStatus::FreeRunning => Self::FreeRunning, + clock_bound_client_v3::ClockStatus::Disrupted => Self::Disrupted, + } + } +} + +fn timespec_to_nsecs(timespec: TimeSpec) -> i64 { + timespec.tv_sec() * NSECS_PER_SEC + timespec.tv_nsec() +} + +#[derive(PartialEq, Clone, Debug)] +pub struct ClockBoundNowResult { + pub timestamp: i64, + pub ceb: i64, + pub clock_status: ClockStatus, +} +impl From for ClockBoundNowResult { + fn from(value: clock_bound_client_v2::ClockBoundNowResult) -> Self { + let midpoint = (value.latest + value.earliest) / 2; + let bound = (value.latest - value.earliest) / 2; + + ClockBoundNowResult { + timestamp: timespec_to_nsecs(midpoint), + ceb: timespec_to_nsecs(bound), + clock_status: ClockStatus::from(value.clock_status), + } + } +} +impl From for ClockBoundNowResult { + fn from(value: clockbound_shm_v3::ClockBoundNowResult) -> Self { + let midpoint = (value.latest + value.earliest) / 2; + let bound = (value.latest - value.earliest) / 2; + + ClockBoundNowResult { + timestamp: timespec_to_nsecs(midpoint), + ceb: timespec_to_nsecs(bound), + clock_status: ClockStatus::from(value.clock_status), + } + } +} + +#[derive(ValueEnum, Clone, Copy, Debug)] +pub enum ClockBoundClientVersion { + V2, + V3, +} + +pub enum ClockBoundClient { + ClientV2(Box), + ClientV3(Box), +} +impl ClockBoundClient { + /// Construct a new generic ClockBound client. + /// + /// # Panics + /// Panics if initialization of ClockBound client fails. + pub fn new(version: ClockBoundClientVersion) -> Self { + match version { + ClockBoundClientVersion::V2 => Self::ClientV2(Box::new( + clock_bound_client_v2::ClockBoundClient::new() + .expect("Failed to initialize ClockBound v2 client."), + )), + ClockBoundClientVersion::V3 => Self::ClientV3(Box::new( + clock_bound_client_v3::ClockBoundClient::new() + .expect("Failed to initialize ClockBound v3 client."), + )), + } + } + /// Retrieves the current time. + /// + /// # Errors + /// Returns an error if the clock bound time retrieval fails. + pub fn now(&mut self) -> Result { + match self { + Self::ClientV2(client) => match client.now() { + Ok(now) => Ok(ClockBoundNowResult::from(now)), + Err(e) => Err(anyhow!("{e:?}")), + }, + Self::ClientV3(client) => match client.now() { + Ok(now) => Ok(ClockBoundNowResult::from(now)), + Err(e) => Err(anyhow!("{e:?}")), + }, + } + } +} diff --git a/test/clock-bound-now/Cargo.toml b/test/clock-bound-now/Cargo.toml new file mode 100644 index 0000000..e7ef9a1 --- /dev/null +++ b/test/clock-bound-now/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "clock-bound-now" +description = "A Rust example program to read the current bounds from ClockBound" +license = "MIT OR Apache-2.0" +publish = false + +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +keywords.workspace = true +readme.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "clock-bound-now" +path = "src/main.rs" + +[dependencies] +clock-bound-client-generic = { version = "2.0", path = "../clock-bound-client-generic" } + +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4.5.31", features = ["derive"] } +tracing = "0.1" +tracing-appender = { version = "0.2", optional = true } +tracing-subscriber = { version = "0.3", features = [ + "std", + "fmt", + "json", + "registry", +] } +serde = "1.0" +serde_json = "1.0.145" diff --git a/test/clock-bound-now/Makefile.toml b/test/clock-bound-now/Makefile.toml new file mode 100644 index 0000000..4710ac8 --- /dev/null +++ b/test/clock-bound-now/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in test/clock-bound-now" +''' diff --git a/test/clock-bound-now/README.md b/test/clock-bound-now/README.md new file mode 100644 index 0000000..9e41a02 --- /dev/null +++ b/test/clock-bound-now/README.md @@ -0,0 +1,33 @@ +# Test program: clock-bound-now + +This directory contains the source code for a test program `clock-bound-now`. + +### `clock-bound-now` +This program reads the SHM segment and the VMClock marker and outputs the earliest and latest +timestamps (in femtoseconds), the status of the clock, and the "error bound" itself. It outputs this +to stdout as JSON to be used in scripts. + +## Prerequisites + +The ClockBound daemon should be running, or a valid SHM segment/VMClock path must be supplied otherwise. + +## Building with Cargo + +Run the following command to build the example programs. + +``` +cargo build --release +``` + +## Running `clock-bound-now` + +The build artifact should show up at +``` +./target/release/clock-bound-now +``` + +You can run the command like below, and the output should look similar: +``` +$ ./target/release/clock-bound-now --client-version (v2|v3) +{"timestamp":"2025-11-17T22:57:30.108786Z","level":"INFO","fields":{"bound":642300,"earliest":1763420250108133684,"latest":1763420250109418284,"clock_status":"Synchronized"},"target":"clock_bound_now"} +``` diff --git a/test/clock-bound-now/src/main.rs b/test/clock-bound-now/src/main.rs new file mode 100644 index 0000000..c595a6b --- /dev/null +++ b/test/clock-bound-now/src/main.rs @@ -0,0 +1,26 @@ +//! A program to read the ClockBound SHM segment's earliest and latest bounds. + +use clap::Parser; +use clock_bound_client_generic::{ClockBoundClient, ClockBoundClientVersion}; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + #[clap(value_enum, long)] + client_version: ClockBoundClientVersion, +} + +fn main() { + tracing_subscriber::fmt().json().init(); + let args = Args::parse(); + + let mut client = ClockBoundClient::new(args.client_version); + match client.now() { + Ok(now) => { + tracing::info!("{now:?}"); + } + Err(e) => { + tracing::error!("Failed to retrieve clock bounds: {e:?}"); + } + } +} From e1ff110b5770d87c5b22bf2303cdb2c52c16f2e5 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 19 Nov 2025 15:53:08 -0500 Subject: [PATCH 141/177] write log files with a rolling hourly file (#163) --- clock-bound/src/daemon/subscriber.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/clock-bound/src/daemon/subscriber.rs b/clock-bound/src/daemon/subscriber.rs index fd68bef..c1bde9f 100644 --- a/clock-bound/src/daemon/subscriber.rs +++ b/clock-bound/src/daemon/subscriber.rs @@ -5,7 +5,7 @@ //! all clock synchronization events in a format that allows for deterministic and //! reproducible testing of the FF Clock Sync Algorithm -use std::path::{Path, PathBuf}; +use std::path::Path; use tracing::Level; use tracing_subscriber::{ @@ -21,14 +21,14 @@ pub const CLOCK_METRICS_TARGET: &str = "clock_bound::clock_metrics"; /// events for reproducing test cases use the target of [`PRIMER_TARGET`] to route into a separate file, while /// the rest of the logs go through the default logger (presently writes to stdout) pub fn init(log_directory: impl AsRef) { - let primer_writer = tracing_appender::rolling::never(&log_directory, "primer.log"); + let primer_writer = tracing_appender::rolling::hourly(&log_directory, "primer.log"); let primer_layer = tracing_subscriber::fmt::layer() .json() .with_writer(primer_writer) .with_filter(filter_fn(|md| md.target().starts_with(PRIMER_TARGET))); let clock_metrics_writer = - tracing_appender::rolling::never(&log_directory, "clock_metrics.log"); + tracing_appender::rolling::hourly(&log_directory, "clock_metrics.log"); let clock_metrics_layer = tracing_subscriber::fmt::layer() .json() .with_writer(clock_metrics_writer) @@ -58,8 +58,5 @@ pub fn init(log_directory: impl AsRef) { .init(); tracing::info!("Initialized tracing subscriber"); - let primer_log_file = PathBuf::from(log_directory.as_ref()).join("primer.log"); - tracing::info!(primer_log_file = %primer_log_file.display(), "Primer log file"); - let clock_metrics_log_file = PathBuf::from(log_directory.as_ref()).join("clock_metrics.log"); - tracing::info!(clock_metrics_log_file = %clock_metrics_log_file.display(), "Clock metrics log file"); + tracing::info!(primer_log_file = %log_directory.as_ref().display(), "Initialized log directory"); } From f59a640c41f7f22342bdb0fd8d160a1b20e84682 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Wed, 19 Nov 2025 16:15:29 -0500 Subject: [PATCH 142/177] [ClockState] set the disruption marker on startup (#164) Previously, the disruption marker written to the shm after starting up is 0. This is WRONG. Instead we should write the current known vmclock disruption marker value each time. --- clock-bound/src/daemon.rs | 35 ++++++++++++++++++--------- clock-bound/src/daemon/clock_state.rs | 3 ++- clock-bound/src/daemon/io.rs | 10 ++++++++ clock-bound/src/daemon/io/vmclock.rs | 5 ++++ 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 4ef7215..99c55bd 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -76,18 +76,6 @@ impl Daemon { }; let clock_state_cancellation_token = CancellationToken::new(); - let (clock_state_tx, clock_state) = { - let (tx, rx) = async_ring_buffer::create(1); - let clock_state = ClockState::construct(rx, clock_state_cancellation_token.clone()); - (tx, clock_state) - }; - let task_tracker = TaskTracker::new(); - let clock_state_handle = ClockStateHandle { - clock_state: Some(clock_state), - tx: clock_state_tx, - cancellation_token: clock_state_cancellation_token, - task_tracker, - }; let selected_clock = Arc::new(SelectedClockSource::default()); @@ -110,6 +98,11 @@ impl Daemon { // Initialize vmclock IO component. io_front_end.create_vmclock(VMCLOCK_SHM_DEFAULT_PATH).await; + #[expect(clippy::redundant_closure_for_method_calls)] + let disruption_marker = io_front_end + .vmclock() + .map(|vmclock| vmclock.last_disruption_marker()) + .unwrap_or_default(); // Initialize PHC event buffer and IO component. let (phc_tx, phc_rx) = async_ring_buffer::create(2); @@ -150,6 +143,24 @@ impl Daemon { .maybe_phc(phc_rx) .build(); + // Initialize the Clock State component + let (clock_state_tx, clock_state) = { + let (tx, rx) = async_ring_buffer::create(1); + let clock_state = ClockState::construct( + rx, + clock_state_cancellation_token.clone(), + disruption_marker, + ); + (tx, clock_state) + }; + let task_tracker = TaskTracker::new(); + let clock_state_handle = ClockStateHandle { + clock_state: Some(clock_state), + tx: clock_state_tx, + cancellation_token: clock_state_cancellation_token, + task_tracker, + }; + Self { io_front_end, clock_sync_algorithm, diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 7f172a3..ab78439 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -60,6 +60,7 @@ impl ClockState { pub fn construct( clock_params_receiver: Receiver, cancellation_token: CancellationToken, + disruption_marker: u64, ) -> Self { // Build two writers, each writing to a specific shared memory segment path. // @@ -87,7 +88,7 @@ impl ClockState { .shm_writer_0(safe_shm_writer_0) .shm_writer_1(safe_shm_writer_1) .max_drift_ppb(MAX_DISPERSION_GROWTH_PPB) - .disruption_marker(0) + .disruption_marker(disruption_marker) .build(); #[cfg(not(feature = "test-side-by-side"))] let clock_adjuster: ClockAdjuster = diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index 3cd615d..dd2a2d9 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -247,6 +247,16 @@ impl SourceIO { } } + /// Returns `VMCLock` if it has been initialized + pub fn vmclock(&self) -> Option<&VMClock> { + self.vmclock + .as_ref() + .and_then(|source| match &source.state { + SourceState::Initialized(vmclock) => Some(vmclock), + SourceState::Running => None, + }) + } + // Creates a new [`watch::Receiver`] connected to the clock distribution watch [`watch::Sender`]. pub fn clock_disruption_receiver(&self) -> watch::Receiver { self.clock_disruption_channels.sender.subscribe() diff --git a/clock-bound/src/daemon/io/vmclock.rs b/clock-bound/src/daemon/io/vmclock.rs index 16e0f65..2be5c0e 100644 --- a/clock-bound/src/daemon/io/vmclock.rs +++ b/clock-bound/src/daemon/io/vmclock.rs @@ -106,6 +106,11 @@ impl VMClock { }) } + /// Returns the last VMClock read's disruption marker + pub fn last_disruption_marker(&self) -> u64 { + self.previous_shm_body.disruption_marker + } + /// Reads the VMClock shared memory page and returns the current clock state. fn sample(&mut self) -> Result { let vmclock_snapshot = self.reader.snapshot()?; From 3074ea3667bfc0f40ba026aa7e0bc661c02b4d65 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Wed, 19 Nov 2025 17:11:04 -0500 Subject: [PATCH 143/177] [clockadjust 5/4] Initialize SHM with Unknown status before anything (#115) * Initialize SHM with `Unknown` status before anything To avoid an issue where if daemon is restarted and the clock is adjusted while the SHM is still reporting some `Synchronized` state, we write the clock status as unknown before the `ClockState` component starts running. This is needed as long as our client depends on the underlying kernel clocks for its `now()` calls. --------- Co-authored-by: Tom Phan --- clock-bound/src/daemon/clock_state.rs | 7 +++ .../daemon/clock_state/clock_state_writer.rs | 53 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index ab78439..e9e7cd6 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -109,6 +109,13 @@ impl ClockState { let mut clock_offset_metric_interval = tokio::time::interval(tokio::time::Duration::from_secs(1)); info!("Starting run for ClockState"); + // FIXME: This clears the SHM segment quite early, before we have + // even received `ClockParameters` and started trying to adjust the clock - + // it is overly cautious. We could hold off even longer before clearing things, + // in case a previous `ClockBound` has written reliable SHM data. + // We could consider to wait til we are about to step the clock, and set the + // clock status to unknown right before then. + self.state_writer.initialize_ceb_v2_shm(); loop { tokio::select! { _ = clock_offset_metric_interval.tick() => { diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index 3cf012a..4bc9c31 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -47,6 +47,7 @@ pub struct ClockStateWriter { #[cfg_attr(test, mockall::automock)] pub trait ClockStateWrite: Send + Sync { + fn initialize_ceb_v2_shm(&mut self); fn handle_clock_parameters( &mut self, clock_parameters: &ClockParameters, @@ -57,6 +58,21 @@ pub trait ClockStateWrite: Send + Sync { } impl ClockStateWrite for ClockStateWriter { + /// Initializes the `ClockErrorBoundV2` segment on daemon startup. Since `ClockBound` manages adjusting + /// kernel system clock and the client currently depends on those clocks for the `ClockErrorBoundV2` SHM segment + /// version, before we perform any adjustments, in case the SHM was already written to previously, + /// we initialize and write the SHM with `ClockStatus::Unknown` BEFORE any adjustments are done. + /// This is to ensure we don't end up breaking the underlying clock for the client. + /// This does not apply to `ClockErrorBoundV3`, which does not depend on a kernel exposed system clock. + fn initialize_ceb_v2_shm(&mut self) { + info!("Initializing SHM segment to status `ClockStatus::Unknown` and zeroing other fields"); + let as_of = MonotonicCoarse.get_time(); + // FIXME: the behavior when writing a V2 layout to shm0 is to only overwrite the clock + // status only. Comment on line below and code to be fixed. + // We're writing that we're disrupted anyways, so the `bound_nsec` value should be useless here, 0 is ok + self.write_shm0(as_of, 0, ClockStatus::Unknown, self.max_drift_ppb); + } + /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. /// /// # Panics @@ -243,7 +259,7 @@ fn get_bound( #[cfg(test)] mod tests { use super::*; - use crate::daemon::time::{Duration, TscCount, tsc::Period}; + use crate::daemon::time::{Duration, Instant, TscCount, tsc::Period}; use mockall::mock; use rstest::rstest; @@ -473,6 +489,41 @@ mod tests { assert_eq!(bound_nsec, expected); } + #[test] + fn initialize_ceb_v2_shm() { + let clock_disruption_support_enabled = false; + let expected_max_drift_ppb = 0; + let expected_disruption_marker = 0; + let mut shm_writer_0 = MockShmWriter::new(); + shm_writer_0 + .expect_write() + .withf(move |ceb: &ClockErrorBound| { + ceb.void_after() + == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) + && ceb.bound_nsec() == 0 + && ceb.disruption_marker() == expected_disruption_marker + && ceb.max_drift_ppb() == expected_max_drift_ppb + && ceb.clock_status() == ClockStatus::Unknown + && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled + }) + .times(1) + .return_const(()); + let mut shm_writer_1 = MockShmWriter::new(); + shm_writer_1.expect_write().never(); + let mut clock_state_writer = ClockStateWriter::builder() + .clock_disruption_support_enabled(clock_disruption_support_enabled) + .shm_writer_0(shm_writer_0) + .shm_writer_1(shm_writer_1) + .max_drift_ppb(expected_max_drift_ppb) + .disruption_marker(expected_disruption_marker) + .build(); + assert_eq!( + clock_state_writer.disruption_marker, + expected_disruption_marker + ); + clock_state_writer.initialize_ceb_v2_shm(); + } + #[test] fn handle_disruption() { let clock_disruption_support_enabled = false; From b16ed6dd1b9a38b67192e79a76239f2ebb422b24 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:26:09 -0500 Subject: [PATCH 144/177] add phc offset test runner This commit adds a test binary that reads the PHC device and compares it to the specified (real-time or monotonic-raw) system clock. This isn't immediately useful, but lays the groundwork for a generic PHC-clock comparison tool that will allow us to perform stronger validations in real testing environments. --- Cargo.lock | 14 + Cargo.toml | 1 + test/clock-bound-phc-offset/Cargo.toml | 32 ++ test/clock-bound-phc-offset/Makefile.toml | 8 + test/clock-bound-phc-offset/README.md | 41 ++ test/clock-bound-phc-offset/src/lib.rs | 2 + test/clock-bound-phc-offset/src/main.rs | 42 ++ test/clock-bound-phc-offset/src/phc.rs | 71 +++ .../src/phc/autoconfiguration.rs | 461 ++++++++++++++++++ test/clock-bound-phc-offset/src/phc/ceb.rs | 26 + test/clock-bound-phc-offset/src/phc/ptp.rs | 83 ++++ 11 files changed, 781 insertions(+) create mode 100644 test/clock-bound-phc-offset/Cargo.toml create mode 100644 test/clock-bound-phc-offset/Makefile.toml create mode 100644 test/clock-bound-phc-offset/README.md create mode 100644 test/clock-bound-phc-offset/src/lib.rs create mode 100644 test/clock-bound-phc-offset/src/main.rs create mode 100644 test/clock-bound-phc-offset/src/phc.rs create mode 100644 test/clock-bound-phc-offset/src/phc/autoconfiguration.rs create mode 100644 test/clock-bound-phc-offset/src/phc/ceb.rs create mode 100644 test/clock-bound-phc-offset/src/phc/ptp.rs diff --git a/Cargo.lock b/Cargo.lock index 12d615c..157d389 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "clock-bound-phc-offset" +version = "2.0.3" +dependencies = [ + "anyhow", + "clap", + "clock-bound", + "libc", + "nix", + "thiserror 2.0.17", + "tracing", + "tracing-subscriber", +] + [[package]] name = "clock-bound-shm" version = "2.0.3" diff --git a/Cargo.toml b/Cargo.toml index 26db9b8..a6650fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "test/clock-bound-adjust-clock-test", "test/vmclock", "test/clock-bound-now", + "test/clock-bound-phc-offset", ] resolver = "3" diff --git a/test/clock-bound-phc-offset/Cargo.toml b/test/clock-bound-phc-offset/Cargo.toml new file mode 100644 index 0000000..7d10155 --- /dev/null +++ b/test/clock-bound-phc-offset/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "clock-bound-phc-offset" +description = "A test program that compares specified clocks to PHC timestamp reads." +license = "Apache-2.0" +publish = false + +authors.workspace = true +categories.workspace = true +edition.workspace = true +exclude.workspace = true +keywords.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "clock-bound-phc-offset" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0.100" +clock-bound = { version = "2.0", path = "../../clock-bound", features = [ + "daemon", + "client", +] } +libc = { version = "0.2", default-features = false, features = [ + "extra_traits", +] } +nix = { version = "0.26" } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["std"] } +thiserror = "2.0" +clap = { version = "4.5.31", features = ["derive"] } diff --git a/test/clock-bound-phc-offset/Makefile.toml b/test/clock-bound-phc-offset/Makefile.toml new file mode 100644 index 0000000..3e39cfd --- /dev/null +++ b/test/clock-bound-phc-offset/Makefile.toml @@ -0,0 +1,8 @@ +extend = "../../Makefile.toml" + + +[tasks.custom-docs-flow] +clear = true +script = ''' +echo "skipping custom docs flow in test/clock-bound-phc-offset" +''' diff --git a/test/clock-bound-phc-offset/README.md b/test/clock-bound-phc-offset/README.md new file mode 100644 index 0000000..bf4442f --- /dev/null +++ b/test/clock-bound-phc-offset/README.md @@ -0,0 +1,41 @@ +# Test program: clock-bound-phc-offset +This directory contains the source code for a test program written to +compare offsets of specified clocks against PHC timestamp reads. + +## Prerequisites + +AWS EC2 instance is required. + +On non-Amazon Linux distributions, the ENA Linux driver will need to be installed and configured with support for the PHC enabled: +- https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configure-ec2-ntp.html#connect-to-the-ptp-hardware-clock +- https://github.com/amzn/amzn-drivers/tree/master/kernel/linux/ena + + +The PTP hardware clock (PHC) device must have read permissions for the user that is running the phc-test program. + +```sh +sudo chmod 644 /dev/ptp0 +``` + + +## Building with Cargo + +Run the following command to build the test program. + +```sh +cargo build --release +``` + +## Running the program after a Cargo build + +Run the following commands to run the test program. + +```sh +cd target/release/ +sudo ./clock-bound-phc-offset --clock real-time +``` + +Example output: +``` +{"timestamp":"2025-11-19T18:58:07.662842Z","level":"INFO","fields":{"message":"OffsetRttAndCeb { offset_and_rtt: ClockOffsetAndRtt { offset: Duration(0.000_007_372), rtt: Duration(0.000_007_997) }, ceb: Duration(0.000_020_808) }"},"target":"clock_bound_phc_offset"} +``` diff --git a/test/clock-bound-phc-offset/src/lib.rs b/test/clock-bound-phc-offset/src/lib.rs new file mode 100644 index 0000000..712512c --- /dev/null +++ b/test/clock-bound-phc-offset/src/lib.rs @@ -0,0 +1,2 @@ +mod phc; +pub use phc::{OffsetRttAndCeb, PhcReader, autoconfigure_phc_reader}; diff --git a/test/clock-bound-phc-offset/src/main.rs b/test/clock-bound-phc-offset/src/main.rs new file mode 100644 index 0000000..dd456ba --- /dev/null +++ b/test/clock-bound-phc-offset/src/main.rs @@ -0,0 +1,42 @@ +//! PHC offset test executable. +//! +//! This executable compares specific clocks against timestamps read from the PHC. + +use clap::{Parser, ValueEnum}; + +use clock_bound::daemon::time::clocks::{MonotonicRaw, RealTime}; +use clock_bound_phc_offset::autoconfigure_phc_reader; + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum ClockToCompare { + RealTime, + MonotonicRaw, +} + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + #[arg(value_enum, long)] + clock: ClockToCompare, +} + +fn main() { + tracing_subscriber::fmt().json().init(); + let args = Args::parse(); + let clock = args.clock; + + let phc_reader = + autoconfigure_phc_reader().expect("failed to create PHC reader via autoconfiguration"); + + let offset_rtt_ceb = match clock { + ClockToCompare::RealTime => phc_reader.get_offset_from_utc_clock(&RealTime), + ClockToCompare::MonotonicRaw => phc_reader.get_offset_from_utc_clock(&MonotonicRaw), + }; + + match offset_rtt_ceb { + Ok(offset_rtt_ceb) => tracing::info!("{offset_rtt_ceb:?}"), + Err(e) => tracing::error!( + "Failed to retrieve offset, rtt, ceb from {clock:?} Clock <-> PHC comparison: {e:?}" + ), + } +} diff --git a/test/clock-bound-phc-offset/src/phc.rs b/test/clock-bound-phc-offset/src/phc.rs new file mode 100644 index 0000000..6cdc0a0 --- /dev/null +++ b/test/clock-bound-phc-offset/src/phc.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use std::{fs::File, path::PathBuf}; + +use clock_bound::daemon::time::inner::{ClockOffsetAndRtt, Time}; +use clock_bound::daemon::time::instant::Utc; +use clock_bound::daemon::time::{Clock, ClockExt, Duration}; + +mod ptp; +use ptp::PtpReader; + +mod ceb; +use ceb::PhcClockErrorBoundReader; + +mod autoconfiguration; +pub use autoconfiguration::autoconfigure_phc_reader; + +#[allow(dead_code)] +#[derive(Debug)] +pub struct OffsetRttAndCeb { + offset_and_rtt: ClockOffsetAndRtt, + ceb: Duration, +} + +#[derive(Debug)] +pub struct PhcReader { + // we need to hold the actual File to keep the raw fd valid. + _phc_device_file: File, + ptp_reader: PtpReader, + ceb_reader: PhcClockErrorBoundReader, +} +impl PhcReader { + pub fn new(phc_device_file: File, phc_clock_error_bound_path: PathBuf) -> Self { + let ptp_reader = PtpReader::new(&phc_device_file); + let ceb_reader = PhcClockErrorBoundReader::new(phc_clock_error_bound_path); + Self { + _phc_device_file: phc_device_file, + ptp_reader, + ceb_reader, + } + } + + /// Return offset, rtt, and CEB from a clock to PHC comparision. + /// + /// # Errors + /// Returns an error if CEB read operation fails. + pub fn get_offset_from_utc_clock>( + &self, + clock: &T, + ) -> Result { + let offset_and_rtt = clock.get_offset_and_rtt(self); + let ceb = self.ceb_reader.read()?; + Ok(OffsetRttAndCeb { + offset_and_rtt, + ceb, + }) + } +} + +/// # Panics +/// Panics if PTP get time system call fails. +/// FIXME: ideally this trait signature would return a Result so we can avoid the panic here. +/// But that would require changing the logic in the daemon libary. +/// We could do that or re-implement this trait here. +impl Clock for PhcReader { + #[doc = " Read the current clock time."] + fn get_time(&self) -> Time { + self.ptp_reader + .ptp_get_time() + .expect("failed to get time from PHC") + } +} diff --git a/test/clock-bound-phc-offset/src/phc/autoconfiguration.rs b/test/clock-bound-phc-offset/src/phc/autoconfiguration.rs new file mode 100644 index 0000000..eb7fe83 --- /dev/null +++ b/test/clock-bound-phc-offset/src/phc/autoconfiguration.rs @@ -0,0 +1,461 @@ +//! Most of this module is copy-paste from the daemon io/phc library. +//! Duplication here let's us sub in the slightly modified types we've setup in this testing library. + +use std::{fs::File, io, path::PathBuf}; + +use thiserror::Error; +use tracing::{debug, warn}; + +use crate::phc::PhcReader; + +#[derive(Debug, Error)] +pub enum PhcError { + #[error("IO failure")] + Io(#[from] io::Error), + #[error("File does not exist")] + FileNotFound(String), + #[error("PTP device not found")] + PtpDeviceNotFound(String), + #[error("PTP device name not found")] + PtpDeviceNameNotFound(String), + #[error("PCI_SLOT_NAME not found in uevent file")] + PciSlotNameNotFound(String), + #[error("PHC clock error bound file not found for PCI slot name {pci_slot_name}")] + PhcClockErrorBoundFileNotFound { pci_slot_name: String }, + #[error("Device driver name not found")] + DeviceDriverNameNotFound(String), + #[error("Unexpected error")] + UnexpectedError(String), +} + +/// Attempts to autoconfigure readers for the PHC and PHC clock error bound +/// by navigating and reading from the filesystem to obtain PTP device details. +/// +/// If there are no eligible PTP devices found then a `PhcError::PtpDeviceNotFound` +/// will be returned in the Result. +/// +/// # Errors +/// Returns an error if autoconfiguration fails. +#[expect( + clippy::too_many_lines, + reason = "This function is already refactored to call + separate functions for specific functionality. The big for loop is needed + because we have many `continue` statements in the loop body, and other alternate + approaches would make the code harder to follow than the current implementation." +)] +pub fn autoconfigure_phc_reader() -> Result { + // Get the list of network interfaces. + let network_interfaces = match get_network_interfaces() { + Ok(network_interfaces) => network_interfaces, + Err(e) => { + warn!( + error = ?e, + "PHC reader autoconfiguration failed due to inability to get the list of network interfaces." + ); + return Err(e); + } + }; + + // Create a vec of tuples holding the PTP device path and PHC clock error bound sysfs path. + // Each tuple entry in this vec is a valid PHC configuration. + let mut ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec: Vec<(String, String)> = + Vec::new(); + + for network_interface in network_interfaces { + debug!( + ?network_interface, + "Gathering information on network_interface", + ); + + let uevent_file_path = match get_uevent_file_path_for_network_interface(&network_interface) + { + Ok(uevent_file_path) => { + debug!( + ?network_interface, + ?uevent_file_path, + "Network interface association with uevent file path" + ); + uevent_file_path + } + Err(e) => { + debug!(error = ?e, ?network_interface, + "uevent file not found for network interface" + ); + continue; + } + }; + + let is_ena = match is_ena_network_interface(&uevent_file_path) { + Ok(is_ena) => { + debug!( + ?network_interface, + ?is_ena, + "Network interface driver details" + ); + is_ena + } + Err(e) => { + debug!(error = ?e, ?network_interface, + "Failed to determine if network interface driver is ena", + ); + continue; + } + }; + + if !is_ena { + // We only consider PTP devices attached to ENA network interfaces as in-scope + // for use because this is the configuration used within Amazon Web Services. + debug!( + ?network_interface, + ?is_ena, + "Network interface does not use the ena driver. Skipping.", + ); + continue; + } + + let pci_slot_name = match get_pci_slot_name(&uevent_file_path) { + Ok(pci_slot_name) => { + debug!( + ?network_interface, + ?pci_slot_name, + "Network interface association with PCI slot name", + ); + pci_slot_name + } + Err(e) => { + debug!(error = ?e, ?uevent_file_path, + "PCI slot name not found for uevent file path", + ); + continue; + } + }; + + let phc_clock_error_bound_sysfs_path = + match get_phc_clock_error_bound_sysfs_path(&pci_slot_name) { + Ok(phc_clock_error_bound_sysfs_path) => { + debug!( + ?network_interface, + ?phc_clock_error_bound_sysfs_path, + "Network interface association with PHC clock error bound sysfs path", + ); + phc_clock_error_bound_sysfs_path + } + Err(e) => { + debug!( + error = ?e, ?pci_slot_name, + "PHC clock error bound sysfs path not found for PCI slot name", + ); + continue; + } + }; + + let ptp_uevent_file_paths = match get_ptp_uevent_file_paths_for_pci_slot(&pci_slot_name) { + Ok(ptp_uevent_file_paths) => { + debug!( + ?network_interface, + ?ptp_uevent_file_paths, + "Network interface association with PTP uevent file paths", + ); + ptp_uevent_file_paths + } + Err(e) => { + debug!( + error = ?e, ?pci_slot_name, + "PTP uevent file paths not found for PCI slot name", + ); + continue; + } + }; + + for ptp_uevent_file_path in ptp_uevent_file_paths { + let ptp_device_name = match get_ptp_device_name_from_uevent_file(&ptp_uevent_file_path) + { + Ok(ptp_device_name) => { + debug!( + ?network_interface, + ?ptp_device_name, + "Network interface association with PTP device name", + ); + ptp_device_name + } + Err(e) => { + debug!( + error = ?e, ?ptp_uevent_file_path, + "Device name not found for PTP uevent file path", + ); + continue; + } + }; + + let ptp_device_path = match get_ptp_device_path(&ptp_device_name) { + Ok(ptp_device_path) => { + debug!( + ?network_interface, + ?ptp_device_path, + "Network interface association with PTP device path", + ); + ptp_device_path + } + Err(e) => { + debug!(error = ?e, ?ptp_device_name, + "Device path not found for PTP device name", + ); + continue; + } + }; + + ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec + .push((ptp_device_path, phc_clock_error_bound_sysfs_path.clone())); + } + } + + // Sort the tuples in ascending order so that if there is more than one + // PTP device, the lower numbered device names are preferred first. e.g.: + // + // [ + // ("/dev/ptp0", "/sys/bus/pci/devices/0000:27:00.0/phc_error_bound"), + // ("/dev/ptp1", "/sys/bus/pci/devices/0000:28:00.0/phc_error_bound"), + // ] + ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec.sort(); + debug!(?ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec); + + // There is at least one PTP device available to use. + // Use the first PTP device in the vec. + if let Some((ptp_device_path, phc_clock_error_bound_sysfs_path)) = + ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec.first() + { + debug!( + ?ptp_device_path, + ?phc_clock_error_bound_sysfs_path, + "Configuring PHC readers" + ); + + let ptp_device_file = match File::open(ptp_device_path.clone()) { + Ok(file) => file, + Err(e) => { + let error_detail = format!( + "Failed to open PTP device file: {:?} {:?}", + &ptp_device_path, e + ); + return Err(PhcError::Io(io::Error::new(e.kind(), error_detail))); + } + }; + + let phc_reader = PhcReader::new( + ptp_device_file, + PathBuf::from(phc_clock_error_bound_sysfs_path), + ); + + debug!(?phc_reader, "Done configuring PHC reader"); + Ok(phc_reader) + } else { + Err(PhcError::PtpDeviceNotFound( + "No eligible PTP devices found".to_string(), + )) + } +} + +/// Gets a list of network interface names on the host by inspecting +/// the files under the path "/sys/class/net/". +fn get_network_interfaces() -> Result, PhcError> { + let mut network_interfaces = Vec::new(); + let network_interfaces_path = "/sys/class/net/"; + + // Validate the file path containing entries of the network interfaces exists. + if !std::fs::exists(network_interfaces_path)? { + return Err(PhcError::FileNotFound(network_interfaces_path.into())); + } + + let entries = match std::fs::read_dir(network_interfaces_path) { + Ok(entries) => entries, + Err(e) => return Err(PhcError::Io(e)), + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => return Err(PhcError::Io(e)), + }; + + let file_name = entry.file_name().to_string_lossy().to_string(); + network_interfaces.push(file_name); + } + + tracing::debug!(?network_interfaces); + Ok(network_interfaces) +} + +/// Gets the uevent file path for a particular network interface. +fn get_uevent_file_path_for_network_interface(network_interface: &str) -> Result { + let uevent_file_path = format!("/sys/class/net/{network_interface}/device/uevent"); + if !std::fs::exists(&uevent_file_path)? { + return Err(PhcError::FileNotFound(uevent_file_path)); + } + + Ok(uevent_file_path) +} + +/// Inspects the given uevent file for a network interface and determines if +/// the corresponding driver is "ena", which is the Amazon elastic network adapter. +fn is_ena_network_interface(uevent_file_path: &str) -> Result { + let contents = match std::fs::read_to_string(uevent_file_path) { + Ok(contents) => contents, + Err(e) => return Err(PhcError::Io(e)), + }; + + let driver_name = contents + .lines() + .find_map(|line| line.strip_prefix("DRIVER=")) + .ok_or_else(|| { + PhcError::DeviceDriverNameNotFound(format!( + "Failed to find DRIVER at uevent file path {uevent_file_path}" + )) + }) + .map(std::string::ToString::to_string)?; + + tracing::debug!( + ?uevent_file_path, + ?driver_name, + "uevent file association with DRIVER value" + ); + Ok(driver_name == "ena") +} + +/// Gets the PCI slot name for a given network interface name. +/// +/// # Arguments +/// +/// * `uevent_file_path` - The path of the uevent file where we lookup the `PCI_SLOT_NAME`. +fn get_pci_slot_name(uevent_file_path: &str) -> Result { + let contents = match std::fs::read_to_string(uevent_file_path) { + Ok(contents) => contents, + Err(e) => return Err(PhcError::Io(e)), + }; + + let pci_slot_name = contents + .lines() + .find_map(|line| line.strip_prefix("PCI_SLOT_NAME=")) + .ok_or_else(|| { + PhcError::PciSlotNameNotFound(format!( + "Failed to find PCI_SLOT_NAME at uevent file path {uevent_file_path}" + )) + }) + .map(std::string::ToString::to_string)?; + + tracing::debug!( + ?uevent_file_path, + ?pci_slot_name, + "uevent file association with PCI_SLOT_NAME value" + ); + Ok(pci_slot_name) +} + +/// Gets the absolute file paths of the uevent files for PTP devices, +/// given the PCI slot name that corresponds to the ENA network interface. +/// +/// File paths are expected to look like: +/// `/sys/bus/pci/devices/{pci_slot_name}/ptp/ptp0/uevent`, +/// `/sys/bus/pci/devices/{pci_slot_name}/ptp/ptp1/uevent`, +/// `/sys/bus/pci/devices/{pci_slot_name}/ptp/ptp2/uevent`, +/// etc. +fn get_ptp_uevent_file_paths_for_pci_slot(pci_slot_name: &str) -> Result, PhcError> { + let mut uevent_file_paths = Vec::new(); + let uevent_file_search_path = format!("/sys/bus/pci/devices/{pci_slot_name}/ptp/"); + + if !std::fs::exists(&uevent_file_search_path)? { + return Err(PhcError::FileNotFound(uevent_file_search_path)); + } + + let entries = match std::fs::read_dir(&uevent_file_search_path) { + Ok(entries) => entries, + Err(e) => return Err(PhcError::Io(e)), + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => return Err(PhcError::Io(e)), + }; + + let entry_name = entry.file_name().to_string_lossy().to_string(); + if entry_name.starts_with("ptp") { + let uevent_path = format!("{uevent_file_search_path}{entry_name}/uevent"); + if std::fs::exists(&uevent_path)? { + uevent_file_paths.push(uevent_path); + } + } + } + + tracing::debug!(?uevent_file_paths, "PTP uevent file paths"); + Ok(uevent_file_paths) +} + +/// Gets the PTP device name from the given `uevent_file_path`. +/// +/// # Arguments +/// +/// * `uevent_file_path` - The path of the uevent file where we lookup DEVNAME. +fn get_ptp_device_name_from_uevent_file(uevent_file_path: &str) -> Result { + let contents = match std::fs::read_to_string(uevent_file_path) { + Ok(contents) => contents, + Err(e) => return Err(PhcError::Io(e)), + }; + + let ptp_device_name = contents + .lines() + .find_map(|line| line.strip_prefix("DEVNAME=")) + .ok_or_else(|| { + PhcError::PtpDeviceNameNotFound(format!( + "Failed to find DEVNAME at uevent file path {uevent_file_path}" + )) + }) + .map(std::string::ToString::to_string)?; + + tracing::debug!( + ?uevent_file_path, + ?ptp_device_name, + "uevent file assocation with DEVNAME value" + ); + Ok(ptp_device_name) +} + +/// Gets the PTP device path for a particular PTP device name. +/// +/// # Arguments +/// +/// * `ptp_device_name` - The network interface to lookup the PHC error bound path for. +fn get_ptp_device_path(ptp_device_name: &str) -> Result { + let ptp_device_path = format!("/dev/{ptp_device_name}"); + if !std::fs::exists(&ptp_device_path)? { + return Err(PhcError::PtpDeviceNotFound(format!( + "Failed to find PTP device at path {ptp_device_path}" + ))); + } + tracing::debug!( + ?ptp_device_name, + ?ptp_device_path, + "PTP device name association with PTP device path" + ); + Ok(ptp_device_path) +} + +/// Gets the PHC Error Bound sysfs file path given a PCI slot name. +/// +/// # Arguments +/// +/// * `pci_slot_name` - The PCI slot name to use for constructing and locating the PHC clock error bound sysfs file. +fn get_phc_clock_error_bound_sysfs_path(pci_slot_name: &str) -> Result { + let phc_clock_error_bound_sysfs_path = + format!("/sys/bus/pci/devices/{pci_slot_name}/phc_error_bound"); + if !std::fs::exists(&phc_clock_error_bound_sysfs_path)? { + return Err(PhcError::PhcClockErrorBoundFileNotFound { + pci_slot_name: pci_slot_name.into(), + }); + } + tracing::debug!( + ?pci_slot_name, + ?phc_clock_error_bound_sysfs_path, + "PCI slot name assocation with PHC clock error bound sysfs path" + ); + Ok(phc_clock_error_bound_sysfs_path) +} diff --git a/test/clock-bound-phc-offset/src/phc/ceb.rs b/test/clock-bound-phc-offset/src/phc/ceb.rs new file mode 100644 index 0000000..76c9184 --- /dev/null +++ b/test/clock-bound-phc-offset/src/phc/ceb.rs @@ -0,0 +1,26 @@ +use anyhow::{Context, Result}; +use clock_bound::daemon::time::Duration; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default)] +pub struct PhcClockErrorBoundReader { + sysfs_phc_error_bound_path: PathBuf, +} + +impl PhcClockErrorBoundReader { + pub fn new(phc_clock_error_bound_path: PathBuf) -> Self { + Self { + sysfs_phc_error_bound_path: phc_clock_error_bound_path, + } + } + + pub fn read(&self) -> Result { + let contents = std::fs::read_to_string(&self.sysfs_phc_error_bound_path) + .context("failed to read phc errror bound path to string")?; + let nanos = contents + .trim() + .parse::() + .context("failed to parse PHC error bound value to i64")?; + Ok(Duration::from_nanos(nanos.into())) + } +} diff --git a/test/clock-bound-phc-offset/src/phc/ptp.rs b/test/clock-bound-phc-offset/src/phc/ptp.rs new file mode 100644 index 0000000..646463b --- /dev/null +++ b/test/clock-bound-phc-offset/src/phc/ptp.rs @@ -0,0 +1,83 @@ +use anyhow::{Result, anyhow}; +use clock_bound::daemon::time::Instant; +use std::fs::File; + +use libc::c_uint; +use nix::ioctl_readwrite; +use std::os::unix::io::AsRawFd; + +/// `PTP_SYS_OFFSET_EXTENDED2` ioctl call. +const PTP_SYS_OFFSET_EXTENDED2: u32 = 3_300_932_882; + +/// Maximum number of samples supported within a single `PTP_SYS_OFFSET_EXTENDED2` ioctl call. +const PTP_MAX_SAMPLES: usize = 25; + +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct PtpClockTime { + pub sec: i64, + pub nsec: u32, + pub reserved: u32, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct PtpSysOffsetExtended { + /// Number of samples to collect + pub n_samples: c_uint, + /// Resevered + pub rsv: [c_uint; 3], + /// Array of samples in the form [pre-TS, PHC, post-TS ] + pub ts: [[PtpClockTime; 3]; PTP_MAX_SAMPLES], +} + +ioctl_readwrite!( + ptp_sys_offset_extended2, + b'=', + PTP_SYS_OFFSET_EXTENDED2, + PtpSysOffsetExtended +); + +#[derive(Debug, Clone, Copy)] +pub struct PtpReader { + phc_device_fd: i32, + ptp_sys_offset_extended: PtpSysOffsetExtended, +} + +impl PtpReader { + /// Construct a new `PtpReader`. This is a one-time-use struct that allows + /// a read from the specified PTP device file. + pub fn new(phc_device_file: &File) -> Self { + let phc_device_fd = phc_device_file.as_raw_fd(); + let ptp_sys_offset_extended = PtpSysOffsetExtended { + n_samples: 1, + ..Default::default() + }; + Self { + phc_device_fd, + ptp_sys_offset_extended, + } + } + + /// Consuming function call to retrieve timestamp from PTP device. + /// + /// # Errors + /// Returns an error if the PTP ioctl fails. + pub fn ptp_get_time(mut self) -> Result { + // SAFETY: The ptp_sys_offset_extended2() function is generated by the + // nix::ioctl_readwrite! macro and the call is safe because the arguments + // are expected to be valid. The file descriptor comes from a File + // that had File::open() successfully called on the path, ensuring + // that the file descriptor is valid. The other argument provided to + // the ptp_sys_offset_extended2() was created within this function + // just above, and its definition matches the expected struct format. + let result = unsafe { + ptp_sys_offset_extended2(self.phc_device_fd, &raw mut self.ptp_sys_offset_extended) + }; + let phc = match result { + Ok(_) => self.ptp_sys_offset_extended.ts[0][1], + Err(e) => return Err(anyhow!("PTP system call failed. {e:?}")), + }; + Ok(Instant::from_time(phc.sec.into(), phc.nsec)) + } +} From 3b72a0e802a81fb64c6036ea1489e9b09972a007 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 20 Nov 2025 12:50:06 -0500 Subject: [PATCH 145/177] [Package] Add systemd service and ExecPre script for installing from an RPM (#170) * [Package] Add systemd service and ExecPre script for installing from an rpm Adds a systemd service which runs clockbound, and enables the user to *enable* clockbound on startup. Installation has two flows, after installing the rpm the user can either *enable* the service for activation on boot, or they can *start* the service immediately. If enabling the service for next boot, then they can call /usr/sbin/configure_phc to enable the ena phc on next boot. * remove systemd scriplets * Remove debug logs. Default to INFO * Add kernel support check and clean up print statements --- clock-bound/Cargo.toml | 9 +++ clock-bound/assets/clockbound.service | 13 +++ clock-bound/assets/configure_phc | 112 ++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 clock-bound/assets/clockbound.service create mode 100644 clock-bound/assets/configure_phc diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 0f18c35..ac8b340 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -98,3 +98,12 @@ default = ["client", "daemon"] [[bin]] name = "clockbound" required-features = ["daemon"] + +[package.metadata.generate-rpm] +name = "clockbound" +version = "3.0.0-alpha" +assets = [ + { source = "target/release/clockbound", dest = "/usr/bin/clockbound", mode = "755" }, + { source = "assets/clockbound.service", dest = "/usr/lib/systemd/system/clockbound.service", mode = "644" }, + { source = "assets/configure_phc", dest = "/usr/sbin/configure_phc", mode = "755" }, +] \ No newline at end of file diff --git a/clock-bound/assets/clockbound.service b/clock-bound/assets/clockbound.service new file mode 100644 index 0000000..6cd974d --- /dev/null +++ b/clock-bound/assets/clockbound.service @@ -0,0 +1,13 @@ +[Unit] +Description=Feed Forward Time Synchronization Client +After=ntpdate.service sntp.service ntpd.service chronyd.service +Conflicts=ntpd.service systemd-timesyncd.service chronyd.service + +[Service] +Type=exec +ExecStartPre=-/usr/sbin/configure_phc +ExecStart=/usr/bin/clockbound +Restart=on-failure + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/clock-bound/assets/configure_phc b/clock-bound/assets/configure_phc new file mode 100644 index 0000000..23a303b --- /dev/null +++ b/clock-bound/assets/configure_phc @@ -0,0 +1,112 @@ +#!/bin/bash +# Configure the PHC device to be used by time synchronization clients + +ena_conf_file="/etc/modprobe.d/ena.conf" + +usage() { + echo "Usage: $0 [-c]" + echo "" + echo "Set up the ENA PHC on the current machine" + echo "Run without any arguments to set up the PHC immediately" + echo "Run with -c to configure $ena_conf_file and enable the phc on next boot" + echo "" + echo "If running without parameters, this script will disable and re-enable the" + echo "ENA driver." + exit 1 +} + +config_only=0 + +while getopts "hc" opt; do + case $opt in + h) + usage + ;; + c) + config_only=1 + ;; + \?) + echo "Invalid option: -$OPTARG" >&2 + usage + ;; + esac +done + +# Function as used in -c path and regular path +enable_phc() { + # Check if the configuration line already exists + if [[ -f "$ena_conf_file" ]] && grep -q "^options ena phc_enable=1" "$ena_conf_file"; then + echo "PHC option already enabled in $ena_conf_file" + return 0 + fi + + # Remove phc_enable=0 to avoid conflicts + if [[ -f "$ena_conf_file" ]] && grep -q "^options ena phc_enable=0" "$confena_conf_file_file"; then + echo "Removing existing phc_enable=0 configuration..." + sed -i '/^options ena phc_enable=0/d' "$ena_conf_file" + fi + + # Add PHC configuration + echo -n "Adding 'phc_enable=1' to $ena_conf_file..." + if echo "options ena phc_enable=1" >> "$ena_conf_file"; then + echo "Success" + else + echo "ERROR: Failed to write configuration to $ena_conf_file" + return 1 + fi +} + +# If -c passed, only do config file modification and exit +if [[ $config_only -eq 1 ]]; then + echo "-c flag passed in. Only configuring $ena_conf_file" + enable_phc + exit 0 +fi + +# Normal path +# First check if the driver has been enabled with phc_enable +param_file="/sys/module/ena/parameters/phc_enable" +if [[ ! -f "$param_file" ]]; then + echo "ERROR: ENA driver parameter file not found at $param_file" + exit 1 +fi +phc_value=$(cat "$param_file") +if [[ "$phc_value" == "1" ]]; then + echo "PHC is already enabled for the ENA driver (phc_enable=1). Exiting." + exit 0 +else + echo "PHC is not enabled for the ENA driver (phc_enable=0). Continuing." +fi + +# Check if the kernel supports this option and bail out if not +# NOTE: Not doing this check for "-c", because there is no immediate impact. +echo -n "Checking if ENA driver supports PTP 1588 clock..." +grep -w '^CONFIG_PTP_1588_CLOCK=[ym]' /boot/config-`uname -r` || { + echo "ENA driver does not support PTP 1588 clock. Exiting." + exit 0 +} +echo "Success" + +# Write the ena config +enable_phc || exit 1 + +echo -n "Restarting ENA driver..." +modprobe -r ena +modprobe ena +echo "Success" + +echo -n "checking for ptp device..." +attempts=0 +ptp_dir="/sys/class/ptp" +while [[ $attempts -lt 10 ]]; do + if [[ -d "$ptp_dir" ]] && [[ -n "$(ls -A "$ptp_dir")" ]]; then + echo "ptp device found" + ls -A "$ptp_dir" + echo "Success" + exit 0 + fi + sleep 0.2 + attempts=$((attempts+1)) +done +echo "ERROR: ptp device not found" +exit 1 \ No newline at end of file From 86e1c1b3e4496008b98621e92d5ecb81e257bce7 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 20 Nov 2025 12:52:46 -0500 Subject: [PATCH 146/177] Modify period_with_error calculation to match design (#172) This change does not change the functionality of the code, it just aids in understandability. The previous implementation was based off of bounding boxes directly. The current implementation uses error values which are derived from the bounding boxes. The net benefit is that the error growth is much more clear as they are specified as variables. A side benefit is that since the function chooses an order, we no longer need to calculate 3 slopes. We can calculate 2 since we know the steepest one will be the max error. --- clock-bound/src/daemon/event/ntp.rs | 48 ++++++++++++++++++----------- clock-bound/src/daemon/event/phc.rs | 34 ++++++++++++++------ 2 files changed, 55 insertions(+), 27 deletions(-) diff --git a/clock-bound/src/daemon/event/ntp.rs b/clock-bound/src/daemon/event/ntp.rs index 2c3cf88..1ec2653 100644 --- a/clock-bound/src/daemon/event/ntp.rs +++ b/clock-bound/src/daemon/event/ntp.rs @@ -113,32 +113,44 @@ impl Ntp { /// However, after a disruption event the effects from the clock error bound and RTT can become more pronounced. /// This calculation stays honest with that. pub fn calculate_period_with_error(&self, other: &Self) -> LocalPeriodAndError { + let (old, new) = if self.tsc_pre < other.tsc_pre { + (self, other) + } else { + (other, self) + }; + // This is the server reported clock error bound. Includes neither peer delay nor local dispersion - let other_server_ceb = other.data().root_dispersion + other.data().root_delay / 2; - let self_server_ceb = self.data().root_dispersion + self.data().root_delay / 2; + let old_server_ceb = old.data.root_dispersion + old.data.root_delay / 2; + let new_server_ceb = new.data.root_dispersion + new.data.root_delay / 2; - let self_server_midpoint = self - .data() + let old_server_midpoint = old + .data .server_recv_time - .midpoint(self.data().server_send_time); - let other_server_midpoint = other - .data() + .midpoint(old.data.server_send_time); + let new_server_midpoint = new + .data .server_recv_time - .midpoint(other.data().server_send_time); - - let period_bound_one = ((self_server_midpoint - self_server_ceb) - - (other_server_midpoint + other_server_ceb)) - / (self.tsc_post - other.tsc_pre); - let period_bound_two = ((self_server_midpoint + self_server_ceb) - - (other_server_midpoint - other_server_ceb)) - / (self.tsc_pre - other.tsc_post); + .midpoint(new.data.server_send_time); + + // Unit-less error values + let period_error_from_ceb = (old_server_ceb + new_server_ceb).as_seconds_f64() + / (new_server_midpoint - old_server_midpoint).as_seconds_f64(); + #[allow( + clippy::cast_precision_loss, + reason = "Durations will be a max of 2 weeks. Precision loss is minimized" + )] + let period_error_from_rtt = (old.rtt() + new.rtt()).get() as f64 + / (2.0 * (new.tsc_midpoint() - old.tsc_midpoint()).get() as f64); let period = self.calculate_period(other); - let period_error_one = (period_bound_one.get() - period.get()).abs(); - let period_error_two = (period_bound_two.get() - period.get()).abs(); + // Calculates the "steepest" possible slope based off of the error bounding boxes + let period_shrink = + period.get() * ((1.0 + period_error_from_ceb) / (1.0 - period_error_from_rtt)); - let error = Period::from_seconds(period_error_one.max(period_error_two)); + // Error is the difference of the two slopes + let error = (period.get() - period_shrink).abs(); + let error = Period::from_seconds(error); LocalPeriodAndError { period_local: period, diff --git a/clock-bound/src/daemon/event/phc.rs b/clock-bound/src/daemon/event/phc.rs index 1a2c972..d291f10 100644 --- a/clock-bound/src/daemon/event/phc.rs +++ b/clock-bound/src/daemon/event/phc.rs @@ -95,20 +95,36 @@ impl Phc { /// /// However, after a disruption event the effects from the clock error bound and RTT can become more pronounced. /// This calculation stays honest with that. + /// + /// # Panics + /// Panics if the two events are equal pub fn calculate_period_with_error(&self, other: &Self) -> LocalPeriodAndError { - let period_bound_one = ((self.data.time - self.data.clock_error_bound) - - (other.data.time + other.data.clock_error_bound)) - / (self.tsc_post - other.tsc_pre); - let period_bound_two = ((self.data.time + self.data.clock_error_bound) - - (other.data.time - other.data.clock_error_bound)) - / (self.tsc_pre - other.tsc_post); + let (old, new) = if self.tsc_pre < other.tsc_pre { + (self, other) + } else { + (other, self) + }; + + // Unit-less error values + let period_error_from_ceb = (old.data.clock_error_bound + new.data.clock_error_bound) + .as_seconds_f64() + / (new.data.time - old.data.time).as_seconds_f64(); + #[allow( + clippy::cast_precision_loss, + reason = "Durations will be a max of 2 weeks. Precision loss is minimized" + )] + let period_error_from_rtt = (old.rtt() + new.rtt()).get() as f64 + / (2.0 * (new.tsc_midpoint() - old.tsc_midpoint()).get() as f64); let period = self.calculate_period(other); - let period_error_one = (period_bound_one.get() - period.get()).abs(); - let period_error_two = (period_bound_two.get() - period.get()).abs(); + // Calculates the "steepest" possible slope based off of the error bounding boxes + let period_shrink = + period.get() * ((1.0 + period_error_from_ceb) / (1.0 - period_error_from_rtt)); - let error = Period::from_seconds(period_error_one.max(period_error_two)); + // Error is the difference of the two slopes + let error = (period.get() - period_shrink).abs(); + let error = Period::from_seconds(error); LocalPeriodAndError { period_local: period, From eecc99980a014960bcf1a94e81cfd0bb776e5868 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 20 Nov 2025 13:34:23 -0500 Subject: [PATCH 147/177] Improve PHC ability to react to changes in oscillator frequency (#161) * Improve PHC ability to react to changes in oscillator frequency Also added ability to replay repo logs into new versions of the clock sync algorithm. Hi primer, nice to finally meet you. Adding this change because we have seen examples of the oscillator changing in frequency at a much faster rate than the SKM window on some processors. This tradeoff improves reactivity of the algorithm (period window calculated within 100 seconds instead of 1024), at the cost of period_max_error calculations. Trade-off seems worth as the algorithm offset from phc read has improved from 50 usec offset to 2 usec offset in this extreme case * Actually use shorter window instead of just having a lower capacity. * decoupled the shorter window calculations from estimate_buffer len Now added a 3rd buffer, which is solely used to capture data over 1024 seconds and then feed into the estimate buffer. This keeps the estimate buffer functionality completely unchanged. TODO: As we settle on the algorithm approaches, having a third buffer is unnecessary and we should be able to optimize memory usage. --- clock-bound-ff-tester/src/bin/repro_replay.rs | 58 +++++++++++++++++++ clock-bound-ff-tester/src/repro.rs | 39 ++++++++++++- .../ff/event_buffer/estimate.rs | 2 +- .../ff/event_buffer/local.rs | 19 +++++- .../src/daemon/clock_sync_algorithm/ff/phc.rs | 32 +++++++--- 5 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 clock-bound-ff-tester/src/bin/repro_replay.rs diff --git a/clock-bound-ff-tester/src/bin/repro_replay.rs b/clock-bound-ff-tester/src/bin/repro_replay.rs new file mode 100644 index 0000000..2c00100 --- /dev/null +++ b/clock-bound-ff-tester/src/bin/repro_replay.rs @@ -0,0 +1,58 @@ +//! Replay a repro logfile against the current `ClockSyncAlgorithm` and see how it goes +//! +//! Prints the new outputs into another log-directory + +use std::{path::PathBuf, sync::Arc}; + +use clap::Parser; +use clock_bound::daemon::{ + self, + clock_sync_algorithm::{ClockSyncAlgorithm, Selector, source}, + selected_clock::SelectedClockSource, +}; +use clock_bound_ff_tester::{ + events::Scenario, + repro::{self, routable_from_tester_event}, + time::Skew, +}; + +#[derive(Parser, Debug)] +/// Replay a scenario from a logfile back into the `ClockSyncAlgorithm` +/// +/// Outputs the results from this run into `--output-dir` with the same +/// format as the input +struct Args { + /// The logfile to replay + #[arg(short, long)] + logfile: PathBuf, + + /// The output log directory + #[arg(short, long)] + output_dir: PathBuf, +} + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let max_dispersion = Skew::from_ppm(15.0); + let mut alg = ClockSyncAlgorithm::builder() + .link_local(source::LinkLocal::new(max_dispersion)) + .ntp_sources(vec![]) + .phc(source::Phc::new("/dev/ptp0".into(), max_dispersion)) + .selected_clock(Arc::new(SelectedClockSource::default())) + .selector(Selector::new(max_dispersion)) + .build(); + + let scenario = repro::scenario_from_log_file(&args.logfile)?; + + daemon::subscriber::init(args.output_dir); + + let Scenario::V1(scenario) = scenario.0; + let events = scenario.events; + + for event in events { + let routable = routable_from_tester_event(event); + alg.feed(routable); + } + + Ok(()) +} diff --git a/clock-bound-ff-tester/src/repro.rs b/clock-bound-ff-tester/src/repro.rs index 0147618..8cb267c 100644 --- a/clock-bound-ff-tester/src/repro.rs +++ b/clock-bound-ff-tester/src/repro.rs @@ -10,7 +10,10 @@ use std::{ use crate::events::{Scenario, v1}; use crate::time::CbBridge; use clock_bound::daemon::{ - clock_parameters::ClockParameters, event, receiver_stream::RoutableEvent, + clock_parameters::ClockParameters, + event, + receiver_stream::RoutableEvent, + time::{Duration, Instant}, }; /// Read a logfile and return all inputs and outputs from the `ClockSyncAlgorithm` @@ -138,6 +141,40 @@ fn tester_event_from_routable(event: &RoutableEvent) -> v1::Event { } } +#[expect(clippy::missing_panics_doc, reason = "malformed input otherwise")] +pub fn routable_from_tester_event(event: v1::Event) -> RoutableEvent { + match event.variants { + v1::EventKind::Ntp(ntp) => { + let event = event::Ntp::builder() + .ntp_data(clock_bound::daemon::event::NtpData { + server_recv_time: Instant::from_estimate(ntp.server_system_recv_time), + server_send_time: Instant::from_estimate(ntp.server_system_send_time), + root_delay: Duration::from_estimate(ntp.root_delay), + root_dispersion: Duration::from_estimate(ntp.root_dispersion), + stratum: clock_bound::daemon::event::Stratum::TWO, + }) + .tsc_pre(event.client_tsc_pre_time) + .tsc_post(event.client_tsc_post_time) + .build() + .unwrap(); + RoutableEvent::LinkLocal(event) + } + v1::EventKind::Phc(phc) => { + let event = event::Phc::builder() + .data(clock_bound::daemon::event::PhcData { + time: Instant::from_estimate(phc.phc_time), + clock_error_bound: Duration::from_estimate(phc.clock_error_bound.unwrap()), + }) + .tsc_pre(event.client_tsc_pre_time) + .tsc_post(event.client_tsc_post_time) + .build() + .unwrap(); + RoutableEvent::Phc(event) + } + v1::EventKind::VMClock(_) => panic!("unsupported"), + } +} + /// Convenience struct to parse each line of the log /// /// It's not exhaustive, but has the minimum number of fields diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs index 3bfc31e..c5bc479 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs @@ -145,7 +145,7 @@ impl Estimate { let diff = now_event_tsc_post - last_tsc_post; let duration = diff * period_estimate; - if duration < super::Local::::SKM_WINDOW { + if duration < local.window() { // SKM window hasn't expired yet. Bail out None } else { diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs index d1f63b9..b0030e2 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs @@ -37,6 +37,8 @@ pub struct Local { /// If a value is less than the `min_rtt * rtt_threshold_multiplier`, /// then we don't add it rtt_threshold_multiplier: usize, + + window: Duration, } impl Local { @@ -51,6 +53,15 @@ impl Local { Self { inner: RingBuffer::new(capacity), rtt_threshold_multiplier: 5, + window: Self::SKM_WINDOW, + } + } + + pub fn with_window(capacity: NonZeroUsize, window: Duration) -> Self { + Self { + inner: RingBuffer::new(capacity), + rtt_threshold_multiplier: 5, + window, } } @@ -62,9 +73,14 @@ impl Local { Self { inner: RingBuffer::new(capacity), rtt_threshold_multiplier, + window: Self::SKM_WINDOW, } } + pub fn window(&self) -> Duration { + self.window + } + /// Returns an iterator of events from oldest to newest pub fn iter(&self) -> impl DoubleEndedIterator { self.inner.iter() @@ -80,6 +96,7 @@ impl Local { let Self { inner, rtt_threshold_multiplier: _rtt, + window: _, } = self; inner.clear(); } @@ -129,7 +146,7 @@ impl Local { let now_post_tsc = head.tsc_post(); // We need to calculate the corresponding TSC for an SKM window ago - let cutoff_tsc = now_post_tsc - (Self::SKM_WINDOW / period); + let cutoff_tsc = now_post_tsc - (self.window / period); // If cutoff tsc is negative, that means we booted within an SKM window seconds. // Log and bail diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs index e88cd1d..f72368a 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs @@ -18,8 +18,15 @@ use crate::daemon::{ /// Feed forward time synchronization algorithm for a single PHC source #[derive(Debug, Clone, PartialEq)] pub struct Phc { - /// Events within the current SKM (within 1024 seconds) + /// Events within 100 seconds. + /// + /// Used for calculation of `period_local` and `theta` local: event_buffer::Local, + /// Staging data over the last 1024 seconds. + /// + /// This data is used solely to stage data before feeding + /// into the estimate buffer + staging: event_buffer::Local, /// Best RTT values of each SKM over the last week estimate: event_buffer::Estimate, /// Current calculation of [`ClockParameters`] @@ -43,19 +50,24 @@ impl Phc { /// # Panics /// Panics if poll duration is zero or greater than or equal to 512 seconds pub fn new(poll_period: Duration, max_dispersion: Skew) -> Self { + const WINDOW: Duration = Duration::from_secs(100); assert!(poll_period > Duration::ZERO, "poll period must be positive"); assert!( - poll_period < event_buffer::Local::::SKM_WINDOW / 2, + poll_period < WINDOW / 2, "Must be able to get at least 2 samples in local buffer" ); - let local_capacity = - event_buffer::Local::::SKM_WINDOW.get() / poll_period.get(); + let local_capacity = WINDOW.get() / poll_period.get(); // unwrap: input is bound and numerator is never 0 let local_capacity = NonZeroUsize::new(local_capacity.try_into().unwrap()).unwrap(); + // unwrap: input is bound and numerator is never 0 + let staging_capacity = event_buffer::Local::::SKM_WINDOW.get() / poll_period.get(); + let staging_capacity = NonZeroUsize::new(staging_capacity.try_into().unwrap()).unwrap(); + Self { - local: event_buffer::Local::new(local_capacity), + local: event_buffer::Local::with_window(local_capacity, WINDOW), + staging: event_buffer::Local::new(staging_capacity), estimate: event_buffer::Estimate::new(), clock_parameters: None, uncorrected_clock: None, @@ -180,11 +192,13 @@ impl Phc { )] fn feed_internal_buffers(&mut self, event: event::Phc) -> Result> { let event_rtt = event.rtt(); - self.local.feed(event)?; + self.local.feed(event.clone())?; + self.staging.feed(event)?; if let Some(uc) = self.uncorrected_clock { self.local.expunge_old_events(uc.p_estimate); - if let Some(new_estimate) = self.estimate.feed(&self.local, uc.p_estimate) { + self.staging.expunge_old_events(uc.p_estimate); + if let Some(new_estimate) = self.estimate.feed(&self.staging, uc.p_estimate) { tracing::info!(?new_estimate, "New value added to estimate buffer"); } } @@ -202,6 +216,7 @@ impl Phc { // Destructure pattern makes handling new fields mandatory let Self { local, + staging, estimate, clock_parameters, uncorrected_clock, @@ -209,6 +224,7 @@ impl Phc { } = self; local.handle_disruption(); + staging.handle_disruption(); estimate.handle_disruption(); *clock_parameters = None; *uncorrected_clock = None; @@ -711,7 +727,7 @@ mod tests { #[test] fn feed_two_events() { - let mut ff = Phc::new(Duration::from_secs(50), Skew::from_ppm(15.0)); + let mut ff = Phc::new(Duration::from_secs(20), Skew::from_ppm(15.0)); let event1 = event::Phc::builder() .tsc_pre(TscCount::new(1_000_000_000)) From 8a31e380a75c08db40e67f805735169e9ba62038 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:44:08 -0500 Subject: [PATCH 148/177] add client clock comparisons in phc offset tester This commit adds support for using the generic clock bound client testing library to read and compare the client-exposed clock-bound clock with PHC clock reads. It also overhauls a significant portion of the data representation structs and output. --- Cargo.lock | 3 + Cargo.toml | 1 + test/clock-bound-client-generic/src/lib.rs | 38 ++++- test/clock-bound-now/src/main.rs | 14 +- test/clock-bound-phc-offset/Cargo.toml | 3 + test/clock-bound-phc-offset/src/lib.rs | 4 +- test/clock-bound-phc-offset/src/main.rs | 44 ++++-- test/clock-bound-phc-offset/src/phc.rs | 155 +++++++++++++++++---- 8 files changed, 217 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 157d389..26d3c3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,8 +407,11 @@ dependencies = [ "anyhow", "clap", "clock-bound", + "clock-bound-client-generic", "libc", "nix", + "serde", + "serde_json", "thiserror 2.0.17", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index a6650fa..83d204f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "test/clock-bound-adjust-clock", "test/clock-bound-adjust-clock-test", "test/vmclock", + "test/clock-bound-client-generic", "test/clock-bound-now", "test/clock-bound-phc-offset", ] diff --git a/test/clock-bound-client-generic/src/lib.rs b/test/clock-bound-client-generic/src/lib.rs index e0ad988..f4273a5 100644 --- a/test/clock-bound-client-generic/src/lib.rs +++ b/test/clock-bound-client-generic/src/lib.rs @@ -5,6 +5,8 @@ use anyhow::{Result, anyhow}; use clap::ValueEnum; +use clock_bound::daemon::time::Duration; +use clock_bound::daemon::time::Instant; use nix::sys::time::TimeSpec; use clock_bound::client as clock_bound_client_v3; @@ -14,7 +16,7 @@ use clock_bound_client as clock_bound_client_v2; const NSECS_PER_SEC: i64 = 1_000_000_000; #[repr(C)] -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize)] pub enum ClockStatus { Unknown = 0, Synchronized = 1, @@ -108,7 +110,7 @@ impl ClockBoundClient { /// /// # Errors /// Returns an error if the clock bound time retrieval fails. - pub fn now(&mut self) -> Result { + fn now(&mut self) -> Result { match self { Self::ClientV2(client) => match client.now() { Ok(now) => Ok(ClockBoundNowResult::from(now)), @@ -121,3 +123,35 @@ impl ClockBoundClient { } } } + +/// Convenience struct holding timestamp, error bound, and status. +/// Encapsulates the output of interest from a clock bound client read. +#[derive(Debug, serde::Serialize)] +pub struct TimeAndBoundAndStatus { + pub time: Instant, + pub ceb: Duration, + pub status: ClockStatus, +} +impl From for TimeAndBoundAndStatus { + fn from(value: ClockBoundNowResult) -> Self { + let time = Instant::from_nanos(value.timestamp.into()); + let ceb = Duration::from_nanos(value.ceb.into()); + let status = value.clock_status; + + TimeAndBoundAndStatus { time, ceb, status } + } +} + +pub trait ClockBoundClientClock { + /// Retrieves timestamp, error bound, and status from a clock bound client clock. + /// + /// # Errors + /// Returns an error if clock read fails. + fn get_time_and_bound_and_status(&mut self) -> Result; +} + +impl ClockBoundClientClock for ClockBoundClient { + fn get_time_and_bound_and_status(&mut self) -> Result { + Ok(TimeAndBoundAndStatus::from(self.now()?)) + } +} diff --git a/test/clock-bound-now/src/main.rs b/test/clock-bound-now/src/main.rs index c595a6b..b6bbe80 100644 --- a/test/clock-bound-now/src/main.rs +++ b/test/clock-bound-now/src/main.rs @@ -1,7 +1,9 @@ //! A program to read the ClockBound SHM segment's earliest and latest bounds. use clap::Parser; -use clock_bound_client_generic::{ClockBoundClient, ClockBoundClientVersion}; +use clock_bound_client_generic::{ + ClockBoundClient, ClockBoundClientClock, ClockBoundClientVersion, +}; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -15,12 +17,8 @@ fn main() { let args = Args::parse(); let mut client = ClockBoundClient::new(args.client_version); - match client.now() { - Ok(now) => { - tracing::info!("{now:?}"); - } - Err(e) => { - tracing::error!("Failed to retrieve clock bounds: {e:?}"); - } + match client.get_time_and_bound_and_status() { + Ok(data) => println!("{}", serde_json::to_string(&data).unwrap()), + Err(e) => tracing::error!("Failed to retrieve clock bounds: {e:?}"), } } diff --git a/test/clock-bound-phc-offset/Cargo.toml b/test/clock-bound-phc-offset/Cargo.toml index 7d10155..71947d4 100644 --- a/test/clock-bound-phc-offset/Cargo.toml +++ b/test/clock-bound-phc-offset/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.100" +clock-bound-client-generic = { version = "2.0", path = "../clock-bound-client-generic" } clock-bound = { version = "2.0", path = "../../clock-bound", features = [ "daemon", "client", @@ -30,3 +31,5 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["std"] } thiserror = "2.0" clap = { version = "4.5.31", features = ["derive"] } +serde = "1.0" +serde_json = "1.0.145" diff --git a/test/clock-bound-phc-offset/src/lib.rs b/test/clock-bound-phc-offset/src/lib.rs index 712512c..0ee13fa 100644 --- a/test/clock-bound-phc-offset/src/lib.rs +++ b/test/clock-bound-phc-offset/src/lib.rs @@ -1,2 +1,4 @@ mod phc; -pub use phc::{OffsetRttAndCeb, PhcReader, autoconfigure_phc_reader}; +pub use phc::{ + ClockBoundClientClock, ClockBoundClientRefComparison, PhcReader, autoconfigure_phc_reader, +}; diff --git a/test/clock-bound-phc-offset/src/main.rs b/test/clock-bound-phc-offset/src/main.rs index dd456ba..f36334d 100644 --- a/test/clock-bound-phc-offset/src/main.rs +++ b/test/clock-bound-phc-offset/src/main.rs @@ -2,15 +2,19 @@ //! //! This executable compares specific clocks against timestamps read from the PHC. +use anyhow::Result; use clap::{Parser, ValueEnum}; use clock_bound::daemon::time::clocks::{MonotonicRaw, RealTime}; +use clock_bound_client_generic::{ClockBoundClient, ClockBoundClientVersion}; use clock_bound_phc_offset::autoconfigure_phc_reader; #[derive(Debug, Clone, Copy, ValueEnum)] enum ClockToCompare { RealTime, MonotonicRaw, + ClockBoundClientV2, + ClockBoundClientV3, } #[derive(Parser, Debug)] @@ -20,6 +24,15 @@ struct Args { clock: ClockToCompare, } +fn emit_logs(clock: ClockToCompare, result: Result) { + match result { + Ok(data) => println!("{}", serde_json::to_string(&data).unwrap()), + Err(e) => tracing::error!( + "Failed to retrieve data from {clock:?} Clock <-> PHC comparison: {e:?}" + ), + } +} + fn main() { tracing_subscriber::fmt().json().init(); let args = Args::parse(); @@ -28,15 +41,28 @@ fn main() { let phc_reader = autoconfigure_phc_reader().expect("failed to create PHC reader via autoconfiguration"); - let offset_rtt_ceb = match clock { - ClockToCompare::RealTime => phc_reader.get_offset_from_utc_clock(&RealTime), - ClockToCompare::MonotonicRaw => phc_reader.get_offset_from_utc_clock(&MonotonicRaw), - }; - - match offset_rtt_ceb { - Ok(offset_rtt_ceb) => tracing::info!("{offset_rtt_ceb:?}"), - Err(e) => tracing::error!( - "Failed to retrieve offset, rtt, ceb from {clock:?} Clock <-> PHC comparison: {e:?}" + match clock { + ClockToCompare::RealTime => emit_logs( + ClockToCompare::RealTime, + phc_reader.get_offset_from_utc_clock(&RealTime), + ), + ClockToCompare::MonotonicRaw => emit_logs( + ClockToCompare::MonotonicRaw, + phc_reader.get_offset_from_utc_clock(&MonotonicRaw), ), + ClockToCompare::ClockBoundClientV2 => { + let mut client = ClockBoundClient::new(ClockBoundClientVersion::V2); + emit_logs( + ClockToCompare::ClockBoundClientV2, + phc_reader.compare_to_clock_bound_client_clock(&mut client), + ); + } + ClockToCompare::ClockBoundClientV3 => { + let mut client = ClockBoundClient::new(ClockBoundClientVersion::V3); + emit_logs( + ClockToCompare::ClockBoundClientV2, + phc_reader.compare_to_clock_bound_client_clock(&mut client), + ); + } } } diff --git a/test/clock-bound-phc-offset/src/phc.rs b/test/clock-bound-phc-offset/src/phc.rs index 6cdc0a0..363a7ec 100644 --- a/test/clock-bound-phc-offset/src/phc.rs +++ b/test/clock-bound-phc-offset/src/phc.rs @@ -1,9 +1,10 @@ use anyhow::Result; +use clock_bound::daemon::io::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; +pub use clock_bound_client_generic::{ClockBoundClientClock, TimeAndBoundAndStatus}; use std::{fs::File, path::PathBuf}; -use clock_bound::daemon::time::inner::{ClockOffsetAndRtt, Time}; use clock_bound::daemon::time::instant::Utc; -use clock_bound::daemon::time::{Clock, ClockExt, Duration}; +use clock_bound::daemon::time::{ClockExt, Duration, Instant, TscCount, TscDiff}; mod ptp; use ptp::PtpReader; @@ -14,13 +15,61 @@ use ceb::PhcClockErrorBoundReader; mod autoconfiguration; pub use autoconfiguration::autoconfigure_phc_reader; +/// Convenience struct holding data pertaining to a snaphot from a reference PHC read. #[allow(dead_code)] -#[derive(Debug)] -pub struct OffsetRttAndCeb { - offset_and_rtt: ClockOffsetAndRtt, +#[derive(Debug, serde::Serialize)] +struct RefClockData { + /// Timestamp read from PHC. + time: Instant, + /// Offset between midpoint of pre/post clock read and PHC timestamp. + /// A positive value here indicates that the clock is ahead of the PHC. + offset: Duration, + /// RTT representing duration between pre/post clock reads (and bounding PHC read). + /// Period is inherited from the clock we're reading from. + rtt_clock: Duration, + /// RTT in representing duration between pre/post TSC reads (and bounding PHC read). + /// Value represents elapsed TSC ticks. + rtt_tsc: TscDiff, + /// Clock Error Bound reported by the PHC. + /// This is read just after the post clock read operation. ceb: Duration, } +/// Convenience struct wrapping an instant. Represents the read of a basic clock (one with no status/error-bound). +/// Really this just exists so that we can normalize the `BasicClockRefComparison` and `ClockBoundClientRefComparison` json format. +#[allow(dead_code)] +#[derive(Debug, serde::Serialize)] +struct BasicClockTime { + time: Instant, +} +impl From for BasicClockTime { + fn from(value: Instant) -> Self { + Self { time: value } + } +} + +/// Convenience struct representing all data of interest from a comparison read between a "basic clock" and the PHC. +#[allow(dead_code)] +#[derive(Debug, serde::Serialize)] +pub struct BasicClockRefComparison { + clock_pre: BasicClockTime, + tsc_pre: TscCount, + ref_clock: RefClockData, + tsc_post: TscCount, + clock_post: BasicClockTime, +} + +/// Convenience struct representing all data of interest from a comparison read between a clock bound client clock and the PHC. +#[allow(dead_code)] +#[derive(Debug, serde::Serialize)] +pub struct ClockBoundClientRefComparison { + clock_pre: TimeAndBoundAndStatus, + tsc_pre: TscCount, + ref_clock: RefClockData, + tsc_post: TscCount, + clock_post: TimeAndBoundAndStatus, +} + #[derive(Debug)] pub struct PhcReader { // we need to hold the actual File to keep the raw fd valid. @@ -29,6 +78,8 @@ pub struct PhcReader { ceb_reader: PhcClockErrorBoundReader, } impl PhcReader { + /// Constructs a `PhcReader` struct using the specified PHC device file and PHC CEB file path. + /// Should not be called directly - used by autoconfiguration function. pub fn new(phc_device_file: File, phc_clock_error_bound_path: PathBuf) -> Self { let ptp_reader = PtpReader::new(&phc_device_file); let ceb_reader = PhcClockErrorBoundReader::new(phc_clock_error_bound_path); @@ -39,33 +90,87 @@ impl PhcReader { } } - /// Return offset, rtt, and CEB from a clock to PHC comparision. + /// Retrieves a timestamp from the PHC device. /// /// # Errors - /// Returns an error if CEB read operation fails. + /// Returns an error if the PTP system call fails. + pub fn get_time(&self) -> Result { + self.ptp_reader.ptp_get_time() + } + + /// Return details from a "basic" UTC clock to PHC comparision. + /// + /// # Errors + /// Returns an error if the PHC read or CEB read operation fails. pub fn get_offset_from_utc_clock>( &self, clock: &T, - ) -> Result { - let offset_and_rtt = clock.get_offset_and_rtt(self); - let ceb = self.ceb_reader.read()?; - Ok(OffsetRttAndCeb { - offset_and_rtt, - ceb, + ) -> Result { + let clock_pre = clock.get_time(); + let tsc_pre = read_timestamp_counter_begin(); + let phc_time = self.get_time()?; + let tsc_post = read_timestamp_counter_end(); + let clock_post = clock.get_time(); + let phc_ceb = self.ceb_reader.read()?; + + let mid = clock_pre.midpoint(clock_post); + let offset = mid - phc_time; + let rtt_clock = clock_post - clock_pre; + + let tsc_pre = TscCount::new(tsc_pre.into()); + let tsc_post = TscCount::new(tsc_post.into()); + let rtt_tsc = tsc_post - tsc_pre; + + Ok(BasicClockRefComparison { + clock_pre: BasicClockTime::from(clock_pre), + tsc_pre, + ref_clock: RefClockData { + time: phc_time, + offset, + rtt_clock, + rtt_tsc, + ceb: phc_ceb, + }, + tsc_post, + clock_post: BasicClockTime::from(clock_post), }) } -} -/// # Panics -/// Panics if PTP get time system call fails. -/// FIXME: ideally this trait signature would return a Result so we can avoid the panic here. -/// But that would require changing the logic in the daemon libary. -/// We could do that or re-implement this trait here. -impl Clock for PhcReader { - #[doc = " Read the current clock time."] - fn get_time(&self) -> Time { - self.ptp_reader - .ptp_get_time() - .expect("failed to get time from PHC") + /// Return comparison data from a ClockBound client clock to PHC. + /// + /// # Errors + /// Returns an error if the PTP syscall, PHC read, or client read operations fail. + pub fn compare_to_clock_bound_client_clock( + &self, + clock: &mut T, + ) -> Result { + let clock_pre = clock.get_time_and_bound_and_status()?; + let tsc_pre = read_timestamp_counter_begin(); + let phc_time = self.get_time()?; + let tsc_post = read_timestamp_counter_end(); + let clock_post = clock.get_time_and_bound_and_status()?; + let phc_ceb = self.ceb_reader.read()?; + + let mid = clock_pre.time.midpoint(clock_post.time); + let offset = mid - phc_time; + let rtt_clock = clock_post.time - clock_pre.time; + + let tsc_pre = TscCount::new(tsc_pre.into()); + let tsc_post = TscCount::new(tsc_post.into()); + let rtt_tsc = tsc_post - tsc_pre; + + Ok(ClockBoundClientRefComparison { + clock_pre, + tsc_pre, + ref_clock: RefClockData { + time: phc_time, + offset, + rtt_clock, + rtt_tsc, + ceb: phc_ceb, + }, + tsc_post, + clock_post, + }) } } From 3c0fafa9a69063064be86e0040b935b332e99c02 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Thu, 20 Nov 2025 10:52:23 -0800 Subject: [PATCH 149/177] [writer] Fix CEB calculation and simplify writer logic (#169) * [writer] Fix CEB calculation and simplify writer logic This patch clarifies and in some places fixes the calculation of the clock error bound shared with clients when writing the shared memory segment. Prior to this, the logic to support ClockBound 2.0 clients was not calculating the bound correctly. The complexity is due to the fact these old clients rely on reading CLOCK_MONOTONIC_COARSE time to grow the CEB value they read from the shared memory segment (the `as_of` field in the SHM layout). This patch creates the `as_of` timestamp *before* the clock parameters are computed to ensure the clients will inflate the CEB pessimistically. This `as_of` timestamp is now stored with the clock parameters computed and send to the ClockState to write out. On one side, this adds an extra and somewhat unrelated field to the ClockParameter struct. On the flip side, this allows to simplify a decent amount of code on the ClockState and ClockStateWriter logic. * rev2: address comments and fix unit tests --- clock-bound/src/daemon/clock_parameters.rs | 12 + clock-bound/src/daemon/clock_state.rs | 28 +- .../src/daemon/clock_state/clock_adjust.rs | 1 + .../clock_state/clock_adjust/state_machine.rs | 8 + .../daemon/clock_state/clock_state_writer.rs | 251 ++++++------------ .../src/daemon/clock_sync_algorithm/ff/ntp.rs | 30 ++- .../src/daemon/clock_sync_algorithm/ff/phc.rs | 15 +- .../daemon/clock_sync_algorithm/selector.rs | 8 + clock-bound/src/daemon/time/clocks.rs | 2 + clock-bound/src/daemon/time/inner.rs | 2 +- clock-bound/src/daemon/time/tsc.rs | 3 + 11 files changed, 166 insertions(+), 194 deletions(-) diff --git a/clock-bound/src/daemon/clock_parameters.rs b/clock-bound/src/daemon/clock_parameters.rs index 7ab0128..441486a 100644 --- a/clock-bound/src/daemon/clock_parameters.rs +++ b/clock-bound/src/daemon/clock_parameters.rs @@ -23,6 +23,10 @@ pub struct ClockParameters { pub period: Period, /// The max error of the `period` at `tsc_count` pub period_max_error: Period, + /// The `CLOCK_MONOTONIC_COARSE` time just before these parameters are calculated. + /// FIXME: remove when ClockBound 2.0 clients are not supported anymore. + #[serde(skip)] + pub as_of_monotonic: Instant, } impl ClockParameters { @@ -84,6 +88,7 @@ mod test { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // unused }, // Second event (identical) event::Ntp::builder() @@ -110,6 +115,7 @@ mod test { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // unused }, // Second event with worse RTT event::Ntp::builder() @@ -136,6 +142,7 @@ mod test { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // unused }, // Second event (newer, 1 second later) event::Ntp::builder() @@ -162,6 +169,7 @@ mod test { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // unused }, // Second event event::Ntp::builder() @@ -188,6 +196,7 @@ mod test { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // unused }, // Second event event::Ntp::builder() @@ -222,6 +231,7 @@ mod test { clock_error_bound: second.calculate_clock_error_bound(period), period, period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // unused }; let result = val.more_accurate_than(&first, max_dispersion); assert_eq!(result, expected); @@ -236,6 +246,7 @@ mod test { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // unused }, // Second event (new, 10 seconds later) event::Ntp::builder() @@ -270,6 +281,7 @@ mod test { clock_error_bound: second.calculate_clock_error_bound(period), period, period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // unused }; let result = val.more_accurate_than(&first, max_dispersion); assert_eq!(result, expected); diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index e9e7cd6..b0e9718 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -165,18 +165,18 @@ impl ClockState { fn handle_tick(&mut self, now: tokio::time::Instant) { if let Some(parameters) = &self.clock_parameters { self.clock_adjuster.handle_clock_parameters(now, parameters); + + // FIXME: this is likely not right. + // The underlying assumption is that only the shm0 file was written to. But we now have + // two writers, and the status of the clock written to shm1 should NOT depend on + // whatever the system clock is doing. let clock_status = self.clock_adjuster.get_clock_realtime_status(); + // FIXME: Initializing behavior of ClockStateWriter should have us write // the initial clock status as unknown to SHM segment. Else, the clock might be adjusted // by ClockAdjuster on its initial state, while the SHM isn't updated with that info.. - let clockbound_clock = ClockBound::new(parameters.clone(), ReadTscImpl); - // TODO: implement multiple attempts in case of latency increase - let clock_realtime_offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); - self.state_writer.handle_clock_parameters( - parameters, - clock_status, - clock_realtime_offset_and_rtt, - ); + self.state_writer + .handle_clock_parameters(parameters, clock_status); } } @@ -246,6 +246,7 @@ mod tests { clock_error_bound: Duration::new(0), period: Period::from_seconds(0.0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), } } @@ -288,6 +289,7 @@ mod tests { clock_error_bound: Duration::from_nanos(1000), period: Period::from_seconds(1e-9), period_max_error: Period::from_seconds(1e-11), + as_of_monotonic: Instant::from_nanos(1000), }; let expected_clock_parameters = clock_parameters.clone(); mock_clock_adjuster @@ -371,12 +373,10 @@ mod tests { mock_clock_state_writer .expect_handle_clock_parameters() .once() - .withf( - move |actual_clock_params, actual_clock_status, _offset_and_rtt| { - *actual_clock_params == expected_clock_params_clone - && *actual_clock_status == expected_clock_status - }, - ) + .withf(move |actual_clock_params, actual_clock_status| { + *actual_clock_params == expected_clock_params_clone + && *actual_clock_status == expected_clock_status + }) .in_sequence(&mut sequence) .return_const(()); diff --git a/clock-bound/src/daemon/clock_state/clock_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs index a57307a..f10102f 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -219,6 +219,7 @@ mod test { time: Instant::new(0), clock_error_bound: Duration::new(0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), } } diff --git a/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs b/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs index 40ff76e..4c05168 100644 --- a/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs +++ b/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs @@ -462,6 +462,7 @@ mod test { time: Instant::new(0), clock_error_bound: Duration::new(0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), } } @@ -773,6 +774,7 @@ mod test { time: Instant::new(0), clock_error_bound: Duration::new(0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), }, ClockSnapshot { system_clock: SystemClockMeasurement { @@ -798,6 +800,7 @@ mod test { time: Instant::new(0), clock_error_bound: Duration::new(0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), }, ClockSnapshot { system_clock: SystemClockMeasurement { @@ -823,6 +826,7 @@ mod test { time: Instant::new(0), clock_error_bound: Duration::new(0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), }, ClockSnapshot { system_clock: SystemClockMeasurement { @@ -848,6 +852,7 @@ mod test { time: Instant::new(0), clock_error_bound: Duration::new(0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), }, ClockSnapshot { system_clock: SystemClockMeasurement { @@ -873,6 +878,7 @@ mod test { time: Instant::new(0), clock_error_bound: Duration::new(0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), }, ClockSnapshot { system_clock: SystemClockMeasurement { @@ -899,6 +905,7 @@ mod test { time: Instant::new(0), clock_error_bound: Duration::new(0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), }, ClockSnapshot { system_clock: SystemClockMeasurement { @@ -925,6 +932,7 @@ mod test { time: Instant::new(0), clock_error_bound: Duration::new(0), period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), }, ClockSnapshot { system_clock: SystemClockMeasurement { diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index 4bc9c31..627e3c1 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -5,10 +5,7 @@ use tracing::info; use crate::{ daemon::{ clock_parameters::ClockParameters, - time::{ - Clock, Duration, Instant, clocks::MonotonicCoarse, inner::ClockOffsetAndRtt, - instant::Utc, tsc::Skew, - }, + time::{Duration, Instant, TscCount, tsc::Period, tsc::Skew}, }, shm::{ ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ClockStatus, @@ -52,25 +49,35 @@ pub trait ClockStateWrite: Send + Sync { &mut self, clock_parameters: &ClockParameters, clock_status: ClockStatus, - clock_realtime_offset_and_rtt: ClockOffsetAndRtt, ); fn handle_disruption(&mut self, clock_parameters: &ClockParameters, new_disruption_marker: u64); } impl ClockStateWrite for ClockStateWriter { - /// Initializes the `ClockErrorBoundV2` segment on daemon startup. Since `ClockBound` manages adjusting - /// kernel system clock and the client currently depends on those clocks for the `ClockErrorBoundV2` SHM segment - /// version, before we perform any adjustments, in case the SHM was already written to previously, - /// we initialize and write the SHM with `ClockStatus::Unknown` BEFORE any adjustments are done. - /// This is to ensure we don't end up breaking the underlying clock for the client. - /// This does not apply to `ClockErrorBoundV3`, which does not depend on a kernel exposed system clock. + /// Initialize the `ClockErrorBoundV2` segment on daemon startup. + /// + /// ClockBound 2.0 clients rely on the OS system clock to 1) create a timestamp, and 2) grow + /// the value of the CEB since it was last computed. + /// + /// Upon restart, we may be stepping the OS clock. Consequently, we cannot let clients use + /// data from an existing SHM segment, as this would break all guarantees we must provide. + /// To be safe, we initialize and write the SHM segment with `ClockStatus::Unknown` BEFORE any + /// adjustments are done. This will lead to a short period of time during which clients will + /// see the clock not being useable. We have a fairly aggressive burst mode on start to + /// minimize this interruption. + /// This is a minor change in behavior compared to how ClockBound 2.0 manages the SHM segment + /// upon restart. fn initialize_ceb_v2_shm(&mut self) { info!("Initializing SHM segment to status `ClockStatus::Unknown` and zeroing other fields"); - let as_of = MonotonicCoarse.get_time(); - // FIXME: the behavior when writing a V2 layout to shm0 is to only overwrite the clock - // status only. Comment on line below and code to be fixed. - // We're writing that we're disrupted anyways, so the `bound_nsec` value should be useless here, 0 is ok - self.write_shm0(as_of, 0, ClockStatus::Unknown, self.max_drift_ppb); + let clock_parameters = ClockParameters { + tsc_count: TscCount::new(0), + time: Instant::new(0), + clock_error_bound: Duration::new(0), + period: Period::from_seconds(0.0), + period_max_error: Period::from_seconds(0.0), + as_of_monotonic: Instant::new(0), + }; + self.write_shm0(&clock_parameters, ClockStatus::Unknown); } /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. @@ -81,38 +88,11 @@ impl ClockStateWrite for ClockStateWriter { &mut self, clock_parameters: &ClockParameters, clock_status: ClockStatus, - clock_realtime_offset_and_rtt: ClockOffsetAndRtt, ) { - // Write to the shared memory segment shm1 first... + // Write to the shared memory segment shm1 first...and then write to the legacy shared + // memory segment shm0, of which the client relies self.write_shm1(clock_parameters, clock_status); - - // .. and then write to the legacy shared memory segment shm0, of which the client relies - // on the system clock.. - let bound = get_bound(clock_parameters, clock_realtime_offset_and_rtt); - // Unwrap safety: sane error bound should be less than `i64::MAX` - let bound_nsec = i64::try_from(bound.as_nanos()).unwrap(); - // TODO: we should normally grab the `as_of` timestamp from the `ClockParameters` themselves, - // e.g. use the `time` at which they were valid. - // However, the client implementation today reads from `CLOCK_MONOTONIC_COARSE`. The `ClockParameters` - // readings are not necessarily going to be monotonic nor aligned with `CLOCK_MONOTONIC_COARSE`. - // If we write an `as_of` that is ahead of `CLOCK_MONOTONIC_COARSE`, we could see `ShmError::CausalityBreach` for client. - // For the sake of backwards compatibility, we will have our initial/alpha release continue to work - // using `CLOCK_MONOTONIC_COARSE`. - let as_of = MonotonicCoarse.get_time(); - - // FIXME: revisit whether we want to add this to the max_drift_ppb, since that is not - // exactly the previous behavior. If not, this would simplify this block, avoiding to have - // the writer logic implement something that should be in the ff-sync one. - let software_skew = - Skew::from_period_and_error(clock_parameters.period, clock_parameters.period_max_error); - let Some(software_skew_ppb) = software_skew.to_ppb() else { - tracing::error!( - "Software skew is too large to be expressed as a ppb, skipping writing to SHM" - ); - return; - }; - let max_drift_ppb = self.max_drift_ppb + software_skew_ppb; - self.write_shm0(as_of, bound_nsec, clock_status, max_drift_ppb); + self.write_shm0(clock_parameters, clock_status); } /// Handle a clock disruption event @@ -137,19 +117,9 @@ impl ClockStateWrite for ClockStateWriter { } = self; *disruption_marker = new_disruption_marker; - // Write shm1 + // Write to the latest and greatest SHM segment first, older one(s) afterwards. self.write_shm1(clock_parameters, ClockStatus::Disrupted); - - // Write to shm0 - let as_of = MonotonicCoarse.get_time(); - info!( - "Writing `ClockStatus::Disrupted` to SHM with 0 `bound_nsec` and `ClockStatus::Disrupted`" - ); - - // FIXME: the behavior when writing a V2 layout to shm0 is to only overwrite the clock - // status only. Comment on line below and code to be fixed. - // We're writing that we're disrupted anyways, so the `bound_nsec` value should be useless here, 0 is ok - self.write_shm0(as_of, 0, ClockStatus::Disrupted, 0); + self.write_shm0(clock_parameters, ClockStatus::Disrupted); tracing::info!("Handled clock disruption event"); } @@ -177,35 +147,44 @@ impl ClockStateWriter { /// Write out to the ClockBound daemon shared memory segment shm0. /// /// Writes the latest supported layout version (V2) of the shared memory segment. - fn write_shm0( - &mut self, - as_of: Instant, - bound_nsec: i64, - clock_status: ClockStatus, - max_drift_ppb: u32, - ) { + fn write_shm0(&mut self, clock_parameters: &ClockParameters, clock_status: ClockStatus) { + // XXX: here as_of is the CLOCK_MONOTONIC_COARSE timestamp taken *before* the clock + // parameters were computed. + // + // Unwrap safety: unlikely to fail for any value for the distant future, + // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds + let as_of = TimeSpec::try_from(clock_parameters.as_of_monotonic).unwrap(); + let void_after = as_of + TimeSpec::new(1000, 0); + + let bound_nsec = i64::try_from(clock_parameters.clock_error_bound.as_nanos()).unwrap(); + + // The shared memory segment layout V2 (for ClockBound 2.0 clients) does not have a field + // to distinguish between the growth of the CEB due to the hardware worse case, from the + // growth due to the fact our period estimate is ... an estimate, and carry some residual + // skew. Consequently, we squash the two notions into one, so that the clients can grow the + // CEB correctly, accounting for the duration between `as_of` and the instant they read the + // clock. + let software_skew = + Skew::from_period_and_error(clock_parameters.period, clock_parameters.period_max_error); + let Some(software_skew_ppb) = software_skew.to_ppb() else { + tracing::error!( + "Software skew is too large to be expressed as a ppb, skipping writing to SHM" + ); + return; + }; + let max_drift_ppb = self.max_drift_ppb + software_skew_ppb; + + // Build the ClockErrorBound::V2 layout and write it out. let ceb = ClockErrorBoundGeneric::builder() - .as_of( - // Unwrap safety: unlikely to fail for any value for the distant future, - // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds - TimeSpec::try_from(as_of).unwrap(), - ) - .void_after( - // Unwrap safety: unlikely to fail for any value for the distant future, - // `i128` -> `i64` conversion would fail at 9_223_372_036_854_775_807 seconds - TimeSpec::try_from(as_of + Duration::from_secs(1000)).unwrap(), - ) + .as_of(as_of) + .void_after(void_after) .bound_nsec(bound_nsec) .disruption_marker(self.disruption_marker) - .max_drift_ppb( - // TODO: It may be worthwhile to add to this max drift ppb base the following - // components: - // - any slew rate for phase correction, since kernel clocks are used on client side - max_drift_ppb, - ) + .max_drift_ppb(max_drift_ppb) .clock_status(clock_status) .clock_disruption_support_enabled(self.clock_disruption_support_enabled) .build(ClockErrorBoundLayoutVersion::V2); + self.shm_writer_0.write(&ceb); } @@ -238,24 +217,6 @@ impl ClockStateWriter { } } -/// Calculate the `ClockErrorBound` `bound_nsec` value. This is used to calculate -/// the `earliest` and `latest` readings from a `now` call. -/// -/// # Arguments -/// * `clock_parameters` - Clock Parameters of best clock chosen by `ClockBound` -/// * `clock_realtime_offset_and_rtt` - offset and round trip time of a measurement of offset between `ClockBound` clock -/// and `CLOCK_REALTIME` -fn get_bound( - clock_parameters: &ClockParameters, - clock_realtime_offset_and_rtt: ClockOffsetAndRtt, -) -> Duration { - let realtime_to_clockbound_measured_offset = clock_realtime_offset_and_rtt.offset(); - let measurement_rtt = clock_realtime_offset_and_rtt.rtt(); - let bound_between_realtime_and_clockbound = - realtime_to_clockbound_measured_offset.abs() + measurement_rtt / 2; - clock_parameters.clock_error_bound + bound_between_realtime_and_clockbound -} - #[cfg(test)] mod tests { use super::*; @@ -283,29 +244,18 @@ mod tests { clock_error_bound: Duration::from_nanos(clock_error_bound_nanos), period: Period::from_seconds(1e-9), period_max_error: Period::from_seconds(1e-11), + as_of_monotonic: Instant::from_nanos(time_nanos), } } - /// Helper function to create a test ClockOffsetAndRtt - #[bon::builder] - fn create_test_clock_offset_and_rtt( - offset_nanos: i128, - rtt_nanos: i128, - ) -> ClockOffsetAndRtt { - ClockOffsetAndRtt::new( - Duration::from_nanos(offset_nanos), - Duration::from_nanos(rtt_nanos), - ) - } - #[rstest] #[case::synchronized(ClockStatus::Synchronized)] #[case::unknown(ClockStatus::Unknown)] #[case::free_running(ClockStatus::FreeRunning)] #[case::disrupted(ClockStatus::Disrupted)] fn test_write_shm(#[case] clock_status: ClockStatus) { - let as_of = MonotonicCoarse.get_time(); - let bound_nsec = 1234; + let as_of = Instant::from_nanos(3_000_000_000); + let bound_nsec = 500; let max_drift_ppb = 15_000; let disruption_marker = 345; let clock_disruption_support_enabled = true; @@ -314,7 +264,7 @@ mod tests { .void_after(TimeSpec::try_from(as_of + Duration::from_secs(1000)).unwrap()) .bound_nsec(bound_nsec) .disruption_marker(disruption_marker) - .max_drift_ppb(max_drift_ppb) + .max_drift_ppb(max_drift_ppb + 10000000) // Clock params have 1% period error, in PPB .clock_status(clock_status) .clock_disruption_support_enabled(clock_disruption_support_enabled) .build(ClockErrorBoundLayoutVersion::V2); @@ -333,11 +283,8 @@ mod tests { let expected_ceb_v3 = ClockErrorBoundGeneric::builder() .as_of_tsc(1_000_000) - .as_of(TimeSpec::try_from(Instant::from_nanos(3_000_000_000)).unwrap()) - .void_after( - TimeSpec::try_from(Instant::from_nanos(3_000_000_000) + Duration::from_secs(1000)) - .unwrap(), - ) + .as_of(TimeSpec::try_from(as_of).unwrap()) + .void_after(TimeSpec::try_from(as_of + Duration::from_secs(1000)).unwrap()) .period(1e-9) .period_err(1e-11) .bound_nsec(500) @@ -360,7 +307,7 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer.write_shm0(as_of, bound_nsec, clock_status, max_drift_ppb); + clock_state_writer.write_shm0(&clock_parameters, clock_status); clock_state_writer.write_shm1(&clock_parameters, clock_status); } @@ -371,10 +318,6 @@ mod tests { .tsc_count(1000) .time_nanos(123_000) .call(); - let clock_realtime_offset_and_rtt = create_test_clock_offset_and_rtt() - .offset_nanos(1000) - .rtt_nanos(500) - .call(); let clock_disruption_support_enabled = false; let max_drift_ppb = 0; let disruption_marker = 0; @@ -386,7 +329,7 @@ mod tests { .withf(move |ceb: &ClockErrorBound| { ceb.void_after() == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) - && ceb.bound_nsec() == 2250 + && ceb.bound_nsec() == 1000 && ceb.disruption_marker() == disruption_marker && ceb.clock_status() == clock_status && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled @@ -415,11 +358,7 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer.handle_clock_parameters( - &clock_parameters, - clock_status, - clock_realtime_offset_and_rtt, - ); + clock_state_writer.handle_clock_parameters(&clock_parameters, clock_status); } #[test] @@ -430,10 +369,6 @@ mod tests { .tsc_count(1000) .time_nanos(1_000_000_000) .call(); - let clock_realtime_offset_and_rtt = create_test_clock_offset_and_rtt() - .offset_nanos(1000) - .rtt_nanos(500) - .call(); let clock_disruption_support_enabled = false; let max_drift_ppb = 0; let disruption_marker = 0; @@ -448,51 +383,13 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer.handle_clock_parameters( - &clock_parameters, - ClockStatus::Synchronized, - clock_realtime_offset_and_rtt, - ); - } - - #[rstest] - #[case( - create_test_clock_parameters() - .clock_error_bound_nanos(0) - .tsc_count(1000) - .time_nanos(1_000_000_000) - .call(), - create_test_clock_offset_and_rtt() - .offset_nanos(0) - .rtt_nanos(0) - .call(), - Duration::from_nanos(0), - )] - #[case( - create_test_clock_parameters() - .clock_error_bound_nanos(1000) - .tsc_count(2000) - .time_nanos(1_000_000_000) - .call(), - create_test_clock_offset_and_rtt() - .offset_nanos(1000) - .rtt_nanos(500) - .call(), - Duration::from_nanos(2250), - )] - fn test_get_bound( - #[case] clock_parameters: ClockParameters, - #[case] clock_realtime_offset_and_rtt: ClockOffsetAndRtt, - #[case] expected: Duration, - ) { - let bound_nsec = get_bound(&clock_parameters, clock_realtime_offset_and_rtt); - assert_eq!(bound_nsec, expected); + clock_state_writer.handle_clock_parameters(&clock_parameters, ClockStatus::Synchronized); } #[test] - fn initialize_ceb_v2_shm() { + fn test_initialize_ceb_v2_shm() { let clock_disruption_support_enabled = false; - let expected_max_drift_ppb = 0; + let expected_max_drift_ppb = 15_000; let expected_disruption_marker = 0; let mut shm_writer_0 = MockShmWriter::new(); shm_writer_0 @@ -542,9 +439,9 @@ mod tests { .withf(move |ceb: &ClockErrorBound| { ceb.void_after() == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) - && ceb.bound_nsec() == 0 + && ceb.bound_nsec() == 1000 && ceb.disruption_marker() == final_disruption_marker - && ceb.max_drift_ppb() == max_drift_ppb + && ceb.max_drift_ppb() == 10000000 // Clock params have 1% period error, in PPB && ceb.clock_status() == ClockStatus::Disrupted && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled }) diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs index 1e41479..b2e9885 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -10,7 +10,8 @@ use crate::daemon::{ clock_sync_algorithm::ring_buffer::Quarter, event::{self, TscRtt}, time::{ - Duration, TscDiff, + Clock, Duration, Instant, TscDiff, + clocks::MonotonicCoarse, tsc::{Period, Skew}, }, }; @@ -66,6 +67,32 @@ impl Ntp { /// /// Returns [`Some`] if the event has improved this source's [`ClockParameters`]. pub fn feed(&mut self, event: event::Ntp) -> Option<&ClockParameters> { + // FIXME: take a MONOTONIC_COARSE timestamp *before* computing the clock error bound. + // + // The only use of this timestamp is to support and maintain the behavior for clients built + // against ClockBound 2.0. These clients grow the CEB by calculating the time elapsed + // between the instant the ClockParameters were computed, and the instant they read the + // system clock. This needs to be a bit pessimistic, and the `as_of_monotonic` timestamp + // should be taken *before* the time at which the CEB is calculated. + // + // Here this should be *before* the TSC post read of event fed to the algorithm. That would + // require carrying this `as_of_monotonic` timestamp from the IO components. Instead, we + // are taking a short cut and placing this timestamp slightly in the past, by 10 + // milliseconds to account for possible events where the daemon is scheduled out. + // + // The CEB is made worse by around 150 nanoseconds (assuming a 15PPM oscillator drift), + // which is negligible for ClockBound 2.0 clients. Moving this timestamp in the past may + // also help reduce the risk of causality breach errors seen when using the + // CLOCK_MONOTONIC_COARSE clock. + // + // This will be eliminated once we decide to stop supporting ClockBound 2.0 clients. + let as_of_monotonic = MonotonicCoarse.get_time(); + let as_of_monotonic = if as_of_monotonic > Instant::from_millis(10) { + as_of_monotonic - Duration::from_millis(10) + } else { + Instant::from_millis(0) + }; + let tsc_midpoint = event.tsc_midpoint(); // First update the internal local (current SKM) and estimate (long term) @@ -134,6 +161,7 @@ impl Ntp { clock_error_bound, period: local_period.period_local, period_max_error: local_period.error, + as_of_monotonic, }; match &mut self.clock_parameters { diff --git a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs index f72368a..fd46302 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs @@ -10,7 +10,8 @@ use crate::daemon::{ clock_sync_algorithm::ring_buffer::Quarter, event::{self, TscRtt}, time::{ - Duration, TscDiff, + Clock, Duration, TscDiff, + clocks::MonotonicCoarse, tsc::{Period, Skew}, }, }; @@ -79,6 +80,17 @@ impl Phc { /// /// Returns [`Some`] if the event has improved this source's [`ClockParameters`]. pub fn feed(&mut self, event: event::Phc) -> Option<&ClockParameters> { + // FIXME: take a MONOTONIC_COARSE timestamp *before* computing the clock error bound. + // + // The only use of this timestamp is to support and maintain the behavior for clients built + // against ClockBound 2.0. These clients grow the CEB by calculating the time elapsed + // between the instant the ClockParameters were computed, and the instant they read the + // system clock. This needs to be a bit pessimistic, hence the as_of_monotonic timestamp is + // taken *before* computing the clock error bound. + // + // This could be eliminated once we decide to stop supporting ClockBound 2.0 clients. + let as_of_monotonic = MonotonicCoarse.get_time(); + let tsc_midpoint = event.tsc_midpoint(); // First update the internal local (current SKM) and estimate (long term) @@ -147,6 +159,7 @@ impl Phc { clock_error_bound, period: local_period.period_local, period_max_error: local_period.error, + as_of_monotonic, }; match &mut self.clock_parameters { diff --git a/clock-bound/src/daemon/clock_sync_algorithm/selector.rs b/clock-bound/src/daemon/clock_sync_algorithm/selector.rs index 4236e10..879421b 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/selector.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/selector.rs @@ -108,6 +108,7 @@ mod tests { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1), // unused }, // Second event (identical) event::Ntp::builder() @@ -134,6 +135,7 @@ mod tests { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1), // unused }, // Second event with worse RTT event::Ntp::builder() @@ -160,6 +162,7 @@ mod tests { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1), // unused }, // Second event (newer, 1 second later) event::Ntp::builder() @@ -186,6 +189,7 @@ mod tests { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1), // unused }, // Second event event::Ntp::builder() @@ -212,6 +216,7 @@ mod tests { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1), // unused }, // Second event event::Ntp::builder() @@ -246,6 +251,7 @@ mod tests { clock_error_bound: second.calculate_clock_error_bound(period), period, period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1), // unused }; let mut selector = Selector { current: Some(SourceParams { @@ -266,6 +272,7 @@ mod tests { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1), // unused }; let mut selector = Selector::new(Skew::from_ppm(0.0)); assert!(selector.current().is_none()); @@ -282,6 +289,7 @@ mod tests { clock_error_bound: Duration::from_nanos(10_500), period: Period::from_seconds(1e-9), // unused period_max_error: Period::from_seconds(1e-11), // unused + as_of_monotonic: Instant::from_days(1), // unused }; let skew = Skew::from_ppm(1.0); let mut selector = Selector::new(skew); diff --git a/clock-bound/src/daemon/time/clocks.rs b/clock-bound/src/daemon/time/clocks.rs index 3597beb..91f7834 100644 --- a/clock-bound/src/daemon/time/clocks.rs +++ b/clock-bound/src/daemon/time/clocks.rs @@ -162,12 +162,14 @@ mod tests { let time = initial_time; let clock_error_bound = Duration::new(0); let period_max_error = Period::from_seconds(0.0); + let as_of_monotonic = Instant::from_secs(2); let clock_parameters = ClockParameters { tsc_count, time, clock_error_bound, period, period_max_error, + as_of_monotonic, }; let clockbound_clock = ClockBound::new(clock_parameters, mock_read_tsc); assert_eq!(clockbound_clock.get_time(), expected_time); diff --git a/clock-bound/src/daemon/time/inner.rs b/clock-bound/src/daemon/time/inner.rs index b92d0c8..446e81b 100644 --- a/clock-bound/src/daemon/time/inner.rs +++ b/clock-bound/src/daemon/time/inner.rs @@ -83,7 +83,7 @@ impl> ClockExt for C {} /// /// This type is not usually used directly, but rather through the [`Instant`](super::Instant) and [`Tsc`](super::TscCount) types. #[derive( - Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, + Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, )] #[serde(transparent)] #[repr(transparent)] diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs index f2aa2d0..9cb6166 100644 --- a/clock-bound/src/daemon/time/tsc.rs +++ b/clock-bound/src/daemon/time/tsc.rs @@ -313,6 +313,9 @@ impl Skew { /// Equivalent to /// `Skew = error (in seconds) / period (in seconds` pub fn from_period_and_error(period: Period, error: Period) -> Self { + if period.get() == 0.0 { + return Self(0.0); + } let skew = error.get() / period.get(); Self(skew) } From 6094e3d7e3ba52474a218d0f39ec7c529f40caf3 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Thu, 20 Nov 2025 15:01:58 -0500 Subject: [PATCH 150/177] Add error between `ClockBound` clock and kernel system clock (#171) SHM0 relies on kernel clock as the reference for its `now` timestamping (the midpoint of earliest and latest), so if the kernel system clock is erroneous w.r.t ClockBound clock, we should include that in our CEB --- .../daemon/clock_state/clock_state_writer.rs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index 627e3c1..7d2d3aa 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -5,7 +5,12 @@ use tracing::info; use crate::{ daemon::{ clock_parameters::ClockParameters, - time::{Duration, Instant, TscCount, tsc::Period, tsc::Skew}, + io::tsc::ReadTscImpl, + time::{ + ClockExt, Duration, Instant, TscCount, + clocks::{ClockBound, RealTime}, + tsc::{Period, Skew}, + }, }, shm::{ ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ClockStatus, @@ -92,7 +97,21 @@ impl ClockStateWrite for ClockStateWriter { // Write to the shared memory segment shm1 first...and then write to the legacy shared // memory segment shm0, of which the client relies self.write_shm1(clock_parameters, clock_status); - self.write_shm0(clock_parameters, clock_status); + + // The kernel system clock is used on `ClockErrorBoundV2` client reads, so the error between that and + // the `ClockBound` clock calculated from `ClockParameters` needs to be added in, thus we measure it here + let clockbound_clock = ClockBound::new(clock_parameters.clone(), ReadTscImpl); + let offset_and_rtt = clockbound_clock.get_offset_and_rtt(&RealTime); + + let clock_error_bound = clock_parameters.clock_error_bound + + offset_and_rtt.offset().abs() + + (offset_and_rtt.rtt() / 2); + + let shm0_clock_parameters = ClockParameters { + clock_error_bound, + ..clock_parameters.clone() + }; + self.write_shm0(&shm0_clock_parameters, clock_status); } /// Handle a clock disruption event @@ -329,7 +348,6 @@ mod tests { .withf(move |ceb: &ClockErrorBound| { ceb.void_after() == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) - && ceb.bound_nsec() == 1000 && ceb.disruption_marker() == disruption_marker && ceb.clock_status() == clock_status && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled @@ -439,7 +457,6 @@ mod tests { .withf(move |ceb: &ClockErrorBound| { ceb.void_after() == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) - && ceb.bound_nsec() == 1000 && ceb.disruption_marker() == final_disruption_marker && ceb.max_drift_ppb() == 10000000 // Clock params have 1% period error, in PPB && ceb.clock_status() == ClockStatus::Disrupted From bc10f52b5d5657ee1d573020faf6fabb9ab4f5cb Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Thu, 20 Nov 2025 15:39:27 -0500 Subject: [PATCH 151/177] [configure_phc] Remove extra check for PTP 1588 (#175) We are effectively checking for the driver version with the check for /sys/module/ena/parameters/phc_enable --- clock-bound/assets/configure_phc | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/clock-bound/assets/configure_phc b/clock-bound/assets/configure_phc index 23a303b..12c372b 100644 --- a/clock-bound/assets/configure_phc +++ b/clock-bound/assets/configure_phc @@ -67,9 +67,11 @@ fi # First check if the driver has been enabled with phc_enable param_file="/sys/module/ena/parameters/phc_enable" if [[ ! -f "$param_file" ]]; then - echo "ERROR: ENA driver parameter file not found at $param_file" - exit 1 + echo "ENA driver parameter file not found at $param_file" + echo "phc_enable not supported by this version of the driver. Exiting." + exit 0 fi + phc_value=$(cat "$param_file") if [[ "$phc_value" == "1" ]]; then echo "PHC is already enabled for the ENA driver (phc_enable=1). Exiting." @@ -78,15 +80,6 @@ else echo "PHC is not enabled for the ENA driver (phc_enable=0). Continuing." fi -# Check if the kernel supports this option and bail out if not -# NOTE: Not doing this check for "-c", because there is no immediate impact. -echo -n "Checking if ENA driver supports PTP 1588 clock..." -grep -w '^CONFIG_PTP_1588_CLOCK=[ym]' /boot/config-`uname -r` || { - echo "ENA driver does not support PTP 1588 clock. Exiting." - exit 0 -} -echo "Success" - # Write the ena config enable_phc || exit 1 From bd2191d5448db22965eb593a1f89940bed31b55d Mon Sep 17 00:00:00 2001 From: tphan25 Date: Thu, 20 Nov 2025 16:13:55 -0500 Subject: [PATCH 152/177] Implement FreeRunning transition and separate clockstatus for v2/v3 (#168) * Implement FreeRunning transition and separate clockstatus for v2/v3 V2 and V3 will have different clock statuses, V2 is more annoying to deal with because of the system clock dependency. handle_clock_parameters uses a single param for both right now, which means even when we write certain clock statuses specific to V2 to the SHM, they'd also be written for the SHM for V3. An example of this is when we consider different clock statuses from `get_clock_realtime_status` - on initialization, clock status will be unknown for a lot of the underlying clockadjust activities, but there's no need for this to happen for V3. To fix this, separating the calls entirely between V2 and V3 * Add/update unit tests and method signatures for cargo clippy Adding unit tests for the determination of clock states. Also changing `determine_clock_error_bound_v3_status` to simply pass in an age and determine based on that - no need for a reference to self nor the clock parameters if we can determine the age in the caller, and simplifies the unit test there --------- Co-authored-by: Tom Phan --- clock-bound/src/daemon/clock_state.rs | 190 +++++++++++++++--- .../daemon/clock_state/clock_state_writer.rs | 85 +++++--- 2 files changed, 227 insertions(+), 48 deletions(-) diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index b0e9718..9e6e7fb 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -18,13 +18,15 @@ use crate::daemon::clock_state::clock_state_writer::ClockStateWriter; use crate::daemon::clock_state::clock_state_writer::{ClockStateWrite, SafeShmWriter}; use crate::daemon::io::tsc::ReadTscImpl; use crate::daemon::subscriber::CLOCK_METRICS_TARGET; -use crate::daemon::time::ClockExt; -use crate::daemon::time::clocks::{ClockBound, MonotonicRaw, RealTime}; +use crate::daemon::time::clocks::{ClockBound, MonotonicCoarse, MonotonicRaw, RealTime}; +use crate::daemon::time::{Clock, ClockExt, Duration}; use crate::shm::{ CLOCKBOUND_SHM_DEFAULT_PATH_V0, CLOCKBOUND_SHM_DEFAULT_PATH_V1, ClockErrorBoundLayoutVersion, - ShmWriter, + ClockStatus, ShmWriter, }; +const FREE_RUNNING_GRACE_PERIOD: Duration = Duration::from_secs(60); + /// The whole `ClockState` component struct. /// This encompasses both `ClockAdjust` component which interfaces /// with the `CLOCK_REALTIME` kernel clock to synchronize it with `ClockBound` estimate @@ -162,21 +164,55 @@ impl ClockState { } } + /// Determines the `ClockStatus` to write to the SHM for `ClockErrorBoundV2`, which depends on the system + /// clock underneath the hood. + fn determine_clock_error_bound_v2_status(&self, parameters: &ClockParameters) -> ClockStatus { + let mut clock_status = self.clock_adjuster.get_clock_realtime_status(); + // Check if the clock parameters have been stale for an extended time, if so then update to status `ClockStatus::FreeRunning` + let time_since_parameters_updated = MonotonicCoarse.get_time() - parameters.as_of_monotonic; + if clock_status == ClockStatus::Synchronized + && time_since_parameters_updated >= FREE_RUNNING_GRACE_PERIOD + { + clock_status = ClockStatus::FreeRunning; + } + clock_status + } + + /// Determines the `ClockStatus` to write to the SHM for `ClockErrorBoundV3`. + /// The `ClockErrorBoundV3` does not directly rely on the kernel system clock. + /// After a disruption, `ClockState` should have no `ClockParameters`, so we don't expect + /// to have to return `ClockState::Disrupted` at all. Similar reasoning also applies for `ClockState::Unknown` + /// not being covered here. + /// + /// If we have received any `ClockParameters` in the caller, we have some notion of a clock, and can say we're `Synchronized`, or + /// if the `ClockParameters` are stale for `FREE_RUNNING_GRACE_PERIOD`, we may declare ourselves `FreeRunning`. + fn determine_clock_error_bound_v3_status(clock_params_age: Duration) -> ClockStatus { + if clock_params_age < FREE_RUNNING_GRACE_PERIOD { + ClockStatus::Synchronized + } else { + ClockStatus::FreeRunning + } + } + fn handle_tick(&mut self, now: tokio::time::Instant) { if let Some(parameters) = &self.clock_parameters { self.clock_adjuster.handle_clock_parameters(now, parameters); - // FIXME: this is likely not right. - // The underlying assumption is that only the shm0 file was written to. But we now have - // two writers, and the status of the clock written to shm1 should NOT depend on - // whatever the system clock is doing. - let clock_status = self.clock_adjuster.get_clock_realtime_status(); + // Handle SHM1 (`ClockErrorboundV3`) first + let clockbound_clock = ClockBound::new(parameters.clone(), ReadTscImpl); + // Get the age of the `ClockParameters`, to determine if we're Synchronized or FreeRunning. + // Inherently, we can't be `Unknown` or `Disrupted` anymore if we've gotten any `ClockParameters`. + let clock_params_age_v3 = clockbound_clock.get_time() - parameters.time; + let clock_status_v3 = + ClockState::determine_clock_error_bound_v3_status(clock_params_age_v3); + + self.state_writer + .handle_clock_parameters_shm1(parameters, clock_status_v3); - // FIXME: Initializing behavior of ClockStateWriter should have us write - // the initial clock status as unknown to SHM segment. Else, the clock might be adjusted - // by ClockAdjuster on its initial state, while the SHM isn't updated with that info.. + // Handle SHM0 (`ClockErrorboundV2`) after SHM1. + let clock_status_v2 = self.determine_clock_error_bound_v2_status(parameters); self.state_writer - .handle_clock_parameters(parameters, clock_status); + .handle_clock_parameters_shm0(parameters, clock_status_v2); } } @@ -227,6 +263,7 @@ impl ClockState { #[cfg(test)] mod tests { use mockall::predicate::eq; + use rstest::rstest; use crate::{ daemon::{ @@ -262,9 +299,8 @@ mod tests { let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer - .expect_handle_clock_parameters() - .never() - .return_const(()); + .expect_handle_clock_parameters_shm0() + .never(); let (_tx, rx) = async_ring_buffer::create(1); let mut clock_state = ClockState::new( @@ -328,9 +364,12 @@ mod tests { let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); mock_clock_state_writer - .expect_handle_clock_parameters() - .never() - .return_const(()); + .expect_handle_clock_parameters_shm0() + .never(); + + mock_clock_state_writer + .expect_handle_clock_parameters_shm1() + .never(); let (_tx, rx) = async_ring_buffer::create(1); let mut clock_state = ClockState::new( @@ -346,8 +385,14 @@ mod tests { #[tokio::test(start_paused = true)] async fn handle_tick_with_parameters() { let mut sequence = mockall::Sequence::new(); - let expected_clock_status = ClockStatus::Synchronized; - let expected_clock_params = get_sample_clock_parameters(); + let expected_clock_error_bound_v2_status = ClockStatus::Synchronized; + let expected_clock_error_bound_v3_status = ClockStatus::Synchronized; + let mut expected_clock_params = get_sample_clock_parameters(); + // If we don't overwrite `as_of_monotonic`, the default from `get_sample_clock_parameters` + // has it at `Instant::new(0)` which would cause us to declare the clock as `FreeRunning` + // since it's likely far in the past. + expected_clock_params.as_of_monotonic = MonotonicCoarse.get_time(); + let expected_instant = tokio::time::Instant::now(); let cancellation_token = CancellationToken::new(); let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); @@ -362,20 +407,32 @@ mod tests { }) .in_sequence(&mut sequence) .return_const(()); + + let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); + let expected_clock_params_clone = expected_clock_params.clone(); + mock_clock_state_writer + .expect_handle_clock_parameters_shm1() + .once() + .withf(move |actual_clock_params, actual_clock_status| { + *actual_clock_params == expected_clock_params_clone + && *actual_clock_status == expected_clock_error_bound_v3_status + }) + .in_sequence(&mut sequence) + .return_const(()); mock_clock_adjuster .expect_get_clock_realtime_status() .once() .in_sequence(&mut sequence) - .return_const(expected_clock_status); - - let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); + // Return synchronized state from `ClockAdjuster` so that + // we may test aging the clock params to get `ClockStatus::FreeRunning` + .return_const(ClockStatus::Synchronized); let expected_clock_params_clone = expected_clock_params.clone(); mock_clock_state_writer - .expect_handle_clock_parameters() + .expect_handle_clock_parameters_shm0() .once() .withf(move |actual_clock_params, actual_clock_status| { *actual_clock_params == expected_clock_params_clone - && *actual_clock_status == expected_clock_status + && *actual_clock_status == expected_clock_error_bound_v2_status }) .in_sequence(&mut sequence) .return_const(()); @@ -390,4 +447,87 @@ mod tests { clock_state.clock_parameters = Some(expected_clock_params); clock_state.handle_tick(expected_instant); } + + #[rstest] + #[case::synchronized_stays_synchronized_params_0sec_old( + Duration::from_secs(0), + ClockStatus::Synchronized, + ClockStatus::Synchronized + )] + #[case::synchronized_stays_synchronized_params_30sec_old( + Duration::from_secs(30), + ClockStatus::Synchronized, + ClockStatus::Synchronized + )] + #[case::synchronized_goes_freerunning_params_60sec_old( + Duration::from_secs(60), + ClockStatus::Synchronized, + ClockStatus::FreeRunning + )] + #[case::synchronized_goes_freerunning_params_90sec_old( + Duration::from_secs(90), + ClockStatus::Synchronized, + ClockStatus::FreeRunning + )] + #[case::unknown_stays_unknown_params_0sec_old( + Duration::from_secs(0), + ClockStatus::Unknown, + ClockStatus::Unknown + )] + #[case::unknown_stays_unknown_params_90sec_old( + Duration::from_secs(90), + ClockStatus::Unknown, + ClockStatus::Unknown + )] + #[case::disrupted_stays_disrupted_params_0sec_old( + Duration::from_secs(0), + ClockStatus::Disrupted, + ClockStatus::Disrupted + )] + #[case::disrupted_stays_disrupted_params_90sec_old( + Duration::from_secs(90), + ClockStatus::Disrupted, + ClockStatus::Disrupted + )] + #[tokio::test] + async fn determine_clock_error_bound_v2_status( + #[case] clock_params_age: Duration, + #[case] clock_adjust_status: ClockStatus, + #[case] expected_clock_error_bound_v2_status: ClockStatus, + ) { + let cancellation_token = CancellationToken::new(); + let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); + mock_clock_adjuster + .expect_get_clock_realtime_status() + .once() + .return_const(clock_adjust_status); + let clock_state = ClockState::new( + Box::new(MockClockStateWrite::new()), + Box::new(mock_clock_adjuster), + async_ring_buffer::create(1).1, + cancellation_token, + ); + + let mut clock_parameters = get_sample_clock_parameters(); + // Adjust the time reported on clock params to be `clock_params_age` old + // For v2, we age clock params based on `CLOCK_MONOTONIC_COARSE`, so use that here + clock_parameters.as_of_monotonic = MonotonicCoarse.get_time() - clock_params_age; + let res = clock_state.determine_clock_error_bound_v2_status(&clock_parameters); + assert_eq!(res, expected_clock_error_bound_v2_status); + } + + #[rstest] + #[case::synchronized_params_0sec_old(Duration::from_secs(0), ClockStatus::Synchronized)] + #[case::synchronized_params_30sec_old(Duration::from_secs(30), ClockStatus::Synchronized)] + #[case::freerunning_params_60sec_old(Duration::from_secs(60), ClockStatus::FreeRunning)] + #[case::freerunning_params_90sec_old(Duration::from_secs(90), ClockStatus::FreeRunning)] + fn determine_clock_error_bound_v3_status( + #[case] clock_params_age: Duration, + #[case] expected_clock_error_bound_v3_status: ClockStatus, + ) { + assert_eq!( + ClockState::determine_clock_error_bound_v3_status(clock_params_age), + expected_clock_error_bound_v3_status + ); + } } diff --git a/clock-bound/src/daemon/clock_state/clock_state_writer.rs b/clock-bound/src/daemon/clock_state/clock_state_writer.rs index 7d2d3aa..d3b8c3f 100644 --- a/clock-bound/src/daemon/clock_state/clock_state_writer.rs +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -50,7 +50,12 @@ pub struct ClockStateWriter { #[cfg_attr(test, mockall::automock)] pub trait ClockStateWrite: Send + Sync { fn initialize_ceb_v2_shm(&mut self); - fn handle_clock_parameters( + fn handle_clock_parameters_shm1( + &mut self, + clock_parameters: &ClockParameters, + clock_status: ClockStatus, + ); + fn handle_clock_parameters_shm0( &mut self, clock_parameters: &ClockParameters, clock_status: ClockStatus, @@ -85,19 +90,24 @@ impl ClockStateWrite for ClockStateWriter { self.write_shm0(&clock_parameters, ClockStatus::Unknown); } - /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector. + /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector for SHM1 (`ClockErrorBoundV3`) + fn handle_clock_parameters_shm1( + &mut self, + clock_parameters: &ClockParameters, + clock_status: ClockStatus, + ) { + self.write_shm1(clock_parameters, clock_status); + } + + /// Handles `ClockParameters` passed out from the `ClockSyncAlgorithm` selector for SHM0 (`ClockErrorBoundV2`) /// /// # Panics /// Panics if error bound calculated exceeds `i64::MAX` - fn handle_clock_parameters( + fn handle_clock_parameters_shm0( &mut self, clock_parameters: &ClockParameters, clock_status: ClockStatus, ) { - // Write to the shared memory segment shm1 first...and then write to the legacy shared - // memory segment shm0, of which the client relies - self.write_shm1(clock_parameters, clock_status); - // The kernel system clock is used on `ClockErrorBoundV2` client reads, so the error between that and // the `ClockBound` clock calculated from `ClockParameters` needs to be added in, thus we measure it here let clockbound_clock = ClockBound::new(clock_parameters.clone(), ReadTscImpl); @@ -331,7 +341,7 @@ mod tests { } #[test] - fn test_handle_clock_parameters() { + fn handle_clock_parameters_shm0() { let clock_parameters = create_test_clock_parameters() .clock_error_bound_nanos(1000) .tsc_count(1000) @@ -356,18 +366,7 @@ mod tests { .return_const(()); let mut shm_writer_1 = MockShmWriter::new(); - shm_writer_1 - .expect_write() - .withf(move |ceb: &ClockErrorBound| { - ceb.void_after() - == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) - && ceb.bound_nsec() == 1000 - && ceb.disruption_marker() == disruption_marker - && ceb.clock_status() == clock_status - && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled - }) - .times(1) - .return_const(()); + shm_writer_1.expect_write().never(); let mut clock_state_writer = ClockStateWriter::builder() .clock_disruption_support_enabled(clock_disruption_support_enabled) @@ -376,12 +375,12 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer.handle_clock_parameters(&clock_parameters, clock_status); + clock_state_writer.handle_clock_parameters_shm0(&clock_parameters, clock_status); } #[test] #[should_panic] - fn test_handle_clock_parameters_panic_on_overflow_error_bound() { + fn handle_clock_parameters_shm0_panic_on_overflow_error_bound() { let clock_parameters = create_test_clock_parameters() .clock_error_bound_nanos(i64::MAX as i128 + 1) // overflowing CEB .tsc_count(1000) @@ -401,7 +400,47 @@ mod tests { .max_drift_ppb(max_drift_ppb) .disruption_marker(disruption_marker) .build(); - clock_state_writer.handle_clock_parameters(&clock_parameters, ClockStatus::Synchronized); + clock_state_writer + .handle_clock_parameters_shm0(&clock_parameters, ClockStatus::Synchronized); + } + + #[test] + fn handle_clock_parameters_shm1() { + let clock_parameters = create_test_clock_parameters() + .clock_error_bound_nanos(1000) + .tsc_count(1000) + .time_nanos(123_000) + .call(); + let clock_disruption_support_enabled = false; + let max_drift_ppb = 0; + let disruption_marker = 0; + let clock_status = ClockStatus::Synchronized; + + let mut shm_writer_0 = MockShmWriter::new(); + shm_writer_0.expect_write().never(); + + let mut shm_writer_1 = MockShmWriter::new(); + shm_writer_1 + .expect_write() + .withf(move |ceb: &ClockErrorBound| { + ceb.void_after() + == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(1000)) + && ceb.bound_nsec() == 1000 + && ceb.disruption_marker() == disruption_marker + && ceb.clock_status() == clock_status + && ceb.clock_disruption_support_enabled() == clock_disruption_support_enabled + }) + .times(1) + .return_const(()); + + let mut clock_state_writer = ClockStateWriter::builder() + .clock_disruption_support_enabled(clock_disruption_support_enabled) + .shm_writer_0(shm_writer_0) + .shm_writer_1(shm_writer_1) + .max_drift_ppb(max_drift_ppb) + .disruption_marker(disruption_marker) + .build(); + clock_state_writer.handle_clock_parameters_shm1(&clock_parameters, clock_status); } #[test] From 51bbdfceff3baa8518b0b6a2185619d0dad5d101 Mon Sep 17 00:00:00 2001 From: tphan25 Date: Thu, 20 Nov 2025 21:27:01 -0500 Subject: [PATCH 153/177] Set "clock_disruption_support_enabled" based on VMClock existence (#179) Flag was just being set to true. VMClock support doesn't exist on metal instances, so clients see that flag, but don't see vmclock, and fail. --- clock-bound/src/daemon.rs | 2 ++ clock-bound/src/daemon/clock_state.rs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 99c55bd..77fe975 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -103,6 +103,7 @@ impl Daemon { .vmclock() .map(|vmclock| vmclock.last_disruption_marker()) .unwrap_or_default(); + let clock_disruption_support_enabled = io_front_end.vmclock().is_some(); // Initialize PHC event buffer and IO component. let (phc_tx, phc_rx) = async_ring_buffer::create(2); @@ -150,6 +151,7 @@ impl Daemon { rx, clock_state_cancellation_token.clone(), disruption_marker, + clock_disruption_support_enabled, ); (tx, clock_state) }; diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 9e6e7fb..816545c 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -63,6 +63,7 @@ impl ClockState { clock_params_receiver: Receiver, cancellation_token: CancellationToken, disruption_marker: u64, + clock_disruption_support_enabled: bool, ) -> Self { // Build two writers, each writing to a specific shared memory segment path. // @@ -86,7 +87,7 @@ impl ClockState { let safe_shm_writer_1 = SafeShmWriter::new(shm_writer_1); let clock_state_writer: ClockStateWriter = ClockStateWriter::builder() - .clock_disruption_support_enabled(true) + .clock_disruption_support_enabled(clock_disruption_support_enabled) .shm_writer_0(safe_shm_writer_0) .shm_writer_1(safe_shm_writer_1) .max_drift_ppb(MAX_DISPERSION_GROWTH_PPB) From 28b23a4615ad67ea0a936ff0a1d2a8083a4c1f3e Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Fri, 21 Nov 2025 10:33:00 -0500 Subject: [PATCH 154/177] phc-offset-tester: improve output structure This commit includes some scrappy updates to the phc-offset test binary to improve the output json format and allow it to better handle individual clock read failures. --- Cargo.lock | 2 +- clock-bound/Cargo.toml | 4 +- test/clock-bound-client-generic/Cargo.toml | 1 + test/clock-bound-client-generic/src/lib.rs | 37 ++++-- test/clock-bound-phc-offset/Cargo.toml | 1 - test/clock-bound-phc-offset/src/main.rs | 32 ++--- test/clock-bound-phc-offset/src/phc.rs | 148 ++++++++++++++------- test/clock-bound-phc-offset/src/phc/ceb.rs | 17 ++- test/clock-bound-phc-offset/src/phc/ptp.rs | 14 +- 9 files changed, 155 insertions(+), 101 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26d3c3a..90f4a67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,6 +343,7 @@ dependencies = [ "nix", "serde", "serde_json", + "thiserror 2.0.17", "tracing", "tracing-appender", "tracing-subscriber", @@ -404,7 +405,6 @@ dependencies = [ name = "clock-bound-phc-offset" version = "2.0.3" dependencies = [ - "anyhow", "clap", "clock-bound", "clock-bound-client-generic", diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index ac8b340..5451a46 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -105,5 +105,5 @@ version = "3.0.0-alpha" assets = [ { source = "target/release/clockbound", dest = "/usr/bin/clockbound", mode = "755" }, { source = "assets/clockbound.service", dest = "/usr/lib/systemd/system/clockbound.service", mode = "644" }, - { source = "assets/configure_phc", dest = "/usr/sbin/configure_phc", mode = "755" }, -] \ No newline at end of file + { source = "assets/configure_phc", dest = "/usr/sbin/configure_phc", mode = "755" }, +] diff --git a/test/clock-bound-client-generic/Cargo.toml b/test/clock-bound-client-generic/Cargo.toml index af58170..5b45340 100644 --- a/test/clock-bound-client-generic/Cargo.toml +++ b/test/clock-bound-client-generic/Cargo.toml @@ -29,6 +29,7 @@ tracing-subscriber = { version = "0.3", features = [ "json", "registry", ] } +thiserror = "2.0" serde = "1.0" serde_json = "1.0.145" anyhow = "1.0.100" diff --git a/test/clock-bound-client-generic/src/lib.rs b/test/clock-bound-client-generic/src/lib.rs index f4273a5..14a84e4 100644 --- a/test/clock-bound-client-generic/src/lib.rs +++ b/test/clock-bound-client-generic/src/lib.rs @@ -3,7 +3,6 @@ //! This library intentionally imports the `clock_bound_client` v2.0 crate to fully simulate/validate backward compatibility //! between v2.0 clients and v3.0 daemon. -use anyhow::{Result, anyhow}; use clap::ValueEnum; use clock_bound::daemon::time::Duration; use clock_bound::daemon::time::Instant; @@ -15,6 +14,16 @@ use clock_bound_client as clock_bound_client_v2; const NSECS_PER_SEC: i64 = 1_000_000_000; +use thiserror::Error; + +#[derive(Error, Clone, Debug, serde::Serialize)] +pub enum ClockBoundClientError { + #[error("clockbound client v2 read failed.")] + V2Error, + #[error("clockbound client v3 read failed.")] + V3Error, +} + #[repr(C)] #[derive(Debug, Copy, Clone, PartialEq, serde::Serialize)] pub enum ClockStatus { @@ -110,23 +119,21 @@ impl ClockBoundClient { /// /// # Errors /// Returns an error if the clock bound time retrieval fails. - fn now(&mut self) -> Result { + fn now(&mut self) -> Result { match self { - Self::ClientV2(client) => match client.now() { - Ok(now) => Ok(ClockBoundNowResult::from(now)), - Err(e) => Err(anyhow!("{e:?}")), - }, - Self::ClientV3(client) => match client.now() { - Ok(now) => Ok(ClockBoundNowResult::from(now)), - Err(e) => Err(anyhow!("{e:?}")), - }, + Self::ClientV2(client) => Ok(ClockBoundNowResult::from( + client.now().map_err(|_| ClockBoundClientError::V2Error)?, + )), + Self::ClientV3(client) => Ok(ClockBoundNowResult::from( + client.now().map_err(|_| ClockBoundClientError::V3Error)?, + )), } } } /// Convenience struct holding timestamp, error bound, and status. /// Encapsulates the output of interest from a clock bound client read. -#[derive(Debug, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize)] pub struct TimeAndBoundAndStatus { pub time: Instant, pub ceb: Duration, @@ -147,11 +154,15 @@ pub trait ClockBoundClientClock { /// /// # Errors /// Returns an error if clock read fails. - fn get_time_and_bound_and_status(&mut self) -> Result; + fn get_time_and_bound_and_status( + &mut self, + ) -> Result; } impl ClockBoundClientClock for ClockBoundClient { - fn get_time_and_bound_and_status(&mut self) -> Result { + fn get_time_and_bound_and_status( + &mut self, + ) -> Result { Ok(TimeAndBoundAndStatus::from(self.now()?)) } } diff --git a/test/clock-bound-phc-offset/Cargo.toml b/test/clock-bound-phc-offset/Cargo.toml index 71947d4..6abf25d 100644 --- a/test/clock-bound-phc-offset/Cargo.toml +++ b/test/clock-bound-phc-offset/Cargo.toml @@ -17,7 +17,6 @@ name = "clock-bound-phc-offset" path = "src/main.rs" [dependencies] -anyhow = "1.0.100" clock-bound-client-generic = { version = "2.0", path = "../clock-bound-client-generic" } clock-bound = { version = "2.0", path = "../../clock-bound", features = [ "daemon", diff --git a/test/clock-bound-phc-offset/src/main.rs b/test/clock-bound-phc-offset/src/main.rs index f36334d..27f28c1 100644 --- a/test/clock-bound-phc-offset/src/main.rs +++ b/test/clock-bound-phc-offset/src/main.rs @@ -2,7 +2,6 @@ //! //! This executable compares specific clocks against timestamps read from the PHC. -use anyhow::Result; use clap::{Parser, ValueEnum}; use clock_bound::daemon::time::clocks::{MonotonicRaw, RealTime}; @@ -24,13 +23,8 @@ struct Args { clock: ClockToCompare, } -fn emit_logs(clock: ClockToCompare, result: Result) { - match result { - Ok(data) => println!("{}", serde_json::to_string(&data).unwrap()), - Err(e) => tracing::error!( - "Failed to retrieve data from {clock:?} Clock <-> PHC comparison: {e:?}" - ), - } +fn emit_logs(data: T) { + println!("{}", serde_json::to_string(&data).unwrap()); } fn main() { @@ -42,27 +36,17 @@ fn main() { autoconfigure_phc_reader().expect("failed to create PHC reader via autoconfiguration"); match clock { - ClockToCompare::RealTime => emit_logs( - ClockToCompare::RealTime, - phc_reader.get_offset_from_utc_clock(&RealTime), - ), - ClockToCompare::MonotonicRaw => emit_logs( - ClockToCompare::MonotonicRaw, - phc_reader.get_offset_from_utc_clock(&MonotonicRaw), - ), + ClockToCompare::RealTime => emit_logs(phc_reader.get_offset_from_utc_clock(&RealTime)), + ClockToCompare::MonotonicRaw => { + emit_logs(phc_reader.get_offset_from_utc_clock(&MonotonicRaw)); + } ClockToCompare::ClockBoundClientV2 => { let mut client = ClockBoundClient::new(ClockBoundClientVersion::V2); - emit_logs( - ClockToCompare::ClockBoundClientV2, - phc_reader.compare_to_clock_bound_client_clock(&mut client), - ); + emit_logs(phc_reader.compare_to_clock_bound_client_clock(&mut client)); } ClockToCompare::ClockBoundClientV3 => { let mut client = ClockBoundClient::new(ClockBoundClientVersion::V3); - emit_logs( - ClockToCompare::ClockBoundClientV2, - phc_reader.compare_to_clock_bound_client_clock(&mut client), - ); + emit_logs(phc_reader.compare_to_clock_bound_client_clock(&mut client)); } } } diff --git a/test/clock-bound-phc-offset/src/phc.rs b/test/clock-bound-phc-offset/src/phc.rs index 363a7ec..3ee09fa 100644 --- a/test/clock-bound-phc-offset/src/phc.rs +++ b/test/clock-bound-phc-offset/src/phc.rs @@ -1,6 +1,7 @@ -use anyhow::Result; use clock_bound::daemon::io::tsc::{read_timestamp_counter_begin, read_timestamp_counter_end}; pub use clock_bound_client_generic::{ClockBoundClientClock, TimeAndBoundAndStatus}; +use clock_bound_client_generic::{ClockBoundClientError, ClockStatus}; +use nix::errno::Errno; use std::{fs::File, path::PathBuf}; use clock_bound::daemon::time::instant::Utc; @@ -15,12 +16,25 @@ use ceb::PhcClockErrorBoundReader; mod autoconfiguration; pub use autoconfiguration::autoconfigure_phc_reader; -/// Convenience struct holding data pertaining to a snaphot from a reference PHC read. +use thiserror::Error; + +use crate::phc::ceb::CebReadError; + +#[allow(dead_code)] +#[derive(Error, Debug, Clone, serde::Serialize)] +pub enum ClockReadError { + #[error("clock bound client read failed: `{0}`")] + ClockBoundClient(ClockBoundClientError), + #[error("PTP device read failed: `{0}`")] + PtpReadFailure(String), + #[error("PHC CEB read failed: `{0}`")] + PhcCebReadFailure(CebReadError), +} + +/// Convenience struct holding metadata pertaining to clock->ref-clock->clock read exchange. #[allow(dead_code)] #[derive(Debug, serde::Serialize)] -struct RefClockData { - /// Timestamp read from PHC. - time: Instant, +struct ExchangeMetadata { /// Offset between midpoint of pre/post clock read and PHC timestamp. /// A positive value here indicates that the clock is ahead of the PHC. offset: Duration, @@ -30,9 +44,6 @@ struct RefClockData { /// RTT in representing duration between pre/post TSC reads (and bounding PHC read). /// Value represents elapsed TSC ticks. rtt_tsc: TscDiff, - /// Clock Error Bound reported by the PHC. - /// This is read just after the post clock read operation. - ceb: Duration, } /// Convenience struct wrapping an instant. Represents the read of a basic clock (one with no status/error-bound). @@ -54,7 +65,8 @@ impl From for BasicClockTime { pub struct BasicClockRefComparison { clock_pre: BasicClockTime, tsc_pre: TscCount, - ref_clock: RefClockData, + ref_clock: Result, + exchange_metadata: Option, tsc_post: TscCount, clock_post: BasicClockTime, } @@ -63,11 +75,12 @@ pub struct BasicClockRefComparison { #[allow(dead_code)] #[derive(Debug, serde::Serialize)] pub struct ClockBoundClientRefComparison { - clock_pre: TimeAndBoundAndStatus, + clock_pre: Result, tsc_pre: TscCount, - ref_clock: RefClockData, + ref_clock: Result, + exchange_metadata: Option, tsc_post: TscCount, - clock_post: TimeAndBoundAndStatus, + clock_post: Result, } #[derive(Debug)] @@ -94,83 +107,124 @@ impl PhcReader { /// /// # Errors /// Returns an error if the PTP system call fails. - pub fn get_time(&self) -> Result { + pub fn get_time(&self) -> Result { self.ptp_reader.ptp_get_time() } + fn build_response_from_phc_read( + phc_time: Result, + phc_ceb: Result, + ) -> Result { + let phc_time = phc_time.map_err(|e| ClockReadError::PtpReadFailure(e.to_string()))?; + let phc_ceb = phc_ceb.map_err(ClockReadError::PhcCebReadFailure)?; + Ok(TimeAndBoundAndStatus { + time: phc_time, + ceb: phc_ceb, + status: ClockStatus::Synchronized, + }) + } + + fn build_response_from_clock_bound_client_read( + clock_read: Result, + ) -> Result { + let clock_read = clock_read.map_err(|e| ClockReadError::PtpReadFailure(e.to_string()))?; + Ok(clock_read) + } + /// Return details from a "basic" UTC clock to PHC comparision. /// /// # Errors /// Returns an error if the PHC read or CEB read operation fails. + #[allow(clippy::missing_panics_doc)] pub fn get_offset_from_utc_clock>( &self, clock: &T, - ) -> Result { + ) -> BasicClockRefComparison { let clock_pre = clock.get_time(); let tsc_pre = read_timestamp_counter_begin(); - let phc_time = self.get_time()?; + let phc_time = self.get_time(); let tsc_post = read_timestamp_counter_end(); let clock_post = clock.get_time(); - let phc_ceb = self.ceb_reader.read()?; - - let mid = clock_pre.midpoint(clock_post); - let offset = mid - phc_time; - let rtt_clock = clock_post - clock_pre; + let phc_ceb = self.ceb_reader.read(); let tsc_pre = TscCount::new(tsc_pre.into()); let tsc_post = TscCount::new(tsc_post.into()); - let rtt_tsc = tsc_post - tsc_pre; - Ok(BasicClockRefComparison { - clock_pre: BasicClockTime::from(clock_pre), - tsc_pre, - ref_clock: RefClockData { - time: phc_time, + let ref_clock = Self::build_response_from_phc_read(phc_time, phc_ceb); + let exchange_metadata = if ref_clock.is_err() { + None + } else { + let mid = clock_pre.midpoint(clock_post); + let offset = mid - ref_clock.clone().unwrap().time; + let rtt_clock = clock_post - clock_pre; + let rtt_tsc = tsc_post - tsc_pre; + + Some(ExchangeMetadata { offset, rtt_clock, rtt_tsc, - ceb: phc_ceb, - }, + }) + }; + + BasicClockRefComparison { + clock_pre: BasicClockTime::from(clock_pre), + tsc_pre, + ref_clock, + exchange_metadata, tsc_post, clock_post: BasicClockTime::from(clock_post), - }) + } } /// Return comparison data from a ClockBound client clock to PHC. /// /// # Errors /// Returns an error if the PTP syscall, PHC read, or client read operations fail. + #[allow(clippy::missing_panics_doc)] pub fn compare_to_clock_bound_client_clock( &self, clock: &mut T, - ) -> Result { - let clock_pre = clock.get_time_and_bound_and_status()?; + ) -> ClockBoundClientRefComparison { + let clock_pre = clock.get_time_and_bound_and_status(); let tsc_pre = read_timestamp_counter_begin(); - let phc_time = self.get_time()?; + let phc_time = self.get_time(); let tsc_post = read_timestamp_counter_end(); - let clock_post = clock.get_time_and_bound_and_status()?; - let phc_ceb = self.ceb_reader.read()?; - - let mid = clock_pre.time.midpoint(clock_post.time); - let offset = mid - phc_time; - let rtt_clock = clock_post.time - clock_pre.time; + let clock_post = clock.get_time_and_bound_and_status(); + let phc_ceb = self.ceb_reader.read(); let tsc_pre = TscCount::new(tsc_pre.into()); let tsc_post = TscCount::new(tsc_post.into()); - let rtt_tsc = tsc_post - tsc_pre; - Ok(ClockBoundClientRefComparison { - clock_pre, - tsc_pre, - ref_clock: RefClockData { - time: phc_time, + let clock_pre = Self::build_response_from_clock_bound_client_read(clock_pre); + let ref_clock = Self::build_response_from_phc_read(phc_time, phc_ceb); + let clock_post = Self::build_response_from_clock_bound_client_read(clock_post); + + let exchange_metadata = if clock_pre.is_err() || ref_clock.is_err() || clock_post.is_err() { + None + } else { + let mid = clock_pre + .clone() + .unwrap() + .time + .midpoint(clock_post.clone().unwrap().time); + let offset = mid - ref_clock.clone().unwrap().time; + let rtt_clock = clock_post.clone().unwrap().time - clock_pre.clone().unwrap().time; + let rtt_tsc = tsc_post - tsc_pre; + + Some(ExchangeMetadata { offset, rtt_clock, rtt_tsc, - ceb: phc_ceb, - }, + }) + }; + + ClockBoundClientRefComparison { + clock_pre, + tsc_pre, + ref_clock, + exchange_metadata, tsc_post, clock_post, - }) + } } } diff --git a/test/clock-bound-phc-offset/src/phc/ceb.rs b/test/clock-bound-phc-offset/src/phc/ceb.rs index 76c9184..7e24fe8 100644 --- a/test/clock-bound-phc-offset/src/phc/ceb.rs +++ b/test/clock-bound-phc-offset/src/phc/ceb.rs @@ -1,7 +1,16 @@ -use anyhow::{Context, Result}; use clock_bound::daemon::time::Duration; use std::path::PathBuf; +use thiserror::Error; + +#[derive(Error, Debug, Clone, serde::Serialize)] +pub enum CebReadError { + #[error("failed to read phc errror bound path to string: `{0}`")] + Io(String), + #[error("failed to parse PHC error bound value to i64: `{0}`")] + ParseInt(String), +} + #[derive(Debug, Clone, Default)] pub struct PhcClockErrorBoundReader { sysfs_phc_error_bound_path: PathBuf, @@ -14,13 +23,13 @@ impl PhcClockErrorBoundReader { } } - pub fn read(&self) -> Result { + pub fn read(&self) -> Result { let contents = std::fs::read_to_string(&self.sysfs_phc_error_bound_path) - .context("failed to read phc errror bound path to string")?; + .map_err(|e| CebReadError::Io(e.to_string()))?; let nanos = contents .trim() .parse::() - .context("failed to parse PHC error bound value to i64")?; + .map_err(|e| CebReadError::ParseInt(e.to_string()))?; Ok(Duration::from_nanos(nanos.into())) } } diff --git a/test/clock-bound-phc-offset/src/phc/ptp.rs b/test/clock-bound-phc-offset/src/phc/ptp.rs index 646463b..40f87a2 100644 --- a/test/clock-bound-phc-offset/src/phc/ptp.rs +++ b/test/clock-bound-phc-offset/src/phc/ptp.rs @@ -1,9 +1,8 @@ -use anyhow::{Result, anyhow}; use clock_bound::daemon::time::Instant; use std::fs::File; use libc::c_uint; -use nix::ioctl_readwrite; +use nix::{errno::Errno, ioctl_readwrite}; use std::os::unix::io::AsRawFd; /// `PTP_SYS_OFFSET_EXTENDED2` ioctl call. @@ -63,7 +62,7 @@ impl PtpReader { /// /// # Errors /// Returns an error if the PTP ioctl fails. - pub fn ptp_get_time(mut self) -> Result { + pub fn ptp_get_time(mut self) -> Result { // SAFETY: The ptp_sys_offset_extended2() function is generated by the // nix::ioctl_readwrite! macro and the call is safe because the arguments // are expected to be valid. The file descriptor comes from a File @@ -71,13 +70,10 @@ impl PtpReader { // that the file descriptor is valid. The other argument provided to // the ptp_sys_offset_extended2() was created within this function // just above, and its definition matches the expected struct format. - let result = unsafe { + let _ = unsafe { ptp_sys_offset_extended2(self.phc_device_fd, &raw mut self.ptp_sys_offset_extended) - }; - let phc = match result { - Ok(_) => self.ptp_sys_offset_extended.ts[0][1], - Err(e) => return Err(anyhow!("PTP system call failed. {e:?}")), - }; + }?; + let phc = self.ptp_sys_offset_extended.ts[0][1]; Ok(Instant::from_time(phc.sec.into(), phc.nsec)) } } From e95f131ba4aaa2284902f30c05b201951ff2572d Mon Sep 17 00:00:00 2001 From: Myles N <95256483+mylescn@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:41:29 -0500 Subject: [PATCH 155/177] Adding retry logic to IMDS requests. * Adding retry logic to IMDS requests. > Current implementation retries requests 3 times(with exponential backoff) --- Cargo.lock | 32 ++++++++ clock-bound/Cargo.toml | 1 + clock-bound/src/daemon/io/imds.rs | 119 ++++++++++++++++++++---------- 3 files changed, 111 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 90f4a67..d125b87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,6 +291,7 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "tokio", + "tokio-retry", "tokio-util", "tracing", "tracing-appender", @@ -1359,6 +1360,26 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2048,6 +2069,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-retry" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" +dependencies = [ + "pin-project", + "rand 0.8.5", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.17" diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 5451a46..69840a7 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -50,6 +50,7 @@ tracing-subscriber = { version = "0.3", features = [ ] } futures = "0.3" rand = "0.9.2" +tokio-retry = "0.3" [dev-dependencies] approx = "0.5" diff --git a/clock-bound/src/daemon/io/imds.rs b/clock-bound/src/daemon/io/imds.rs index eba52f6..ace10df 100644 --- a/clock-bound/src/daemon/io/imds.rs +++ b/clock-bound/src/daemon/io/imds.rs @@ -1,12 +1,18 @@ //! Struct holding instance type meta data +use bytes::Bytes; use core::str; - +use reqwest::{Client, Response}; use std::time::Duration; use thiserror::Error; use tokio::time::timeout; +use tokio_retry::{ + Retry, + strategy::{ExponentialBackoff, jitter}, +}; +use tracing::warn; #[derive(Debug, Error)] -pub enum InstanceTypeError { +pub enum IMDSError { #[error("Failed to parse into json.")] Serde(#[from] serde_json::Error), #[error("Reqwest error")] @@ -30,37 +36,75 @@ impl InstanceType { const IMDS_ORIGIN: &'static str = "http://169.254.169.254"; const IMDS_TIMEOUT_DURATION: Duration = Duration::from_secs(1); - #[cfg(test)] - pub async fn get_from_imds() -> Result { - // Setting InstanceType explicitly to prevent unit tests from attempting to reach out to - // imds. - Ok(InstanceType("m7i.xlarge".to_string())) - } - /// Request and parse info using the [IMDSv2 API] /// /// [IMDSv2 API]: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html - #[cfg(not(test))] - pub async fn get_from_imds() -> Result { + pub async fn get_from_imds() -> Result { + const MAX_REQUESTS: usize = 4; let client = reqwest::Client::new(); + let retry_strategy = ExponentialBackoff::from_millis(10) + .map(jitter) + .take(MAX_REQUESTS); + // get session token - let response = timeout( + let token_response = Retry::spawn(retry_strategy.clone(), || { + Self::execute_imds_token_request(&client) + }) + .await?; + + let token = token_response.bytes().await?; + + // // get actual metadata + let instance_type_response = Retry::spawn(retry_strategy.clone(), || { + Self::execute_imds_instance_type_request(&client, token.clone()) + }) + .await?; + + let instance_type_bytes = instance_type_response.bytes().await?; + let instance_type = str::from_utf8(&instance_type_bytes)?; + + Ok(Self(instance_type.to_string())) + } + + // Uses instance metadata to determine if instance is bare metal. + pub fn is_metal(&self) -> bool { + self.0.contains("metal") + } + + /// Executes an IMDS request for an authentication token and returns the result + async fn execute_imds_token_request(client: &Client) -> Result { + let response_result = match timeout( Self::IMDS_TIMEOUT_DURATION, client .put(format!("{}/latest/api/token", Self::IMDS_ORIGIN)) .header("X-aws-ec2-metadata-token-ttl-seconds", "21600") .send(), ) - .await??; - - if !response.status().is_success() { - return Err(InstanceTypeError::ReceiveToken); + .await + { + Ok(result) => result, + Err(e) => { + warn!("Timeout for IMDS request breached. Retrying."); + Err(IMDSError::Timeout(e))? + } + }; + + match response_result { + Ok(response) => Ok(response), + Err(e) => { + warn!("Error returned by IMDS request: {}. Retrying.", e); + Err(IMDSError::ReceiveToken) + } } - let token = response.bytes().await?; + } - // get actual metadata - let response = timeout( + /// Executes an IMDS request for instance type metadata and returns the result + async fn execute_imds_instance_type_request( + client: &Client, + token: Bytes, + ) -> Result { + let response_result = match timeout( Self::IMDS_TIMEOUT_DURATION, client .get(format!( @@ -70,21 +114,22 @@ impl InstanceType { .header("X-aws-ec2-metadata-token", &*token) .send(), ) - .await??; - - if !response.status().is_success() { - return Err(InstanceTypeError::ReceiveImds); + .await + { + Ok(result) => result, + Err(e) => { + warn!("Timeout for IMDS request breached. Retrying."); + Err(IMDSError::Timeout(e))? + } + }; + + match response_result { + Ok(response) => Ok(response), + Err(e) => { + warn!("Error returned by IMDS request: {}. Retrying.", e); + Err(IMDSError::ReceiveToken) + } } - let bytes = response.bytes().await?; - - let contents = str::from_utf8(&bytes)?; - - Ok(Self(contents.to_string())) - } - - // Uses instance metadata to determine if instance is bare metal. - pub fn is_metal(&self) -> bool { - self.0.contains("metal") } } @@ -92,14 +137,6 @@ impl InstanceType { mod test { use super::*; - // This tests does NOT reach out to IMDS. Instead it uses `get_from_imds` specific to the test - // binary which returns a hard coded value, `m7i.xlarge`. - #[tokio::test] - async fn from_imds() { - let instance_type = InstanceType::get_from_imds().await.unwrap(); - println!("{:?}", instance_type); - } - #[test] fn metal_instance() { let instance_type = InstanceType("m7i.metal-24xlarge ".to_string()); From 0236f699ed7b101affc92ec95957dba3b2365eea Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 21 Nov 2025 12:15:53 -0500 Subject: [PATCH 156/177] [service] make vmclock readable (#183) * [service] make vmclock readable * full path of chmod --- clock-bound/assets/clockbound.service | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clock-bound/assets/clockbound.service b/clock-bound/assets/clockbound.service index 6cd974d..d323901 100644 --- a/clock-bound/assets/clockbound.service +++ b/clock-bound/assets/clockbound.service @@ -5,6 +5,9 @@ Conflicts=ntpd.service systemd-timesyncd.service chronyd.service [Service] Type=exec +# Make the vmclock readable by clients +ExecStartPre=-/usr/bin/chmod a+r /dev/vmclock0 +# Enable the PHC if possible ExecStartPre=-/usr/sbin/configure_phc ExecStart=/usr/bin/clockbound Restart=on-failure From 2c221033ad9dae3b91ce3072a90450429486a692 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 21 Nov 2025 12:27:56 -0500 Subject: [PATCH 157/177] repro captures startup and disruption events (#178) The repro logs don't capture when a disruption event occurs. Also add markers for startup since logs can be appended to during application restarts. --- clock-bound-ff-tester/src/bin/repro_replay.rs | 24 ++-- clock-bound-ff-tester/src/repro.rs | 131 ++++++++++-------- clock-bound/src/daemon.rs | 1 + .../src/daemon/clock_sync_algorithm.rs | 16 +++ 4 files changed, 104 insertions(+), 68 deletions(-) diff --git a/clock-bound-ff-tester/src/bin/repro_replay.rs b/clock-bound-ff-tester/src/bin/repro_replay.rs index 2c00100..40bc402 100644 --- a/clock-bound-ff-tester/src/bin/repro_replay.rs +++ b/clock-bound-ff-tester/src/bin/repro_replay.rs @@ -11,8 +11,7 @@ use clock_bound::daemon::{ selected_clock::SelectedClockSource, }; use clock_bound_ff_tester::{ - events::Scenario, - repro::{self, routable_from_tester_event}, + repro::{self, ReproEvent}, time::Skew, }; @@ -42,16 +41,23 @@ fn main() -> anyhow::Result<()> { .selector(Selector::new(max_dispersion)) .build(); - let scenario = repro::scenario_from_log_file(&args.logfile)?; + let repro_events = repro::repro_events_from_log_file(&args.logfile)?; + // needed to output new clock parameters daemon::subscriber::init(args.output_dir); - let Scenario::V1(scenario) = scenario.0; - let events = scenario.events; - - for event in events { - let routable = routable_from_tester_event(event); - alg.feed(routable); + for event in repro_events { + match event { + ReproEvent::Init => tracing::warn!("Received init."), // TODO: Better breakdown into scenarios + ReproEvent::Disruption => { + tracing::warn!("Received disruption."); + alg.handle_disruption(); + } + ReproEvent::Feed(event, _) => { + // unused result since output log directory will contain this + let _ = alg.feed(event); + } + } } Ok(()) diff --git a/clock-bound-ff-tester/src/repro.rs b/clock-bound-ff-tester/src/repro.rs index 8cb267c..8db042b 100644 --- a/clock-bound-ff-tester/src/repro.rs +++ b/clock-bound-ff-tester/src/repro.rs @@ -1,13 +1,12 @@ //! Convert logs into `ff-tester` types in a Scenario use std::{ - collections::HashMap, fs::File, io::{BufRead, Read}, path::Path, }; -use crate::events::{Scenario, v1}; +use crate::events::v1; use crate::time::CbBridge; use clock_bound::daemon::{ clock_parameters::ClockParameters, @@ -16,16 +15,25 @@ use clock_bound::daemon::{ time::{Duration, Instant}, }; +/// Events fed into the `ClockSyncAlgorithm` +#[expect(clippy::large_enum_variant)] +pub enum ReproEvent { + /// Feed a routable event into the clock sync algorithm + Feed(RoutableEvent, Option), + /// The application initialized. + Init, + /// A disruption event came in. Call `handle_disruption` + Disruption, +} + /// Read a logfile and return all inputs and outputs from the `ClockSyncAlgorithm` /// /// # Errors /// - Returns an error if unable to open the file. /// - Returns and error if unable to read values from the logfile -pub fn scenario_from_log_file( - file_path: impl AsRef, -) -> anyhow::Result<(Scenario, Vec>)> { +pub fn repro_events_from_log_file(file_path: impl AsRef) -> anyhow::Result> { let file = File::open(file_path)?; - scenario_from_reader(file) + repro_events_from_reader(file) } /// Convert a log file at `file_path` into a `ff-tester` scenario @@ -36,13 +44,10 @@ pub fn scenario_from_log_file( /// /// # Errors /// Returns errors if the logfile is corrupted -pub fn scenario_from_reader( - input: impl Read, -) -> anyhow::Result<(Scenario, Vec>)> { +pub fn repro_events_from_reader(input: impl Read) -> anyhow::Result> { let reader = std::io::BufReader::new(input); let mut events = Vec::new(); - let mut clock_parameters = Vec::new(); for line in reader.lines() { let Ok(line) = line else { @@ -57,53 +62,41 @@ pub fn scenario_from_reader( } }; - let event = match line.fields.parse_event() { - Ok(event) => event, - Err(e) => { - tracing::warn!(line = ?line, "Could not parse event: {e}"); - continue; + match &line.fields { + FieldEnum::Init(_init) => events.push(ReproEvent::Init), + + FieldEnum::Disruption(_disruption) => events.push(ReproEvent::Disruption), + + FieldEnum::FeedEvent(fields) => { + let event = match fields.parse_event() { + Ok(event) => event, + Err(e) => { + tracing::warn!(line = ?line, "Could not parse event: {e}"); + continue; + } + }; + + let output = match fields.parse_output() { + Ok(output) => output, + Err(e) => { + tracing::warn!(line = ?line, "Could not parse output: {e}"); + continue; + } + }; + + events.push(ReproEvent::Feed(event, output)); } - }; - - let output = match line.fields.parse_output() { - Ok(output) => output, - Err(e) => { - tracing::warn!(line = ?line, "Could not parse output: {e}"); - continue; - } - }; - - events.push(event); - clock_parameters.push(output); + } } if events.is_empty() { anyhow::bail!("No events found in log file"); } - if clock_parameters.is_empty() { - anyhow::bail!("No clock parameters found in log file"); - } - - if events.len() != clock_parameters.len() { - anyhow::bail!("Number of events and clock parameters do not match"); - } - - let events: Vec<_> = events - .into_iter() - .map(|event| tester_event_from_routable(&event)) - .collect(); - - let scenario = Scenario::V1(v1::Scenario { - events, - oscillator: None, - metadata: HashMap::new(), - }); - - Ok((scenario, clock_parameters)) + Ok(events) } -fn tester_event_from_cb_ntp(event: &event::Ntp, source_id: String) -> v1::Event { +pub fn tester_event_from_cb_ntp(event: &event::Ntp, source_id: String) -> v1::Event { v1::Event { variants: v1::EventKind::Ntp(v1::Ntp { server_system_recv_time: event.data().server_recv_time.into_estimate(), @@ -118,7 +111,7 @@ fn tester_event_from_cb_ntp(event: &event::Ntp, source_id: String) -> v1::Event } } -fn tester_event_from_cb_phc(event: &event::Phc, source_id: String) -> v1::Event { +pub fn tester_event_from_cb_phc(event: &event::Phc, source_id: String) -> v1::Event { v1::Event { variants: v1::EventKind::Phc(v1::Phc { phc_time: event.data().time.into_estimate(), @@ -131,7 +124,7 @@ fn tester_event_from_cb_phc(event: &event::Phc, source_id: String) -> v1::Event } } -fn tester_event_from_routable(event: &RoutableEvent) -> v1::Event { +pub fn tester_event_from_routable(event: &RoutableEvent) -> v1::Event { match event { RoutableEvent::LinkLocal(event) => { tester_event_from_cb_ntp(event, String::from("link_local")) @@ -181,7 +174,27 @@ pub fn routable_from_tester_event(event: v1::Event) -> RoutableEvent { /// to get the [`NTP event`](clock_bound::daemon::event::Ntp) and [`ClockParameters`] #[derive(Debug, Clone, serde::Deserialize)] struct Line { - fields: Fields, + fields: FieldEnum, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(untagged)] // load bearing untagged +enum FieldEnum { + FeedEvent(Fields), + Init(Init), + Disruption(Disruption), +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[expect(unused, reason = "key used for deserialization")] +struct Init { + init: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[expect(unused, reason = "key used for deserialization")] +struct Disruption { + disruption: String, } /// Workaround for tracing escapes @@ -281,16 +294,16 @@ mod tests { fn scenario_from_logs() { let example_log = include_str!("repro/test_11_06_2025.log"); - let (scenario, clock_parameters) = scenario_from_reader(example_log.as_bytes()).unwrap(); - - assert_eq!(clock_parameters.len(), 13); - let Scenario::V1(scenario) = scenario; - assert_eq!(scenario.events.len(), 13); - assert!(scenario.oscillator.is_none()); + let events = repro_events_from_reader(example_log.as_bytes()).unwrap(); - let param_count = clock_parameters + let param_count = events .iter() - .filter_map(|params| params.as_ref()) + .filter_map(|event| { + let ReproEvent::Feed(_, params) = event else { + return None; + }; + params.as_ref() + }) .count(); assert_eq!(param_count, 10); // 3 of the values are the first inputs for sources. So 10 calculate clock parameters } diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 77fe975..ea7493e 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -175,6 +175,7 @@ impl Daemon { /// Run the daemon pub async fn run(mut self: Box) { + self.clock_sync_algorithm.init_repro(); // Start IO polling self.io_front_end.spawn_all(); self.clock_state_handle.task_tracker.spawn({ diff --git a/clock-bound/src/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs index 7a53e47..579ac6e 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -54,6 +54,17 @@ pub struct ClockSyncAlgorithm { } impl ClockSyncAlgorithm { + /// Logs into the reproducibility logs that the app has started + /// + /// Can be used to break-up application restarts when scanning logs + pub fn init_repro(&self) { + tracing::info!( + target: PRIMER_TARGET, + init = "CSA initialized", + "Clock Sync Alg started" + ); + } + /// Feed the clock sync algorithm with a time synchronization event pub fn feed(&mut self, routable_event: RoutableEvent) -> Option<&ClockParameters> { #[cfg(not(test))] @@ -208,6 +219,11 @@ impl ClockSyncAlgorithm { } selector.handle_disruption(); tracing::info!("Handled clock disruption event"); + tracing::info!( + target: PRIMER_TARGET, + disruption = "Disruption occurred", + "handled disruption" + ); } } From cfde9e4f7005bbc0670ff262d69f30cd2785371d Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 21 Nov 2025 12:55:14 -0500 Subject: [PATCH 158/177] Bump version to 3.0.0-alpha.0 (#177) * Bump version to 3.0.0-alpha.0 This is not the actual release. Just getting this merged in before publishing at a later date * use the repo version for rpm --- Cargo.lock | 31 ++++++++++--------- Cargo.toml | 2 +- clock-bound-ffi/Cargo.toml | 2 +- clock-bound/Cargo.toml | 3 +- examples/client/rust/Cargo.toml | 4 +-- test/clock-bound-adjust-clock-test/Cargo.toml | 4 +-- test/clock-bound-adjust-clock/Cargo.toml | 4 +-- test/clock-bound-client-generic/Cargo.toml | 4 +-- test/clock-bound-now/Cargo.toml | 2 +- test/clock-bound-phc-offset/Cargo.toml | 8 ++--- .../Cargo.toml | 4 +-- test/link-local/Cargo.toml | 4 +-- test/ntp-source/Cargo.toml | 4 +-- test/phc/Cargo.toml | 4 +-- test/vmclock-updater/Cargo.toml | 2 +- test/vmclock/Cargo.toml | 2 +- 16 files changed, 33 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d125b87..904d2ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -266,7 +266,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "clock-bound" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "approx", "bon", @@ -301,7 +301,7 @@ dependencies = [ [[package]] name = "clock-bound-adjust-clock" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "anyhow", "chrono", @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "clock-bound-adjust-clock-test" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "clock-bound", "rstest 0.26.1", @@ -334,7 +334,7 @@ dependencies = [ [[package]] name = "clock-bound-client-generic" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "anyhow", "chrono", @@ -352,7 +352,7 @@ dependencies = [ [[package]] name = "clock-bound-ff-tester" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "anyhow", "approx", @@ -378,7 +378,7 @@ dependencies = [ [[package]] name = "clock-bound-ffi" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "byteorder", "clock-bound", @@ -390,7 +390,7 @@ dependencies = [ [[package]] name = "clock-bound-now" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "chrono", "clap", @@ -404,8 +404,9 @@ dependencies = [ [[package]] name = "clock-bound-phc-offset" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ + "anyhow", "clap", "clock-bound", "clock-bound-client-generic", @@ -447,7 +448,7 @@ dependencies = [ [[package]] name = "clock-bound-vmclock-client-example" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "byteorder", "clock-bound", @@ -457,7 +458,7 @@ dependencies = [ [[package]] name = "clock-bound-vmclock-client-test" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "byteorder", "clock-bound", @@ -1064,7 +1065,7 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "link-local" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "clock-bound", "rand 0.9.2", @@ -1242,7 +1243,7 @@ dependencies = [ [[package]] name = "ntp-source" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "clock-bound", "md5", @@ -1350,7 +1351,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phc" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "clock-bound", "rand 0.9.2", @@ -2339,7 +2340,7 @@ dependencies = [ [[package]] name = "vmclock" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "clock-bound", "rand 0.9.2", @@ -2349,7 +2350,7 @@ dependencies = [ [[package]] name = "vmclock-updater" -version = "2.0.3" +version = "3.0.0-alpha.0" dependencies = [ "byteorder", "clap", diff --git a/Cargo.toml b/Cargo.toml index 83d204f..f7fe916 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,5 +39,5 @@ exclude = [] keywords = ["aws", "ntp", "ec2", "time"] publish = false repository = "https://github.com/aws/clock-bound" -version = "2.0.3" +version = "3.0.0-alpha.0" diff --git a/clock-bound-ffi/Cargo.toml b/clock-bound-ffi/Cargo.toml index fe67a42..a0d5fce 100644 --- a/clock-bound-ffi/Cargo.toml +++ b/clock-bound-ffi/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["cdylib", "staticlib"] name = "clockbound" [dependencies] -clock-bound = { version = "2.0", path = "../clock-bound" } +clock-bound = { version = "3.0.0-alpha.0", path = "../clock-bound" } errno = { version = "0.3.0", default-features = false } libc = { version = "0.2", default-features = false } nix = { version = "0.26", features = ["feature", "time"] } diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 69840a7..8ec19bf 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -94,7 +94,7 @@ test-side-by-side = [ ] # run without changing system clock. And compare against system clock time-string-parse = ["dep:nom"] -default = ["client", "daemon"] +default = ["client"] [[bin]] name = "clockbound" @@ -102,7 +102,6 @@ required-features = ["daemon"] [package.metadata.generate-rpm] name = "clockbound" -version = "3.0.0-alpha" assets = [ { source = "target/release/clockbound", dest = "/usr/bin/clockbound", mode = "755" }, { source = "assets/clockbound.service", dest = "/usr/lib/systemd/system/clockbound.service", mode = "644" }, diff --git a/examples/client/rust/Cargo.toml b/examples/client/rust/Cargo.toml index 15ab077..372aea8 100644 --- a/examples/client/rust/Cargo.toml +++ b/examples/client/rust/Cargo.toml @@ -19,9 +19,7 @@ path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clock-bound = { version = "2.0", path = "../../../clock-bound", features = [ - "client", -] } +clock-bound = { path = "../../../clock-bound", features = ["client"] } nix = { version = "0.26", features = ["feature", "time"] } [dev-dependencies] diff --git a/test/clock-bound-adjust-clock-test/Cargo.toml b/test/clock-bound-adjust-clock-test/Cargo.toml index 7005e88..470c353 100644 --- a/test/clock-bound-adjust-clock-test/Cargo.toml +++ b/test/clock-bound-adjust-clock-test/Cargo.toml @@ -18,9 +18,7 @@ name = "adjust-clock-test" path = "src/adjust_clock_test.rs" [dependencies] -clock-bound = { version = "2.0", path = "../../clock-bound", features = [ - "daemon", -] } +clock-bound = { path = "../../clock-bound", features = ["daemon"] } tokio = { version = "1.47.1", features = ["macros", "rt", "test-util"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "std"] } diff --git a/test/clock-bound-adjust-clock/Cargo.toml b/test/clock-bound-adjust-clock/Cargo.toml index ced5a9f..5456774 100644 --- a/test/clock-bound-adjust-clock/Cargo.toml +++ b/test/clock-bound-adjust-clock/Cargo.toml @@ -25,6 +25,4 @@ path = "src/step_clock.rs" anyhow = "1" chrono = "0.4" clap = { version = "4.5.31", features = ["derive"] } -clock-bound = { version = "2.0", path = "../../clock-bound", features = [ - "daemon", -] } +clock-bound = { path = "../../clock-bound", features = ["daemon"] } diff --git a/test/clock-bound-client-generic/Cargo.toml b/test/clock-bound-client-generic/Cargo.toml index 5b45340..9c66bc1 100644 --- a/test/clock-bound-client-generic/Cargo.toml +++ b/test/clock-bound-client-generic/Cargo.toml @@ -14,9 +14,7 @@ repository.workspace = true version.workspace = true [dependencies] -clock-bound = { version = "2.0", path = "../../clock-bound", features = [ - "client", -] } +clock-bound = { path = "../../clock-bound", features = ["client", "daemon"] } clock-bound-client = { version = "2.0" } chrono = { version = "0.4", features = ["serde"] } diff --git a/test/clock-bound-now/Cargo.toml b/test/clock-bound-now/Cargo.toml index e7ef9a1..6d61cbd 100644 --- a/test/clock-bound-now/Cargo.toml +++ b/test/clock-bound-now/Cargo.toml @@ -18,7 +18,7 @@ name = "clock-bound-now" path = "src/main.rs" [dependencies] -clock-bound-client-generic = { version = "2.0", path = "../clock-bound-client-generic" } +clock-bound-client-generic = { path = "../clock-bound-client-generic" } chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5.31", features = ["derive"] } diff --git a/test/clock-bound-phc-offset/Cargo.toml b/test/clock-bound-phc-offset/Cargo.toml index 6abf25d..8a5af7c 100644 --- a/test/clock-bound-phc-offset/Cargo.toml +++ b/test/clock-bound-phc-offset/Cargo.toml @@ -17,11 +17,9 @@ name = "clock-bound-phc-offset" path = "src/main.rs" [dependencies] -clock-bound-client-generic = { version = "2.0", path = "../clock-bound-client-generic" } -clock-bound = { version = "2.0", path = "../../clock-bound", features = [ - "daemon", - "client", -] } +anyhow = "1.0.100" +clock-bound-client-generic = { path = "../clock-bound-client-generic" } +clock-bound = { path = "../../clock-bound", features = ["daemon", "client"] } libc = { version = "0.2", default-features = false, features = [ "extra_traits", ] } diff --git a/test/clock-bound-vmclock-client-test/Cargo.toml b/test/clock-bound-vmclock-client-test/Cargo.toml index 8a6ac49..4da0465 100644 --- a/test/clock-bound-vmclock-client-test/Cargo.toml +++ b/test/clock-bound-vmclock-client-test/Cargo.toml @@ -19,9 +19,7 @@ path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clock-bound = { version = "2.0", path = "../../clock-bound", features = [ - "client", -] } +clock-bound = { path = "../../clock-bound", features = ["client"] } nix = { version = "0.26", features = ["feature", "time"] } [dev-dependencies] diff --git a/test/link-local/Cargo.toml b/test/link-local/Cargo.toml index 6896281..2222f0f 100644 --- a/test/link-local/Cargo.toml +++ b/test/link-local/Cargo.toml @@ -17,9 +17,7 @@ name = "link-local-test" path = "src/main.rs" [dependencies] -clock-bound = { version = "2.0", path = "../../clock-bound", features = [ - "daemon", -] } +clock-bound = { path = "../../clock-bound", features = ["daemon"] } rand = "0.9.2" tempfile = "3.13" tokio = { version = "1.47.1", features = ["macros", "rt"] } diff --git a/test/ntp-source/Cargo.toml b/test/ntp-source/Cargo.toml index 6ffa4c4..7fc6a3a 100644 --- a/test/ntp-source/Cargo.toml +++ b/test/ntp-source/Cargo.toml @@ -17,9 +17,7 @@ name = "ntp-source-test" path = "src/main.rs" [dependencies] -clock-bound = { version = "2.0", path = "../../clock-bound", features = [ - "daemon", -] } +clock-bound = { path = "../../clock-bound", features = ["daemon"] } md5 = "0.8.0" rand = "0.9.2" tokio = { version = "1.47.1", features = ["macros", "rt"] } diff --git a/test/phc/Cargo.toml b/test/phc/Cargo.toml index 6b32abd..5c9b379 100644 --- a/test/phc/Cargo.toml +++ b/test/phc/Cargo.toml @@ -17,9 +17,7 @@ name = "phc-test" path = "src/main.rs" [dependencies] -clock-bound = { version = "2.0", path = "../../clock-bound", features = [ - "daemon", -] } +clock-bound = { path = "../../clock-bound", features = ["daemon"] } rand = "0.9.2" tokio = { version = "1.47.1", features = ["macros", "rt"] } tracing = "0.1" diff --git a/test/vmclock-updater/Cargo.toml b/test/vmclock-updater/Cargo.toml index 740e3f4..6ba416a 100644 --- a/test/vmclock-updater/Cargo.toml +++ b/test/vmclock-updater/Cargo.toml @@ -17,7 +17,7 @@ name = "vmclock-updater" path = "src/main.rs" [dependencies] -clock-bound = { version = "2.0", path = "../../clock-bound" } +clock-bound = { path = "../../clock-bound" } byteorder = "1" clap = { version = "4", features = ["derive"] } errno = { version = "0.3.0", default-features = false } diff --git a/test/vmclock/Cargo.toml b/test/vmclock/Cargo.toml index 8ff3b5c..7ffab71 100644 --- a/test/vmclock/Cargo.toml +++ b/test/vmclock/Cargo.toml @@ -17,7 +17,7 @@ name = "vmclock-test" path = "src/main.rs" [dependencies] -clock-bound = { version = "2.0", path = "../../clock-bound", features = [ +clock-bound = { version = "3.0.0-alpha.0", path = "../../clock-bound", features = [ "daemon", ] } rand = "0.9.2" From 1be16466b0c5249676e2d4da5cf4e78986ebbee7 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 21 Nov 2025 12:56:38 -0500 Subject: [PATCH 159/177] [csa::phc] device name in span (#165) Adds the ptp device path to the span for phc clock sync algorithm. This adds context on the device on any issues within the ff::phc logic. --- clock-bound/src/daemon.rs | 19 +++++++-------- .../daemon/clock_sync_algorithm/source/phc.rs | 8 +++---- clock-bound/src/daemon/io.rs | 7 ++++++ clock-bound/src/daemon/io/phc.rs | 23 +++++++++++++++---- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index ea7493e..4ec5887 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -40,7 +40,7 @@ use tokio_util::task::TaskTracker; use rand::{RngCore, rng}; use tokio::sync::watch; use tokio_util::sync::CancellationToken; -use tracing::{info, warn}; +use tracing::{error, info}; /// The maximum dispersion growth every second /// @@ -111,15 +111,12 @@ impl Daemon { // Note: Failure to create a PHC IO component here is considered non-fatal, // we will continue without using the device as an clock sync input. - let phc = if io_front_end.phc_exists() { - Some(clock_sync_algorithm::source::Phc::new( - std::path::PathBuf::new(), //TODO: propagate actual device path. + let phc = io_front_end.phc().map(|io_phc| { + clock_sync_algorithm::source::Phc::new( + io_phc.device_path().to_owned(), MAX_DISPERSION_GROWTH, - )) - } else { - warn!("PHC source failed creation. Continuing without it."); - None - }; + ) + }); let phc_rx = if io_front_end.phc_exists() { Some(phc_rx) } else { @@ -225,13 +222,13 @@ impl Daemon { Ok(()) => (), Err(SendError::Disrupted(clock_parameters)) => { // don't handle_disruption. It will be handled on the next call of tokio::select - tracing::info!( + info!( ?clock_parameters, "Trying to send a value when there was a disruption event. dropping." ); } Err(SendError::BufferClosed(e)) => { - tracing::error!( + error!( ?e, "Trying to send a value when the buffer is closed. Panicking." ); diff --git a/clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs index 84f792e..2525659 100644 --- a/clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs @@ -1,7 +1,5 @@ //! PHC source -use std::path::PathBuf; - use crate::daemon::{ clock_parameters::ClockParameters, clock_sync_algorithm::ff, @@ -14,7 +12,7 @@ use crate::daemon::{ /// Wraps around a PHC feed-forward clock sync algorithm #[derive(Debug, Clone, PartialEq)] pub struct Phc { - device_path: PathBuf, + device_path: String, inner: ff::Phc, } @@ -22,7 +20,7 @@ impl Phc { const POLL_INTERVAL: Duration = Duration::from_millis(500); /// Create a new PHC reference clock source - pub fn new(device_path: PathBuf, max_dispersion: Skew) -> Self { + pub fn new(device_path: String, max_dispersion: Skew) -> Self { Self { device_path, inner: ff::Phc::new(Self::POLL_INTERVAL, max_dispersion), @@ -30,7 +28,7 @@ impl Phc { } /// Feed an event into the PHC clock sync algorithm - #[tracing::instrument(level = "info", skip_all, fields(source = %self.device_path.display()))] + #[tracing::instrument(level = "info", skip_all, fields(source = %self.device_path))] pub fn feed(&mut self, event: event::Phc) -> Option<&ClockParameters> { self.inner.feed(event) } diff --git a/clock-bound/src/daemon/io.rs b/clock-bound/src/daemon/io.rs index dd2a2d9..2fee271 100644 --- a/clock-bound/src/daemon/io.rs +++ b/clock-bound/src/daemon/io.rs @@ -185,6 +185,13 @@ impl SourceIO { info!("Source PHC update complete."); } + pub fn phc(&self) -> Option<&Phc> { + self.phc.as_ref().and_then(|s| match &s.state { + SourceState::Initialized(phc) => Some(phc), + SourceState::Running => None, + }) + } + /// Returns true if a PHC source exists (has been created). pub fn phc_exists(&self) -> bool { self.phc.is_some() diff --git a/clock-bound/src/daemon/io/phc.rs b/clock-bound/src/daemon/io/phc.rs index 6acc74d..20a5c3b 100644 --- a/clock-bound/src/daemon/io/phc.rs +++ b/clock-bound/src/daemon/io/phc.rs @@ -171,6 +171,8 @@ pub struct Phc { reader: PhcReader, /// Reader for the PTP hardware clock (PHC) clock error bound. clock_error_bound_reader: PhcClockErrorBoundReader, + /// Path to the PHC device. + device_path: String, } impl Phc { @@ -184,7 +186,8 @@ impl Phc { ctrl_receiver: mpsc::Receiver, clock_disruption_receiver: watch::Receiver, ) -> Result { - let (phc_reader, phc_clock_error_bound_reader) = Self::autoconfigure_phc_readers().await?; + let (phc_reader, phc_clock_error_bound_reader, device_path) = + Self::autoconfigure_phc_readers().await?; let mut phc_interval = interval(PHC_SOURCE_INTERVAL_DURATION); phc_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); @@ -195,14 +198,22 @@ impl Phc { interval: phc_interval, reader: phc_reader, clock_error_bound_reader: phc_clock_error_bound_reader, + device_path, }) } + /// Getter for `device_path` + pub fn device_path(&self) -> &str { + &self.device_path + } + /// Attempts to autoconfigure readers for the PHC and PHC clock error bound /// by navigating and reading from the filesystem to obtain PTP device details. /// /// If there are no eligible PTP devices found then a `PhcError::PtpDeviceNotFound` /// will be returned in the Result. + /// + /// Returns the Phc Reader, the Phc Clock Error Bound Reader, and the selected ptp device path #[expect( clippy::too_many_lines, reason = "This function is already refactored to call @@ -211,7 +222,7 @@ impl Phc { approaches would make the code harder to follow than the current implementation." )] pub async fn autoconfigure_phc_readers() - -> Result<(PhcReader, PhcClockErrorBoundReader), PhcError> { + -> Result<(PhcReader, PhcClockErrorBoundReader, String), PhcError> { // Get the list of network interfaces. let network_interfaces = match get_network_interfaces().await { Ok(network_interfaces) => network_interfaces, @@ -391,7 +402,9 @@ impl Phc { // There is at least one PTP device available to use. // Use the first PTP device in the vec. if let Some((ptp_device_path, phc_clock_error_bound_sysfs_path)) = - ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec.first() + ptp_device_path_and_phc_clock_error_bound_sysfs_path_vec + .into_iter() + .next() { info!( ?ptp_device_path, @@ -412,14 +425,14 @@ impl Phc { let phc_reader: PhcReader = PhcReader::new(ptp_device_file); let phc_clock_error_bound_reader: PhcClockErrorBoundReader = - PhcClockErrorBoundReader::new(PathBuf::from(phc_clock_error_bound_sysfs_path)); + PhcClockErrorBoundReader::new(PathBuf::from(&phc_clock_error_bound_sysfs_path)); info!( ?ptp_device_path, ?phc_clock_error_bound_sysfs_path, "Done configuring PHC readers" ); - Ok((phc_reader, phc_clock_error_bound_reader)) + Ok((phc_reader, phc_clock_error_bound_reader, ptp_device_path)) } else { Err(PhcError::PtpDeviceNotFound( "No eligible PTP devices found".to_string(), From 1c855a7410f73f17605ecb5756c0531223b56638 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Fri, 21 Nov 2025 10:22:11 -0800 Subject: [PATCH 160/177] [ffi] Simplify the API for C client (#186) This patch changes the API to better reflect the changes introduced in Clockbound 3.0. There is now an `open()` function which uses the default SHM paths, and an `open_with` one, that let user specify paths. The define default paths are kept in the C header file. --- clock-bound-ffi/include/clockbound.h | 8 ++++---- clock-bound-ffi/src/lib.rs | 16 +++++++--------- examples/client/c/src/clockbound_loop_forever.c | 2 +- examples/client/c/src/clockbound_now.c | 4 +--- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/clock-bound-ffi/include/clockbound.h b/clock-bound-ffi/include/clockbound.h index ad01c15..ca24d08 100644 --- a/clock-bound-ffi/include/clockbound.h +++ b/clock-bound-ffi/include/clockbound.h @@ -74,13 +74,13 @@ typedef struct clockbound_now_result { } clockbound_now_result; /* - * Open a new context using the ClockBound daemon-client segment at `clockbound_shm_path` - * and the VMClock segment at the default VMClock segment path. + * Open a new context using the ClockBound daemon-client segment and the VMClock segment + * at their default path. * * Returns a newly-allocated context on success, and NULL on failure. If `err` is * non-null, fills `*err` with error details. */ -clockbound_ctx* clockbound_open(char const* clockbound_shm_path, clockbound_err *err); +clockbound_ctx* clockbound_open(clockbound_err *err); /* * Open a new context using the ClockBound daemon-client segment at `clockbound_shm_path` @@ -89,7 +89,7 @@ clockbound_ctx* clockbound_open(char const* clockbound_shm_path, clockbound_err * Returns a newly-allocated context on success, and NULL on failure. If `err` is * non-null, fills `*err` with error details. */ -clockbound_ctx* clockbound_vmclock_open(char const* clockbound_shm_path, +clockbound_ctx* clockbound_open_with(char const* clockbound_shm_path, char const* vmclock_shm_path, clockbound_err *err); /* diff --git a/clock-bound-ffi/src/lib.rs b/clock-bound-ffi/src/lib.rs index 3c84878..97fb35c 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -6,7 +6,7 @@ #![allow(non_camel_case_types)] use clock_bound::client::{ClockBoundClient, ClockBoundError, ClockBoundErrorKind}; -use clock_bound::shm::{ClockBoundNowResult, ClockStatus}; +use clock_bound::shm::{CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH, ClockBoundNowResult, ClockStatus}; use clock_bound::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use core::ptr; use errno::Errno; @@ -173,16 +173,14 @@ impl From for clockbound_now_result { /// # Safety /// Rely on the caller to pass valid pointers. #[unsafe(no_mangle)] -pub unsafe extern "C" fn clockbound_open( - clockbound_shm_path: *const c_char, - err: *mut clockbound_err, -) -> *mut clockbound_ctx { +pub unsafe extern "C" fn clockbound_open(err: *mut clockbound_err) -> *mut clockbound_ctx { // Safety: Convert the default path to a CString, and then an array of bytes. The conversion of // the default path into a CString is unit tested and safe. + #[expect(clippy::missing_panics_doc, reason = "infallible")] unsafe { - #[expect(clippy::missing_panics_doc, reason = "infallible")] + let clockbound_shm_path = CString::new(CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH).unwrap(); let vmclock_shm_path = CString::new(VMCLOCK_SHM_DEFAULT_PATH).unwrap(); - clockbound_vmclock_open(clockbound_shm_path, vmclock_shm_path.as_ptr(), err) + clockbound_open_with(clockbound_shm_path.as_ptr(), vmclock_shm_path.as_ptr(), err) } } @@ -201,7 +199,7 @@ pub unsafe extern "C" fn clockbound_open( // be to have a more meaningful variant added to `ClockBoundErrorKind` but don't want to change the // C API just yet. #[unsafe(no_mangle)] -pub unsafe extern "C" fn clockbound_vmclock_open( +pub unsafe extern "C" fn clockbound_open_with( clockbound_shm_path: *const c_char, vmclock_shm_path: *const c_char, err: *mut clockbound_err, @@ -551,7 +549,7 @@ mod t_ffi { let mut err: clockbound_err = std::mem::zeroed(); let mut now_result: clockbound_now_result = std::mem::zeroed(); - let ctx = clockbound_vmclock_open( + let ctx = clockbound_open_with( clockbound_path_cstring.as_ptr(), vmclock_path_cstring.as_ptr(), &mut err, diff --git a/examples/client/c/src/clockbound_loop_forever.c b/examples/client/c/src/clockbound_loop_forever.c index fa06d32..2b9b8ba 100644 --- a/examples/client/c/src/clockbound_loop_forever.c +++ b/examples/client/c/src/clockbound_loop_forever.c @@ -86,7 +86,7 @@ int main(int argc, char *argv[]) { int i; // Open clockbound and retrieve a context on success. - ctx = clockbound_vmclock_open(clockbound_shm_path, vmclock_shm_path, &cb_err); + ctx = clockbound_open_with(clockbound_shm_path, vmclock_shm_path, &cb_err); if (ctx == NULL) { print_clockbound_err(&cb_err); return 1; diff --git a/examples/client/c/src/clockbound_now.c b/examples/client/c/src/clockbound_now.c index 012d8ae..f1945b6 100644 --- a/examples/client/c/src/clockbound_now.c +++ b/examples/client/c/src/clockbound_now.c @@ -91,8 +91,6 @@ double duration(struct timespec start, struct timespec end) { } int main(int argc, char *argv[]) { - char const* clockbound_shm_path = CLOCKBOUND_SHM_DEFAULT_PATH; - char const* vmclock_shm_path = VMCLOCK_SHM_DEFAULT_PATH; clockbound_ctx *ctx; clockbound_err cb_err; clockbound_err *err; @@ -102,7 +100,7 @@ int main(int argc, char *argv[]) { int i; // Open clockbound and retrieve a context on success. - ctx = clockbound_vmclock_open(clockbound_shm_path, vmclock_shm_path, &cb_err); + ctx = clockbound_open(&cb_err); if (ctx == NULL) { print_clockbound_err(&cb_err); return 1; From 7ba3f187bc857c2ce81e26fc904f772eaa6b3305 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Fri, 21 Nov 2025 10:49:55 -0800 Subject: [PATCH 161/177] Ridouxj/fix client side ceb (#187) * [client] correct the calculation of the CEB Correct unit conversion when calculating the CEB. --- clock-bound/src/shm.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index b2b385a..37f5f77 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -715,6 +715,7 @@ impl ClockErrorBoundV3 { // updated last. This is computed of a TSC value stored in the snapshot, hence this // duration should never be negative. let duration_tsc = now_tsc.saturating_sub(self.as_of_tsc); + let duration = duration_tsc as f64 * self.period(); let duration_nsec = duration_tsc as f64 * self.period() * NANOS_PER_SECOND; // Convert the TSC timestamp into seconds with a linear projection. @@ -725,11 +726,13 @@ impl ClockErrorBoundV3 { ); // Similarly, need to grow the bound on the clock error since the last update. - // This takes into accounts the fact that the ff-sync period is an estimate (polluted by - // measurement noise), and that the underlying oscillator drifts (possibly at a worse + // + // First, amount for the underlying oscillator drifts (possibly at a worse // possible rate) in between consecutive clock adjustments. - let oscillator_err_nsec = duration_nsec * f64::from(self.max_drift_ppb) / NANOS_PER_SECOND; - let p_estimate_err_nsec = duration_nsec * self.period_err() / NANOS_PER_SECOND; + let oscillator_err_nsec = duration * f64::from(self.max_drift_ppb); + // And take into account the fact that the ff-sync period is an estimate (polluted by + // measurement noise). + let p_estimate_err_nsec = duration_nsec * self.period_err(); let updated_bound = TimeSpec::nanoseconds( (self.bound_nsec as f64 + oscillator_err_nsec + p_estimate_err_nsec) as i64, From d06bce34dde33c59637154318cc7780ac6b7399e Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Fri, 21 Nov 2025 11:31:51 -0800 Subject: [PATCH 162/177] [docs] Update main README (#181) * [docs] Update main README Clarify list of components that make clockbound, update the figure, and put placeholder links to upcoming other docs section. --- README.md | 140 +++++++++++++++++++++++++++----- docs/assets/ClockErrorBound.png | Bin 154212 -> 712764 bytes 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a69fc8a..704392d 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,52 @@ ## Summary -ClockBound allows you to generate and compare bounded timestamps that include accumulated error as reported from the local chronyd process. On every request, ClockBound uses two pieces of information: the current time and the associated absolute error range, which is also known as the clock error bound. This means that the “true” time of a ClockBound timestamp is within a set range. +ClockBound is a solution to synchronize your Operating System clock and determine a consistent order of events across +distributed nodes. -Using ClockBound with a consistent, trusted time service will allow you to compare timestamps to determine order and consistency for events and transactions, independent from the instances’ respective geographic locations. We recommend you use the Amazon Time Sync Service, a highly accurate and reliable time reference that is natively accessible from Amazon EC2 instances, to get the most out of ClockBound on your AWS infrastructure. For more information on the Amazon Time Sync Service and configuration with PTP Hardware Clocks or the NTP endpoints, see the [EC2 User Guide](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/set-time.html). +Most users will be interested in the ClockBound daemon and library as a cohesive system: -## Calculations +- The clockbound daemon that keeps the system clock synchronized by accessing local PTP Hardware Clock (PHC) device or + NTP sources, and offers extra information over a shared memory segment. +- The libclockbound library that offers a clockbound client to timestamp events with rich information, reading from the clockbound daemon. -Clock accuracy is a measure of clock error, typically defined as the offset to UTC. This clock error is the difference between the observed time on the computer and reference time (also known as true time). In NTP architecture, this error can be bounded using three measurements that are defined by the protocol: +Optionally, the clockbound-ff-tester let's you simulate or replay previous runs of the daemons to assess its correctness. + +To determine a consistent order of events across nodes, applications must use the client offered by libclockbound. For +every event of interest the client reports a window of uncertainty and the status of the clockbound daemon: + +- The window of uncertainty (the Clock Error Bound) is defined by two timestamps (earliest, latest) within which true time exists. +- The status is a code indicated whether the daemon is synchronized, free running, etc. + +Using ClockBound with a consistent, trusted time service will allow you to compare timestamps to determine order and +consistency for events and transactions, independent from the nodes respective geographic locations. + +We recommend you use the Amazon Time Sync Service, a highly accurate and reliable time reference that is natively +accessible from Amazon EC2 instances, to get the most out of ClockBound on your AWS infrastructure. For more information +on the Amazon Time Sync Service and configuration with PTP Hardware Clocks or the NTP endpoints, see the +[EC2 User Guide](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/set-time.html). + +Note that the clockbound daemon automatically detects and configure the reference time sources available. + +## Reasons to use ClockBound + +- Distributed System Consistency: Make reliable ordering decisions for events across geographically distributed systems +- Bounded Uncertainty: Get timestamps with error bounds to support your application business logic +- High Performance: Access time information through efficient shared memory without system calls +- Multiple Time Sources: Leverage NTP, PHC, and VMClock sources for robust time synchronization +- AWS Integration: Optimized for use with Amazon Time Sync Service on EC2 instances + +## The Clock Error Bound + +The Clock Error Bound is a measure of clock accuracy. It is defined against reference time (UTC time, an idealized "true +time"), and represents the maximum difference between the local time on your computer and the reference time. + +The ClockBound daemon and client implement the calculation of this bound, which accounts for the clock error that +accumulates between reliable reference clocks and your computer. Reference clocks currently supported include Network +Time Protocol (NTP) servers, a local PTP Hardware Clock (PHC) device, or a VMClock device if available. + +ClockBound implements a calculation that is specific to each type of reference clock. For NTP sources, for example, the +clock error bound relies on three measurements defined by the protocol: - Local offset (the system time): The residual adjustment to be applied to the operating system clock. - Root dispersion: The accumulation of clock drift at each NTP server on the path to the reference clock. @@ -18,31 +57,93 @@ The clock error bound is calculated using the formula below: > Clock Error Bound = |Local Offset| + Root Dispersion + (Root Delay / 2) +The window of uncertainty is defined as the time reported by the clockbound daemon +/- the clock error bound. + +The figure below illustrates how the clock error bound grows in between clock updates. At any point, a clockbound client +can read a pair of timestamps that bound the clock error. + ![Clock Error Bound Image](docs/assets/ClockErrorBound.png) -Figure 1: The Clock Error Bound provides a bound on the worst case offset of a clock with regard to “true time”. +Figure 1: The Clock Error Bound provides a bound on the local clock error with regard to “true time”. + +Note that ClockBound reports on clock accuracy is not a clock offset against the sources it tracks to keep your local +clock synchronized. Instead, it reports on the worse case scenario: the sum of all errors from reference time to your +computer. Therefore, the clock error bound is a general metric to compare timestamps across nodes, independent from +their location or the synchronization protocol they use. -The combination of local offset, root dispersion, and root delay provides us with a clock error bound. For a given reading of the clock C(t) at true time t, this bound makes sure that true time exists within the clock error bound. The clock error bound is used as a proxy for clock accuracy and measures the worst case scenario (see Figure 1). Therefore, clock error bound is the main metric used to determine the accuracy of a NTP service. +## Getting Started -ClockBound uses this clock error bound to return a bounded range of timestamps. This is calculated by adding and subtracting the clock error bound from the timestamp provided by a system's clock. It also contains functionality to check if a given timestamp is in the past or future. This allows users to have consistency when dealing with time sensitive transactions. +To use ClockBound, you must run the ClockBound daemon and make use of a ClockBound client to communicate with +the daemon. ClockBound clients are provided as a Rust crate and C library. -## Usage +### Install the daemon using release binaries -To be able to use ClockBound, you must run the ClockBound daemon and make use of a ClockBound client to communicate with the daemon. ClockBound clients are provided as a C library and as a Rust library. +Download pre-built binaries from the GitHub releases page. The releases include RPM packages for x86_64 Linux and +aarch64 Linux architectures. -[ClockBound Daemon](clock-bound-d/README.md) - A daemon to provide clients with an error bounded timestamp interval. +```bash +# Install RPM package (RHEL/CentOS/Amazon Linux) +sudo rpm -i clockbound-*.rpm -[ClockBound Client FFI](clock-bound-ffi/README.md) - A C client library to communicate with ClockBound daemon. +# Start the daemon +sudo systemctl enable clockbound +sudo systemctl start clockbound +``` -[ClockBound Client Rust](clock-bound-client/README.md) - A Rust client library to communicate with ClockBound daemon. +More information on the daemon can be found here in [ClockBound Daemon](docs/clockbound-daemon.md) - A daemon to provide +clients with an error bounded timestamp interval. -Please see the respective README.md files for information about how to get these set up. +### Use the rust client -### Custom Client +Set up your application's `Cargo.toml` so it looks like this: -The [ClockBound Protocol](docs/PROTOCOL.md) is provided if there is interest in creating a custom client. +```toml +[dependencies] +clock-bound = "3.0.0-alpha.0" +``` + +And then the code: + +```rust +use clock_bound::client::ClockBoundClient; + +let mut client = ClockBoundClient::new()?; +let result = client.now()?; +println!("Time range: {:?} to {:?}", result.earliest, result.latest); +``` + +For more information on the rust client, check out [ClockBound Client Rust](docs/rust-client.md) - A Rust client crate +to create bounded timestamps synchronized by the ClockBound daemon. (Or better yet, we should just move this info into +the docs.rs page) + +#### C Client + +```c +#include + +clockbound_now_result now; +clockbound_err cb_err; +clockbound_ctx *ctx; + +ctx = clockbound_open(&cb_err); + +clockbound_now(ctx, &now, &cb_err); + +printf("Time range: %ld.%09ld to %ld.%09ld\n", + now.earliest.tv_sec, now.earliest.tv_nsec, + now.latest.tv_sec, now.latest.tv_nsec); +``` + +For more information see [ClockBound FFI library](docs/clockbound-ffi.md) - A C client library to create bounded +timestamps synchronized by the ClockBound daemon, and examples provided. + +#### Custom Client + +The [ClockBound Protocol](docs/PROTOCOL.md) is provided so that one can create custom clients. Clients can be created in any programming language that can read from a shared memory segment that is backed by a file. +[ClockBound Protocol](docs/protocol.md) - Reference provided to create a custom client. Clients can be created in any +programming language that can read from a shared memory segment that is backed by a file. ## Security @@ -50,9 +151,10 @@ See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more inform ## License -clock-bound-d is licensed under the [GPL v2 LICENSE](clock-bound-d/LICENSE). - -clock-bound-ffi is licensed under the [Apache 2.0 LICENSE](clock-bound-ffi/LICENSE). +This project is distributed under the following 2 licenses: -clock-bound-client is licensed under the [Apache 2.0 LICENSE](clock-bound-client/LICENSE). +- MIT License +- Apache License 2.0 +These are included as LICENSE.MIT and LICENSE.Apache-2.0 respectively. You may use this software under the terms of any +of these licenses, at your option. diff --git a/docs/assets/ClockErrorBound.png b/docs/assets/ClockErrorBound.png index 842fb1b4ba62fb7faffc234ad72b9609563f831c..e185ba43b8d4023bdeb8252672e5e7f82ee2ffaa 100644 GIT binary patch literal 712764 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

  • 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¼R|L(wbt30qS&zdQvgE!E)2Ra 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

    !nRpK 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 xZSi;0SBMHUt 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/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

    1Pw&Me4?ClevP;PL^S{_ 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 z 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

    Q3^AUF0WC<)MEgBrGwaCok=WZs$9V?M@TdQ3 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

    Ov^g?lKLWD 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 Date: Fri, 21 Nov 2025 16:14:01 -0500 Subject: [PATCH 163/177] [io::phc] handle disruption (#189) PHC now clears the flag from the sender side (necessary for correctness). Also resets the tick so that the PHC actor will immediately read the phc again --- clock-bound/src/daemon/io/phc.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/clock-bound/src/daemon/io/phc.rs b/clock-bound/src/daemon/io/phc.rs index 20a5c3b..acfd1aa 100644 --- a/clock-bound/src/daemon/io/phc.rs +++ b/clock-bound/src/daemon/io/phc.rs @@ -548,7 +548,7 @@ impl Phc { break; } // Clock Disruption logic here - Self::handle_disruption(); + self.handle_disruption(); } ctrl_req = self.ctrl_receiver.recv() => { // Ctrl logic here. @@ -593,9 +593,21 @@ impl Phc { } /// Handles a clock disruption event - fn handle_disruption() { - // No special logic to handle disruption. - debug!("PHC IO source handle_disruption()"); + fn handle_disruption(&mut self) { + let Self { + clock_disruption_receiver: _, + event_sender, + ctrl_receiver: _, + interval, + reader: _, + clock_error_bound_reader: _, + device_path: _, + } = self; + // so interval immediately fires + interval.reset(); + // and clean up disruption from sender side + event_sender.handle_disruption(); + tracing::info!("PHC IO handled disruption"); } } From 0c0a47270b89af2a3921b197b1b3c14d476d9824 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 21 Nov 2025 17:00:06 -0500 Subject: [PATCH 164/177] cargo update (#192) --- Cargo.lock | 354 ++++++++++++++--------------------------------------- 1 file changed, 92 insertions(+), 262 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 904d2ce..4289b00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,26 +2,11 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -67,22 +52,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -112,21 +97,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.22.1" @@ -141,9 +111,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bon" @@ -190,15 +160,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.40" +version = "1.2.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" +checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" dependencies = [ "find-msvc-tools", "shlex", @@ -206,9 +176,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" @@ -226,9 +196,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -236,9 +206,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -260,9 +230,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "clock-bound" @@ -584,9 +554,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fnv" @@ -727,12 +697,6 @@ dependencies = [ "wasip2", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "glob" version = "0.3.3" @@ -741,9 +705,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -799,9 +763,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -820,9 +784,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" dependencies = [ "base64", "bytes", @@ -976,25 +940,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -1013,9 +966,9 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" @@ -1025,9 +978,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", @@ -1053,9 +1006,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.176" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libm" @@ -1132,24 +1085,15 @@ dependencies = [ "autocfg", ] -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - [[package]] name = "mio" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "wasi", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1254,11 +1198,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1316,15 +1260,6 @@ dependencies = [ "libm", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -1333,9 +1268,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "paste" @@ -1464,18 +1399,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -1586,9 +1521,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "relative-path" @@ -1687,12 +1622,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc_version" version = "0.4.1" @@ -1708,7 +1637,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -1848,12 +1777,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1882,9 +1811,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.106" +version = "2.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" dependencies = [ "proc-macro2", "quote", @@ -2043,27 +1972,24 @@ dependencies = [ [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", - "io-uring", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "slab", "socket2", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -2146,7 +2072,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -2291,9 +2217,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "url" @@ -2388,9 +2314,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -2399,25 +2325,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ "cfg-if", "js-sys", @@ -2428,9 +2340,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2438,31 +2350,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ "js-sys", "wasm-bindgen", @@ -2537,31 +2449,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2573,22 +2467,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - [[package]] name = "windows-targets" version = "0.53.5" @@ -2596,106 +2474,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -2748,18 +2578,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "43fa6694ed34d6e57407afbccdeecfa268c470a7d2a5b0cf49ce9fcc345afb90" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" dependencies = [ "proc-macro2", "quote", From 2d71cfcdf652cce8d911f41f15db5c94b5b17638 Mon Sep 17 00:00:00 2001 From: Myles N <95256483+mylescn@users.noreply.github.com> Date: Fri, 21 Nov 2025 17:00:44 -0500 Subject: [PATCH 165/177] Updating IO sources to log disruption events at info level (#190) * Updating IO sources to log disruption events at info level * Modifying vmclock read failures to log at warn! level --- clock-bound/src/daemon/io/ntp_source.rs | 1 + clock-bound/src/daemon/io/phc.rs | 1 + clock-bound/src/daemon/io/vmclock.rs | 6 +++--- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index aecde11..a641d8b 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -147,6 +147,7 @@ impl NTPSource { tracing::error!(?e, "Clock disruption receiver dropped."); break; } + info!("Received clock disruption signal."); self.handle_disruption(); } ctrl_req = self.ctrl_receiver.recv() => { diff --git a/clock-bound/src/daemon/io/phc.rs b/clock-bound/src/daemon/io/phc.rs index acfd1aa..2cda7c1 100644 --- a/clock-bound/src/daemon/io/phc.rs +++ b/clock-bound/src/daemon/io/phc.rs @@ -548,6 +548,7 @@ impl Phc { break; } // Clock Disruption logic here + info!("Received clock disruption signal."); self.handle_disruption(); } ctrl_req = self.ctrl_receiver.recv() => { diff --git a/clock-bound/src/daemon/io/vmclock.rs b/clock-bound/src/daemon/io/vmclock.rs index 2be5c0e..3b4d90a 100644 --- a/clock-bound/src/daemon/io/vmclock.rs +++ b/clock-bound/src/daemon/io/vmclock.rs @@ -6,7 +6,7 @@ use tokio::{ sync::{mpsc, watch}, time::{Duration, Interval, MissedTickBehavior, interval}, }; -use tracing::{debug, info}; +use tracing::{info, warn}; use crate::shm::ShmError; use crate::vmclock::{shm::VMClockShmBody, shm_reader::VMClockShmReader}; @@ -142,10 +142,10 @@ impl VMClock { Ok(s) => { if let ClockDisruptionStatus::Disrupted(disruption_marker) = s { self.clock_disruption_sender.send(ClockDisruptionEvent{ disruption_marker: Some(disruption_marker)}).unwrap(); - debug!(?self, "A clock disruption event occurred and a disruption event was sent."); + info!(?self, "A clock disruption event occurred and a disruption event was sent."); } }, - Err(e) => debug!(?e, "Failed to sample the VMClock.") + Err(e) => warn!(?e, "Failed to sample the VMClock.") } } ctrl_req = self.ctrl_receiver.recv() => { From ec7faba074f4a20e235910525a1fc1e00769e868 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Fri, 21 Nov 2025 17:01:44 -0500 Subject: [PATCH 166/177] add disruption receiver channel to clock state actor (#191) * add disruption receiver channel to clock state actor * fix unit test --------- Co-authored-by: Nick Matthews --- clock-bound/src/daemon.rs | 8 ++-- clock-bound/src/daemon/clock_state.rs | 62 +++++++++++++++++++++------ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs index 4ec5887..897ac21 100644 --- a/clock-bound/src/daemon.rs +++ b/clock-bound/src/daemon.rs @@ -146,6 +146,7 @@ impl Daemon { let (tx, rx) = async_ring_buffer::create(1); let clock_state = ClockState::construct( rx, + clock_disruption_receiver.clone(), clock_state_cancellation_token.clone(), disruption_marker, clock_disruption_support_enabled, @@ -250,17 +251,14 @@ impl Daemon { } = self; let ClockStateHandle { - clock_state, // TODO clear the buffer when the actor pattern comes in + clock_state: _, tx, cancellation_token: _, task_tracker: _, } = clock_state_handle; let val = clock_disruption_receiver.borrow_and_update().clone(); - if let Some(disruption_marker) = val.disruption_marker { - if let Some(clock_state) = clock_state { - clock_state.handle_disruption(disruption_marker); - } + if val.disruption_marker.is_some() { tx.handle_disruption(); clock_sync_algorithm.handle_disruption(); receiver_stream.handle_disruption(); diff --git a/clock-bound/src/daemon/clock_state.rs b/clock-bound/src/daemon/clock_state.rs index 816545c..713e628 100644 --- a/clock-bound/src/daemon/clock_state.rs +++ b/clock-bound/src/daemon/clock_state.rs @@ -3,6 +3,7 @@ pub mod clock_adjust; pub mod clock_state_writer; use std::path::Path; +use tokio::sync::watch; use tokio_util::sync::CancellationToken; use tracing::info; @@ -16,6 +17,7 @@ use crate::daemon::clock_state::clock_adjust::NoopClockAdjuster; use crate::daemon::clock_state::clock_adjust::{ClockAdjust, ClockAdjuster}; use crate::daemon::clock_state::clock_state_writer::ClockStateWriter; use crate::daemon::clock_state::clock_state_writer::{ClockStateWrite, SafeShmWriter}; +use crate::daemon::io::ClockDisruptionEvent; use crate::daemon::io::tsc::ReadTscImpl; use crate::daemon::subscriber::CLOCK_METRICS_TARGET; use crate::daemon::time::clocks::{ClockBound, MonotonicCoarse, MonotonicRaw, RealTime}; @@ -37,6 +39,7 @@ pub(crate) struct ClockState { clock_adjuster: Box, clock_parameters: Option, interval: tokio::time::Interval, + clock_disruption_receiver: watch::Receiver, clock_params_receiver: Receiver, cancellation_token: CancellationToken, } @@ -46,6 +49,7 @@ impl ClockState { clock_state_writer: Box, clock_adjuster: Box, clock_params_receiver: Receiver, + clock_disruption_receiver: watch::Receiver, cancellation_token: CancellationToken, ) -> Self { let interval = tokio::time::interval(tokio::time::Duration::from_millis(100)); @@ -54,6 +58,7 @@ impl ClockState { clock_adjuster, interval, clock_params_receiver, + clock_disruption_receiver, clock_parameters: None, cancellation_token, } @@ -61,6 +66,7 @@ impl ClockState { pub fn construct( clock_params_receiver: Receiver, + clock_disruption_receiver: watch::Receiver, cancellation_token: CancellationToken, disruption_marker: u64, clock_disruption_support_enabled: bool, @@ -104,6 +110,7 @@ impl ClockState { Box::new(clock_state_writer), Box::new(clock_adjuster), clock_params_receiver, + clock_disruption_receiver, cancellation_token, ) } @@ -121,15 +128,19 @@ impl ClockState { self.state_writer.initialize_ceb_v2_shm(); loop { tokio::select! { + biased; // biased to ensure disruption is handled first when this happens + Ok(()) = self.clock_disruption_receiver.changed() => { + self.handle_disruption(); + } + params = self.clock_params_receiver.recv() => { + self.handle_clock_parameters(params.unwrap()); // todo fixme + }, _ = clock_offset_metric_interval.tick() => { self.emit_clock_offsets(); }, now = self.interval.tick() => { self.handle_tick(now); }, - params = self.clock_params_receiver.recv() => { - self.handle_clock_parameters(params.unwrap()); // todo fixme - }, () = self.cancellation_token.cancelled() => { // nothing fancy for now. just exit // TODO: we may want to clean-up SHM here. @@ -235,7 +246,7 @@ impl ClockState { /// Call this function after the system detects a VMClock disruption event. /// /// It will go through and clear the state (like startup). - pub fn handle_disruption(&mut self, new_disruption_marker: u64) { + pub fn handle_disruption(&mut self) { // Use the destructure pattern to get a mutable reference to each item. // // This makes it a compilation error if we add a new field this Self without handling it here @@ -243,21 +254,25 @@ impl ClockState { clock_adjuster, state_writer: clock_state_writer, clock_params_receiver, + clock_disruption_receiver, interval: _, clock_parameters, cancellation_token: _, } = self; - // Update the clock status on the shared memory segments - if let Some(params) = clock_parameters { - clock_state_writer.handle_disruption(params, new_disruption_marker); - } + let val = clock_disruption_receiver.borrow_and_update().clone(); + if let Some(disruption_marker) = val.disruption_marker { + // Update the clock status on the shared memory segments + if let Some(params) = clock_parameters { + clock_state_writer.handle_disruption(params, disruption_marker); + } - *clock_parameters = None; - clock_params_receiver.handle_disruption(); - clock_adjuster.handle_disruption(new_disruption_marker); + *clock_parameters = None; + clock_params_receiver.handle_disruption(); + clock_adjuster.handle_disruption(disruption_marker); - tracing::info!("Handled clock disruption event"); + tracing::info!("Handled clock disruption event"); + } } } @@ -304,10 +319,14 @@ mod tests { .never(); let (_tx, rx) = async_ring_buffer::create(1); + let (_, clock_disruption_receiver) = + watch::channel::(ClockDisruptionEvent::default()); + let mut clock_state = ClockState::new( Box::new(mock_clock_state_writer), Box::new(mock_clock_adjuster), rx, + clock_disruption_receiver, cancellation_token, ); assert_eq!(clock_state.clock_parameters, None); @@ -343,15 +362,23 @@ mod tests { }) .return_const(()); let (_tx, rx) = async_ring_buffer::create(1); + let (clock_disruption_sender, clock_disruption_receiver) = + watch::channel::(ClockDisruptionEvent::default()); let mut clock_state = ClockState::new( Box::new(mock_clock_state_writer), Box::new(mock_clock_adjuster), rx, + clock_disruption_receiver, cancellation_token, ); clock_state.clock_parameters = Some(clock_parameters); - clock_state.handle_disruption(disruption_marker); + clock_disruption_sender + .send(ClockDisruptionEvent { + disruption_marker: Some(disruption_marker), + }) + .unwrap(); + clock_state.handle_disruption(); } #[tokio::test] @@ -373,10 +400,13 @@ mod tests { .never(); let (_tx, rx) = async_ring_buffer::create(1); + let (_, clock_disruption_receiver) = + watch::channel::(ClockDisruptionEvent::default()); let mut clock_state = ClockState::new( Box::new(mock_clock_state_writer), Box::new(mock_clock_adjuster), rx, + clock_disruption_receiver, cancellation_token, ); clock_state.clock_parameters = None; @@ -439,10 +469,13 @@ mod tests { .return_const(()); let (_tx, rx) = async_ring_buffer::create(1); + let (_, clock_disruption_receiver) = + watch::channel::(ClockDisruptionEvent::default()); let mut clock_state = ClockState::new( Box::new(mock_clock_state_writer), Box::new(mock_clock_adjuster), rx, + clock_disruption_receiver, cancellation_token, ); clock_state.clock_parameters = Some(expected_clock_params); @@ -498,6 +531,8 @@ mod tests { ) { let cancellation_token = CancellationToken::new(); let mut mock_clock_adjuster: MockClockAdjust = MockClockAdjust::new(); + let (_, clock_disruption_receiver) = + watch::channel::(ClockDisruptionEvent::default()); mock_clock_adjuster .expect_get_clock_realtime_status() .once() @@ -506,6 +541,7 @@ mod tests { Box::new(MockClockStateWrite::new()), Box::new(mock_clock_adjuster), async_ring_buffer::create(1).1, + clock_disruption_receiver, cancellation_token, ); From 3fdf62ca3d4b7684722d4d021ea4f3ed1a57939e Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 21 Nov 2025 18:21:35 -0500 Subject: [PATCH 167/177] Add clockbound-daemon.md (#193) * Add clockbound-daemon.md --- docs/clockbound-daemon.md | 167 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/clockbound-daemon.md diff --git a/docs/clockbound-daemon.md b/docs/clockbound-daemon.md new file mode 100644 index 0000000..738c361 --- /dev/null +++ b/docs/clockbound-daemon.md @@ -0,0 +1,167 @@ +# ClockBound Daemon + +The ClockBound daemon `clockbound` keeps the system clock synchronized by accessing local PTP Hardware Clock (PHC) device or +NTP sources, and offers extra information over a shared memory segment. + +## Getting Started + +### Installing from the github release RPM + +Download pre-built binaries from the GitHub releases page. The releases include RPM packages for x86_64 Linux and +aarch64 Linux architectures. + +```sh +# Install RPM package (RHEL/CentOS/Amazon Linux) +sudo rpm -i clockbound-*.rpm + +# Start the daemon +sudo systemctl enable clockbound +sudo systemctl start clockbound +``` + +### Building from `crates.io` +`clockbound` can also be installed via `cargo install`. + +```sh +cargo install clock-bound --version "3.0.0-alpha.0" +``` + +From there you can run `clockbound` as a privileged user by calling +```sh +clockbound +``` + +If you would like to set up a systemd service, you can use the service and associated script found in the clock-bound repo: + +https://github.com/aws/clock-bound/blob/main/clock-bound/assets/ + +And you can copy the `clockbound` directory into the path expected +by the service file with: + +```sh +sudo cp ~/.cargo/bin/clockbound /usr/bin/clockbound +``` + +## Prerequisites + +### VMClock + +The VMClock is a vDSO-style clock provided to VM guests. + +During maintenance events, VM guests may experience a clock disruption and it is possible that the underlying clock hardware is changed. +This violates assumptions made by time-synchronization software running on VM guests. The VMClock allows us to address this problem by +providing a mechanism for user-space applications such as ClockBound to be aware of clock disruptions, and take appropriate actions to +ensure correctness for applications that depend on clock accuracy. + +For more details, see the description provided in file [vmclock-abi.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/vmclock-abi.h). + +The VMClock is included by default in: + +- Amazon Linux 2 `kernel-5.10.223-211.872.amzn2` and later. +- Amazon Linux 2023 `kernel-6.1.102-108.177.amzn2023` and later. +- Linux kernel `6.13` and later. + +If you are running a Linux kernel that is mentioned above, you will see VMClock at file path `/dev/vmclock0`, assuming that the cloud provider supports it for your virtual machine. + +Amazon Web Services (AWS) is rolling out VMClock support on EC2, for AWS Graviton, Intel and AMD architectures. + +#### VMClock configuration + +VMClock at path `/dev/vmclock0` may not have the read permissions needed by ClockBound. Run the following command to add read permissions. + +```sh +sudo chmod a+r /dev/vmclock0 +``` + +## PTP Hardware Clock (PHC) Support on EC2 + +### Configuring the PHC on Linux and Chrony. + +Steps to setup the PHC on Amazon Linux and Chrony are provided here: + +- https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configure-ec2-ntp.html + +On non-Amazon Linux distributions, the ENA Linux driver will need to be installed and configured with support for the PHC enabled: + +- https://github.com/amzn/amzn-drivers/tree/master/kernel/linux/ena + +Assuming the ENA driver is enabled and the instance/region combination is supported, you can configure the PHC using the +`configure_phc` script located in `clock-bound/assets`. + +```sh +# either call with the '-c' flag +# which configures the PHC to enable on next boot +./clock-bound/assets/configure_phc -c + +# Or call without any flags to immediately update +# NOTE: that this will reload the ena driver of the instance +./clock-bound/assets/configure_phc +``` + +## Testing clock disruption support + +### Manual testing - VMClock + +ClockBound reads from the VMClock to know that the clock is disrupted. + +If you would like to do testing of ClockBound, simulating various VMClock states, one possibility is to use the vmclock-updater CLI tool. + +See the vmclock-updater [README.md](../test/vmclock-updater/README.md) for more details. + +## Under the Hood + +`clockbound` is a batteries-included clock synchronization daemon that runs with zero configuration, and no required expertise +to set clock synchronization low level parameters. Instead, `clockbound` automatically gathers network devices within the +environment and uses them + +The daemon has 3 major components: + +- IO +- Clock Sync Algorithm, and +- Clock State + +### IO + +The io component reads from NTP Sources, the PTP Hardware Clock (PHC), and the VMCLock. + +#### NTP Sources + +`clockbound` automatically configures to read from the Amazon Time Sync Service and `time.aws.com` via NTP. + +For the Amazon time sync service, traffic is sent over `169.254.169.123:123`. +For more information on the Amazon time sync service, information can be found +[here](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configure-ec2-ntp.html) + +For the `time.aws.com` endpoints, traffic is sent to 2 endpoints over the internet. +`clockbound` includes NTP extension field 0xFEC2 (from experimental, reserved range 0xF000–0xFFFF) that carries minimal daemon metadata +for future extensions. The metadata includes the ClockBound version and an ephemeral random session ID regenerated at each daemon start. + +#### PHC + +`clockbound` automatically detects `PHC` devices on the instance and will synchronize the clock with them when available. + +NOTE: `clockbound` PHC support is limited to the PHC enabled from the ENA driver. For more information on this device, see + +- https://github.com/amzn/amzn-drivers/tree/master/kernel/linux/ena + +#### VMClock + +The VMClock is a vDSO-style clock provided to VM guests. `clockbound` will use `/dev/vmclock0` if it is available. + +### Clock Sync Algorithm + +The Clock Sync Algorithm uses a feed-forward synchronization algorithm to calculate the current time. + +It does this by using hardware oscillator counters to measure the local clock drift. It then compares the local counter values to +reference clock timestamp values to measure the local clock drift. + +It carries certain advantages including: + +- Decoupling IO from the modifications to the system clock +- Decouples the local System Clock from calculating time +- Enables a stronger story for handling disruption events + +### Clock State + +This component takes the outputs from the Clock Sync Algorithm, and writes the time and clock error bound to shared memory regions +(for ClockBound clients) and disciplines the system clock. From 0c21097c8bdeb22a1f44f0341717860f97d1c9f4 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Fri, 21 Nov 2025 18:31:56 -0500 Subject: [PATCH 168/177] Update protocol.md for ClockBound version 3 (#188) * Update protocol.md for ClockBound version 3 --- docs/{PROTOCOL.md => protocol.md} | 173 ++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) rename docs/{PROTOCOL.md => protocol.md} (63%) diff --git a/docs/PROTOCOL.md b/docs/protocol.md similarity index 63% rename from docs/PROTOCOL.md rename to docs/protocol.md index e6bf574..2b99d8a 100644 --- a/docs/PROTOCOL.md +++ b/docs/protocol.md @@ -1,3 +1,176 @@ +# ClockBound Shared Memory Protocol Version 3 + +This protocol version corresponds with ClockBound daemon and client releases 3.0.0 and greater. +The communication between the daemon and client are performed via shared memory. +By default the shared memory segment is mapped to a file at path `/var/run/clockbound/shm1`. + +## Shared Memory Segment Layout + +The byte ordering of data described below is in the native endian of the CPU architecture you are running on. +For example, x86_64 and ARM-based Graviton CPUs use little endian. + +```text +0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | ++ Magic Number + +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 8 bytes +| Segment Size | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Min Version | Max Version | Generation | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 16 bytes +| | ++ As-Of TSC | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 24 bytes +| | ++ | +| | ++ As-Of Timestamp + 32 bytes +| | ++ | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 40 bytes +| | ++ | +| | ++ Void-After Timestamp + 48 bytes +| | ++ | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 56 bytes +| | ++ Period | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 64 bytes +| | ++ Period Error | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 72 bytes +| | ++ Bound | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 80 bytes +| | ++ Disruption Count | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 88 bytes +| Max Drift | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Clock Status | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 96 bytes +| Disruption | Period Shift |PeriodErrShift | Padding | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Padding | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ 104 bytes +``` + +## Description + +**Magic Number**: (u64) + +The signature that identifies a ClockBound shared memory segment. + +`0x41 0x4D 0x5A 0x4E 0x43 0x42 0x02 0x00` + +**Segment Size**: (u32) + +The size of shared memory segment in bytes. + +**Mininum Version**: (u8) + +This field represents the minimum version supported by this layout. + +That is the oldest version that is backward compatible with this layout + +**Maximum Version**: (u8) + +This fields represents the most recent version of this layout. + +For example a Minimum Version of 3 and a Maximum Version of 5 means that clients that know 3, 4, and 5 are supported. + +**Generation**: (u16) + +The generation number is increased during updates to the shared memory content by the ClockBound daemon. +It is set to an odd number before an update and it is set to an even number after an update is completed. +Upon rolling over, the generation is not set to 0 but it is set to 2. + +**As-Of TSC**: (u64) + +the TSC counter value used to create the as_of timestamp. + +This value can be read by either calling `rdtsc` on x86 architectures, or `cntvct_el0` on aarch64 architectures. + +**As-Of Timestamp**: (i64, i64) + +The ClockBound calculated timestamp. This is the time at the `TSC` timestamp + +The two signed 64-bit integers correspond to a libc::timespec's `tv_sec` and `tv_nsec`. + +**Void-After Timestamp**: (i64, i64) + +The time after which the bound of the clock error should not be trusted. + +The two signed 64-bit integers correspond to a libc::timespec's `tv_sec` and `tv_nsec`. + +**Period**: (u64) + +The fractional part of the scaled period of the TSC + +resolution is 1/2^(64 + p_shift), expressed in ppb + +**Period Error**: (u64) + +the fractional part of the period error of the Period estimation + +resolution is 1/2^(64 + p_err_shift), expressed in ppb + +**Bound**: (i64) + +The absolute upper bound on the accuracy with regard to true time at the instant represented by the *As-Of Timestamp*. The units of this value is nanoseconds. + +**Disruption Marker**: (u64) + +The last disruption marker value that the ClockBound daemon has read from the VMClock. + +**Max Drift**: (u32) + +The maximum drift rate of the clock between updates of the synchronization daemon, represented in parts per billion (ppb). + +**Clock Status**: (i32) + +The clock status. Possible values are: + +0 - Unknown: The status of the clock is unknown. + +1 - Synchronized: The clock is kept accurate by the synchronization daemon. + +2 - FreeRunning: The clock is free running and not updated by the synchronization daemon. + +3 - Disrupted: The clock has been disrupted and the accuracy of time cannot be bounded. + +**Clock Disruption Support**: (u8) + +The flag which indicates that clock disruption support is enabled. + +0 - Clock disruption support is not enabled. + +1 - Clock disruption support is enabled. + +**Period Shift**: (u8) + +The value used to scale the period. + +See the above Period section on how to use this field. + +**Period Error Shift**: (u8) + +The value used to scale the period error. + +See the above Period Error section on how to use this field. + # ClockBound Shared Memory Protocol Version 2 This protocol version corresponds with ClockBound daemon and client releases 2.0.0 and greater. From 23b2589f0218da0ee74d3275a3f80e1d36defbac Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Mon, 24 Nov 2025 09:42:41 -0800 Subject: [PATCH 169/177] [docs] Add high level documentation to build C applications (#194) Bring the instructions that were in the C client example directory in this one place. --- docs/clockbound-ffi.md | 114 ++++++++++++++++++++++++++++++++++++ examples/client/c/README.md | 72 +---------------------- 2 files changed, 116 insertions(+), 70 deletions(-) create mode 100644 docs/clockbound-ffi.md diff --git a/docs/clockbound-ffi.md b/docs/clockbound-ffi.md new file mode 100644 index 0000000..824fc0a --- /dev/null +++ b/docs/clockbound-ffi.md @@ -0,0 +1,114 @@ +# ClockBound Foreign Function Interface (FFI) + +This crate implements the FFI for ClockBound. It builds into the libclockbound C library that an +application can use to communicate with the ClockBound daemon. + +## Usage + +clock-bound-ffi requires ClockBound daemon to be running to work. + +See [ClockBound daemon documentation](./clockbound-daemon.md) for installation instructions. + +### Building + +Run the following to build the source code of this crate: + +```sh +cargo build --release +``` + +The build will produce files `libclockbound.a` and `libclockbound.so`. + +```sh +# Copy header file `clockbound.h` to directory `/usr/include/`. +sudo cp clock-bound-ffi/include/clockbound.h /usr/include/ + +# Copy library files `libclockbound.a` and `libclockbound.so` to +# directory `/usr/lib/`. +sudo cp target/release/libclockbound.a target/release/libclockbound.so /usr/lib/ +``` + +# C example programs + +Source code of a runnable C example programs can be found at [../examples/client/c](../examples/client/c). + +This directory contains the source code for example programs in C that show how to obtain error bounded timestamps from +the ClockBound daemon. The example programs make use of the libclockbound C library that is produced by +`clock-bound-ffi`. + +## Prerequisites + +- `gcc` is required for compiling C source code files. Use following command to install it if you don't have it: + + ```sh + sudo yum install gcc + ``` + +- The ClockBound daemon must be running for the example to work. See the [ClockBound daemon documentation](../../clock-bound-d/README.md) + for details on how to get the ClockBound daemon running. + +- `libclockbound` library is required for the example to work, as per instructions above. + +- Update your `ldconfig` cache or specify the directories to be searched for shared libraries in the `LD_LIBRARY_PATH`. + Add following to your shell configuration file. See `.zshrc` example: + + ```sh + vim ~/.zshrc + + # Add following line to the shell configuration file + export LD_LIBRARY_PATH=/usr/lib + + # Use updated shell configuration + source ~/.zshrc + ``` + +## Running + +- Run the following command to compile example C source code files. + + ```sh + # From top-level directory cd into src directory that contains examples in C. + cd examples/client/c/src + + # Compile the C source code files. + gcc clockbound_now.c -o clockbound_now -I/usr/include -L/usr/lib -lclockbound + gcc clockbound_loop_forever.c -o clockbound_loop_forever -I/usr/include -L/usr/lib -lclockbound + ``` + +- Run the following command to run the C example programs. + + ```sh + # Run the `clockbound_now` program. + ./clockbound_now + + # The output should look something like the following: + When clockbound_now was called true time was somewhere within 1709854392.907495824 and 1709854392.908578628 seconds since Jan 1 1970. The clock status is SYNCHRONIZED. + It took 9.428327416 seconds to call clock bound 100000000 times (10606335 tps). + ``` + + ```sh + # Run the `clockbound_loop_forever` program. + ./clockbound_loop_forever + + # The output should look something like the following: + When clockbound_now was called true time was somewhere within 1741187470.034504209 and 1741187470.035652589 seconds since Jan 1 1970. The clock status is SYNCHRONIZED (1). + When clockbound_now was called true time was somewhere within 1741187471.034596805 and 1741187471.035746587 seconds since Jan 1 1970. The clock status is SYNCHRONIZED (1). + When clockbound_now was called true time was somewhere within 1741187472.034682964 and 1741187472.035834148 seconds since Jan 1 1970. The clock status is SYNCHRONIZED (1). + + # To quit the example program, press CTRL-C. + ``` + +- Clean up + + ```sh + rm ./clockbound_now + rm ./clockbound_loop_forever + ``` + +## Security + +See [CONTRIBUTING](../CONTRIBUTING.md#security-issue-notifications) for more information. + +## License + +Licensed under the [Apache 2.0](LICENSE) license. diff --git a/examples/client/c/README.md b/examples/client/c/README.md index a72c85d..cbd862a 100644 --- a/examples/client/c/README.md +++ b/examples/client/c/README.md @@ -1,72 +1,4 @@ # C example programs -This directory contains the source code for example programs in C that show how to obtain error bounded timestamps from the ClockBound daemon. The example programs make use of the libclockbound C library that is produced by `clock-bound-ffi`. - -## Prerequisites - -- `gcc` is required for compiling C source code files. Use following command to install it if you don't have it: - - ```sh - sudo yum install gcc - ``` - -- The ClockBound daemon must be running for the example to work. - See the [ClockBound daemon documentation](../../clock-bound-d/README.md) for - details on how to get the ClockBound daemon running. - -- `libclockbound` library is required for the example to work. See the [ClockBound FFI documentation](../../clock-bound-ffi/README.md#building) for details on how to build the `libclockbound` library. - -- Specify the directories to be searched for shared libraries in the `LD_LIBRARY_PATH`. Add following to your shell configuration file. See `.zshrc` example: - - ```sh - vim ~/.zshrc - - # Add following line to the shell configuration file - export LD_LIBRARY_PATH=/usr/lib - - # Use updated shell configuration - source ~/.zshrc - ``` - -## Running - -- Run the following command to compile example C source code files. - - ```sh - # From top-level directory cd into src directory that contains examples in C. - cd examples/client/c/src - - # Compile the C source code files. - gcc clockbound_now.c -o clockbound_now -I/usr/include -L/usr/lib -lclockbound - gcc clockbound_loop_forever.c -o clockbound_loop_forever -I/usr/include -L/usr/lib -lclockbound - ``` - -- Run the following command to run the C example programs. - - ```sh - # Run the `clockbound_now` program. - ./clockbound_now - - # The output should look something like the following: - When clockbound_now was called true time was somewhere within 1709854392.907495824 and 1709854392.908578628 seconds since Jan 1 1970. The clock status is SYNCHRONIZED. - It took 9.428327416 seconds to call clock bound 100000000 times (10606335 tps). - ``` - - ```sh - # Run the `clockbound_loop_forever` program. - ./clockbound_loop_forever - - # The output should look something like the following: - When clockbound_now was called true time was somewhere within 1741187470.034504209 and 1741187470.035652589 seconds since Jan 1 1970. The clock status is SYNCHRONIZED (1). - When clockbound_now was called true time was somewhere within 1741187471.034596805 and 1741187471.035746587 seconds since Jan 1 1970. The clock status is SYNCHRONIZED (1). - When clockbound_now was called true time was somewhere within 1741187472.034682964 and 1741187472.035834148 seconds since Jan 1 1970. The clock status is SYNCHRONIZED (1). - - # To quit the example program, press CTRL-C. - ``` - -- Clean up - - ```sh - rm ./clockbound_now - rm ./clockbound_loop_forever - ``` +See [ClockBound Foreign Function Interface (FFI)](../../../docs/clockbound-ffi.md) +for instructions to build the libclockbound library and the C examples in this directory. From 13efc43b0355de99d6130e26125e43993c89ade6 Mon Sep 17 00:00:00 2001 From: Shamik Chakraborty Date: Mon, 24 Nov 2025 14:05:17 -0500 Subject: [PATCH 170/177] enable publishing clock-bound and clock-bound-ffi (#195) --- clock-bound-ff-tester/Cargo.toml | 2 +- clock-bound-ffi/Cargo.toml | 2 +- clock-bound/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/clock-bound-ff-tester/Cargo.toml b/clock-bound-ff-tester/Cargo.toml index e9c8c26..acf3fd2 100644 --- a/clock-bound-ff-tester/Cargo.toml +++ b/clock-bound-ff-tester/Cargo.toml @@ -8,7 +8,7 @@ categories.workspace = true edition = "2024" exclude.workspace = true keywords.workspace = true -publish.workspace = true +publish = false repository.workspace = true version.workspace = true diff --git a/clock-bound-ffi/Cargo.toml b/clock-bound-ffi/Cargo.toml index a0d5fce..67a1f7b 100644 --- a/clock-bound-ffi/Cargo.toml +++ b/clock-bound-ffi/Cargo.toml @@ -8,7 +8,7 @@ categories.workspace = true edition.workspace = true exclude.workspace = true keywords.workspace = true -publish.workspace = true +publish = true repository.workspace = true version.workspace = true diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index 8ec19bf..c6194cd 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -8,7 +8,7 @@ categories.workspace = true edition = "2024" exclude.workspace = true keywords.workspace = true -publish.workspace = true +publish = true repository.workspace = true version.workspace = true From af729fdf99e3fd0157dc25bd3316712c6f15c20b Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Fri, 28 Nov 2025 13:34:35 -0800 Subject: [PATCH 171/177] [client] Correct logic to handle stale shared memory segment (#196) * [client] Correct logic to handle stale shared memory segment This patch aligns the client configuration to match the ClockBound daemon definition of "free running". It is possible the daemon is stopped, after it succesfully wrote to the shared memory segment. In such case, the client is responsible to report a FREE_RUNNING, and subsequently UNKNOWN status as the data in the shared memory segment ages and then becomes stale. --- clock-bound/src/shm.rs | 58 ++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 37f5f77..0aaf5cd 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -31,7 +31,7 @@ pub const CLOCKBOUND_SHM_DEFAULT_PATH_V0: &str = "/var/run/clockbound/shm0"; pub const CLOCKBOUND_SHM_DEFAULT_PATH_V1: &str = "/var/run/clockbound/shm1"; pub const CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH: &str = CLOCKBOUND_SHM_DEFAULT_PATH_V1; -const CLOCKBOUND_RESTART_GRACE_PERIOD: TimeSpec = TimeSpec::new(5, 0); +const FREE_RUNNING_GRACE_PERIOD: TimeSpec = TimeSpec::new(60, 0); const NANOS_PER_SECOND: f64 = 1_000_000_000.0; /// Convenience macro to build a `ShmError::SyscallError` with extra info from errno and custom @@ -460,16 +460,17 @@ impl ClockErrorBoundV2 { // the shared memory segment in a while, the status written to the shared memory // segment may not be reliable anymore. ClockStatus::Synchronized | ClockStatus::FreeRunning => { - if mono < self.as_of + CLOCKBOUND_RESTART_GRACE_PERIOD { - // Allow for a restart of the daemon, for a short period of time, the status is - // trusted to be correct. - self.clock_status - } else if mono < self.void_after { - // Beyond the grace period, for a free running status. + if mono > self.void_after { + // The last update is old and beyond the horizon defined by the daemon, no + // guarantee is provided anymore, hence report Unknown status. + ClockStatus::Unknown + } else if mono > self.as_of + FREE_RUNNING_GRACE_PERIOD { + // The last update is too old to be trusted to be synchronized, reports Free + // Running status. ClockStatus::FreeRunning } else { - // If beyond void_after, no guarantee is provided anymore. - ClockStatus::Unknown + // The last update is recent enough, hence report it + self.clock_status } } }; @@ -748,7 +749,6 @@ impl ClockErrorBoundV3 { // caller that guarantees are gone. We could return an Err here, but choosing to leverage // ClockStatus instead, and putting the responsibility on the caller to check the clock // status value being returned. - let duration = TimeSpec::nanoseconds(duration_nsec as i64); let clock_status = match self.clock_status { // If the status in the shared memory segment is Unknown or Disrupted, returns that // status. @@ -759,16 +759,17 @@ impl ClockErrorBoundV3 { // the shared memory segment in a while, the status written to the shared memory // segment may not be reliable anymore. ClockStatus::Synchronized | ClockStatus::FreeRunning => { - if duration < CLOCKBOUND_RESTART_GRACE_PERIOD { - // Allow for a restart of the daemon, for a short period of time, the status is - // trusted to be correct. - self.clock_status - } else if now < self.void_after { - // Beyond the grace period, for a free running status. + if now > self.void_after { + // The last update is old and beyond the horizon defined by the daemon, no + // guarantee is provided anymore, hence report Unknown status. + ClockStatus::Unknown + } else if now > self.as_of + FREE_RUNNING_GRACE_PERIOD { + // The last update is too old to be trusted to be synchronized, reports Free + // Running status. ClockStatus::FreeRunning } else { - // If beyond void_after, no guarantee is provided anymore. - ClockStatus::Unknown + // The last update is recent enough, hence report it + self.clock_status } } }; @@ -914,13 +915,13 @@ mod t_lib { assert_eq!(clock_status, ClockStatus::Synchronized); } - /// Assert the clock status is FreeRunning if the ClockErrorBound data is passed the grace - /// period + /// Assert the clock status is FreeRunning if the ClockErrorBound data is passed the free + /// running grace period, simulating behavior of the daemon has died. #[test] fn compute_bound_force_free_running_status() { let ceb = clockbound_v2!((0, 0), (100, 0)); - let real = TimeSpec::new(8, 0); - let mono = TimeSpec::new(8, 0); + let real = TimeSpec::new(61, 0); + let mono = TimeSpec::new(61, 0); let ClockBoundNowResult { earliest, @@ -930,12 +931,13 @@ mod t_lib { .compute_bound_at(real, mono) .expect("Failed to compute bound"); - // 8 seconds have passed since the bound was snapshot, hence 8 microsec of drift on top of - // the default 10 microsec put in the ClockBoundError data - assert_eq!(earliest.tv_sec(), 7); - assert_eq!(earliest.tv_nsec(), 1_000_000_000 - 18_000); - assert_eq!(latest.tv_sec(), 8); - assert_eq!(latest.tv_nsec(), 18_000); + // 61 seconds have passed since the bound was snapshot, hence 61 microsec of drift have + // accumulated at max_drift_ppb on top of the default 10 microsec put in the + // ClockBoundError data. + assert_eq!(earliest.tv_sec(), 60); + assert_eq!(earliest.tv_nsec(), 1_000_000_000 - 71_000); + assert_eq!(latest.tv_sec(), 61); + assert_eq!(latest.tv_nsec(), 71_000); assert_eq!(clock_status, ClockStatus::FreeRunning); } From cb2a2d03d61aa379e1eb5f8b6eb626005749b67f Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Fri, 28 Nov 2025 13:37:54 -0800 Subject: [PATCH 172/177] [shm] redefine ShmError to pass more informative message (#198) This patch changes the internal representation of the ShmError enum such that: - extra information is passed with every type of error - messages are String instead of CString This change is meant to provide the user of the clockbound daemon or a Rust/C client with more informative message when something is amiss. --- clock-bound/src/client.rs | 76 ++++++++++--------- clock-bound/src/shm.rs | 74 +++++++++++-------- clock-bound/src/shm/common.rs | 2 +- clock-bound/src/shm/reader.rs | 36 ++++++--- clock-bound/src/shm/shm_header.rs | 25 +++++-- clock-bound/src/shm/writer.rs | 12 ++- clock-bound/src/vmclock/shm.rs | 21 ++++-- clock-bound/src/vmclock/shm_reader.rs | 101 ++++++++++++-------------- clock-bound/src/vmclock/shm_writer.rs | 5 +- 9 files changed, 205 insertions(+), 147 deletions(-) diff --git a/clock-bound/src/client.rs b/clock-bound/src/client.rs index b91a5b3..7196537 100644 --- a/clock-bound/src/client.rs +++ b/clock-bound/src/client.rs @@ -141,10 +141,14 @@ impl ClockBoundSHM { fn new(clockbound_shm_path: &str) -> Result { // Fail early if the provided shared memory path does not exist. if !Path::new(clockbound_shm_path).exists() { - let mut error = ClockBoundError::from(ShmError::SegmentNotInitialized); - error.detail = format!( + let detail = format!( "Path to clockbound daemon shared memory segment does not exist: {clockbound_shm_path}" ); + let error = ClockBoundError { + kind: ClockBoundErrorKind::SegmentNotInitialized, + detail, + errno: Errno(0), + }; return Err(error); } @@ -185,10 +189,14 @@ impl VMClockSHM { if clock_disruption_support_enabled { // Fail early if the provided shared memory path does not exist. if !Path::new(vmclock_shm_path).exists() { - let mut error = ClockBoundError::from(ShmError::SegmentNotInitialized); - error.detail = format!( + let detail = format!( "Path to VMClock device shared memory segment does not exist: {vmclock_shm_path}" ); + let error = ClockBoundError { + kind: ClockBoundErrorKind::SegmentNotInitialized, + detail, + errno: Errno(0), + }; return Err(error); } vmclock_shm_reader = Some(VMClockShmReader::new(vmclock_shm_path)?); @@ -227,25 +235,22 @@ pub struct ClockBoundError { impl From for ClockBoundError { fn from(value: ShmError) -> Self { - let kind = match value { - ShmError::SyscallError(_, _) => ClockBoundErrorKind::Syscall, - ShmError::SegmentNotInitialized => ClockBoundErrorKind::SegmentNotInitialized, - ShmError::SegmentMalformed => ClockBoundErrorKind::SegmentMalformed, - ShmError::CausalityBreach => ClockBoundErrorKind::CausalityBreach, - ShmError::SegmentVersionNotSupported => ClockBoundErrorKind::SegmentVersionNotSupported, - }; - - let errno = match value { - ShmError::SyscallError(errno, _) => errno, - _ => Errno(0), - }; - - let detail = match value { - ShmError::SyscallError(_, detail) => detail - .to_str() - .expect("Failed to convert CStr to str") - .to_owned(), - _ => String::from("No detail available"), + let (kind, detail, errno) = match value { + ShmError::SyscallError(detail, errno) => (ClockBoundErrorKind::Syscall, detail, errno), + ShmError::SegmentNotInitialized(detail) => { + (ClockBoundErrorKind::SegmentNotInitialized, detail, Errno(0)) + } + ShmError::SegmentMalformed(detail) => { + (ClockBoundErrorKind::SegmentMalformed, detail, Errno(0)) + } + ShmError::CausalityBreach(detail) => { + (ClockBoundErrorKind::CausalityBreach, detail, Errno(0)) + } + ShmError::SegmentVersionNotSupported(detail) => ( + ClockBoundErrorKind::SegmentVersionNotSupported, + detail, + Errno(0), + ), }; ClockBoundError { @@ -277,7 +282,6 @@ mod lib_tests { use byteorder::{NativeEndian, WriteBytesExt}; use nix::sys::time::TimeSpec; - use std::ffi::CStr; use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::Path; @@ -748,21 +752,19 @@ mod lib_tests { #[test] fn test_shmerror_clockbounderror_conversion_syscallerror() { let errno = Errno(1); - let detail: &CStr = - ::std::ffi::CStr::from_bytes_with_nul("test_detail\0".as_bytes()).unwrap(); - let detail_str_slice: &str = detail.to_str().unwrap(); - let detail_string: String = detail_str_slice.to_owned(); - let shm_error = ShmError::SyscallError(errno, detail); + let detail = String::from("test detail"); + let shm_error = ShmError::SyscallError(detail.clone(), errno); // Perform the conversion. let clockbounderror = ClockBoundError::from(shm_error); assert_eq!(ClockBoundErrorKind::Syscall, clockbounderror.kind); assert_eq!(errno, clockbounderror.errno); - assert_eq!(detail_string, clockbounderror.detail); + assert_eq!(detail, clockbounderror.detail); } #[test] fn test_shmerror_clockbounderror_conversion_segmentnotinitialized() { - let shm_error = ShmError::SegmentNotInitialized; + let detail = String::from("test detail"); + let shm_error = ShmError::SegmentNotInitialized(detail.clone()); // Perform the conversion. let clockbounderror = ClockBoundError::from(shm_error); assert_eq!( @@ -770,26 +772,28 @@ mod lib_tests { clockbounderror.kind ); assert_eq!(Errno(0), clockbounderror.errno); - assert_eq!(String::from("No detail available"), clockbounderror.detail); + assert_eq!(detail, clockbounderror.detail); } #[test] fn test_shmerror_clockbounderror_conversion_segmentmalformed() { - let shm_error = ShmError::SegmentMalformed; + let detail = String::from("test detail"); + let shm_error = ShmError::SegmentMalformed(detail.clone()); // Perform the conversion. let clockbounderror = ClockBoundError::from(shm_error); assert_eq!(ClockBoundErrorKind::SegmentMalformed, clockbounderror.kind); assert_eq!(Errno(0), clockbounderror.errno); - assert_eq!(String::from("No detail available"), clockbounderror.detail); + assert_eq!(detail, clockbounderror.detail); } #[test] fn test_shmerror_clockbounderror_conversion_causalitybreach() { - let shm_error = ShmError::CausalityBreach; + let detail = String::from("test detail"); + let shm_error = ShmError::CausalityBreach(detail.clone()); // Perform the conversion. let clockbounderror = ClockBoundError::from(shm_error); assert_eq!(ClockBoundErrorKind::CausalityBreach, clockbounderror.kind); assert_eq!(Errno(0), clockbounderror.errno); - assert_eq!(String::from("No detail available"), clockbounderror.detail); + assert_eq!(detail, clockbounderror.detail); } } diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs index 0aaf5cd..bd03486 100644 --- a/clock-bound/src/shm.rs +++ b/clock-bound/src/shm.rs @@ -24,7 +24,6 @@ use bon::Builder; use errno::Errno; use nix::sys::time::{TimeSpec, TimeValLike}; use std::error::Error; -use std::ffi::CStr; use std::fmt; pub const CLOCKBOUND_SHM_DEFAULT_PATH_V0: &str = "/var/run/clockbound/shm0"; @@ -38,11 +37,8 @@ const NANOS_PER_SECOND: f64 = 1_000_000_000.0; /// origin information. #[macro_export] macro_rules! syserror { - ($origin:expr) => { - Err($crate::shm::ShmError::SyscallError( - ::errno::errno(), - ::std::ffi::CStr::from_bytes_with_nul(concat!($origin, "\0").as_bytes()).unwrap(), - )) + ($msg:expr) => { + Err($crate::shm::ShmError::SyscallError($msg, ::errno::errno())) }; } @@ -212,7 +208,9 @@ impl TryFrom for ClockErrorBoundLayoutVersion { match value { 2 => Ok(ClockErrorBoundLayoutVersion::V2), 3 => Ok(ClockErrorBoundLayoutVersion::V3), - _ => Err(ShmError::SegmentVersionNotSupported), + _ => Err(ShmError::SegmentVersionNotSupported(format!( + "Found version {value}", + ))), } } } @@ -223,7 +221,9 @@ impl TryFrom for ClockErrorBoundLayoutVersion { match value { 2 => Ok(ClockErrorBoundLayoutVersion::V2), 3 => Ok(ClockErrorBoundLayoutVersion::V3), - _ => Err(ShmError::SegmentVersionNotSupported), + _ => Err(ShmError::SegmentVersionNotSupported(format!( + "Found version {value}", + ))), } } } @@ -247,45 +247,52 @@ pub struct ClockBoundNowResult { /// Error condition returned by all low-level ClockBound APIs. /// -// FIXME: the `detail` static CString referenced on the Syscall variant can be changed. The C -// caller now pass memory over the FFI interface. -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ShmError { /// A system call failed. /// Variant includes the Errno struct with error details, and an indication on the origin of /// the system call that error'ed. - SyscallError(Errno, &'static CStr), + SyscallError(String, Errno), /// The shared memory segment is not initialized. - SegmentNotInitialized, + SegmentNotInitialized(String), /// The shared memory segment is initialized but malformed. - SegmentMalformed, + SegmentMalformed(String), /// Failed causality check when comparing timestamps. - CausalityBreach, + CausalityBreach(String), /// The shared memory segment version is not supported. - SegmentVersionNotSupported, + SegmentVersionNotSupported(String), } impl fmt::Display for ShmError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ShmError::SyscallError(errno, c_str) => { - write!(f, "Errno: {errno:?} Details: {c_str:?}") + ShmError::SyscallError(msg, errno) => { + write!(f, "Errno: {errno:?} Details: {msg}") } - ShmError::SegmentNotInitialized => { - write!(f, "The shared memory segment is not initialized.") + ShmError::SegmentNotInitialized(msg) => { + write!(f, "The shared memory segment is not initialized [{msg}].") } - ShmError::SegmentMalformed => { - write!(f, "The shared memory segment is initialized but malformed.") + ShmError::SegmentMalformed(msg) => { + write!( + f, + "The shared memory segment is initialized but malformed [{msg}]." + ) } - ShmError::CausalityBreach => { - write!(f, "Failed causality check when comparing timestamps.") + ShmError::CausalityBreach(msg) => { + write!( + f, + "Failed causality check when comparing timestamps [{msg}]." + ) } - ShmError::SegmentVersionNotSupported => { - write!(f, "The shared memory segment version is not supported.") + ShmError::SegmentVersionNotSupported(msg) => { + write!( + f, + "The shared memory segment version is not supported [{msg}]." + ) } } } @@ -439,7 +446,10 @@ impl ClockErrorBoundV2 { // causality between mono and as_of should be enforced. // - a extremely high value of the `max_drift_ppb` is a sign of something going wrong if self.max_drift_ppb >= 1_000_000_000 { - return Err(ShmError::SegmentMalformed); + return Err(ShmError::SegmentMalformed(format!( + "max_drift_ppb too large [{}]", + self.max_drift_ppb, + ))); } // If the ClockErrorBound data has not been updated "recently", the status of the clock @@ -518,7 +528,10 @@ impl ClockErrorBoundV2 { TimeSpec::new(0, 0) } else { // Causality is breached. - return Err(ShmError::CausalityBreach); + return Err(ShmError::CausalityBreach(format!( + "as_of ({:?}) more recent than {:?}", + self.as_of, mono + ))); }; // Inflate the bound on clock error with the maximum drift the clock may be experiencing @@ -709,7 +722,10 @@ impl ClockErrorBoundV3 { // causality between mono and as_of should be enforced. // - a extremely high value of the `max_drift_ppb` is a sign of something going wrong if self.max_drift_ppb >= 1_000_000_000 { - return Err(ShmError::SegmentMalformed); + return Err(ShmError::SegmentMalformed(format!( + "max_drift_ppb too large: [{}]", + self.max_drift_ppb, + ))); } // Compute the number of TSC cycles between now and the instant the ff-sync clock was diff --git a/clock-bound/src/shm/common.rs b/clock-bound/src/shm/common.rs index 54c9ba6..d956f5a 100644 --- a/clock-bound/src/shm/common.rs +++ b/clock-bound/src/shm/common.rs @@ -20,7 +20,7 @@ pub const CLOCK_MONOTONIC: ClockId = ClockId::CLOCK_MONOTONIC_COARSE; pub fn clock_gettime_safe(clock_id: ClockId) -> Result { match clock_gettime(clock_id) { Ok(ts) => Ok(ts), - _ => syserror!("clock_gettime"), + _ => syserror!(String::from("clock_gettime failed")), } } diff --git a/clock-bound/src/shm/reader.rs b/clock-bound/src/shm/reader.rs index a0e2793..1fe96aa 100644 --- a/clock-bound/src/shm/reader.rs +++ b/clock-bound/src/shm/reader.rs @@ -29,7 +29,7 @@ impl FdGuard { // SAFETY: `path` is a valid C string. let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY) }; if fd < 0 { - return syserror!(concat!("open")); + return syserror!(format!("Faild to open file at {:?}", path)); } Ok(FdGuard(fd)) @@ -86,7 +86,7 @@ impl MmapGuard { }; if segment == libc::MAP_FAILED { - return syserror!("mmap SHM segment"); + return syserror!(String::from("Failed to mmap the SHM segment")); } Ok(MmapGuard { segment, segsize }) @@ -178,7 +178,10 @@ impl ShmReader { if CLOCKBOUND_SHM_LATEST_VERSION < min_version || CLOCKBOUND_SHM_LATEST_VERSION > max_version { - return Err(ShmError::SegmentVersionNotSupported); + let msg = format!( + "Clockbound shared memory segment supports versions {min_version} to {max_version} which does not include this reader version {CLOCKBOUND_SHM_LATEST_VERSION}", + ); + return Err(ShmError::SegmentVersionNotSupported(msg)); } let shm_version = ClockErrorBoundLayoutVersion::try_from(CLOCKBOUND_SHM_LATEST_VERSION)?; @@ -274,7 +277,12 @@ impl ShmReader { }; if mmap_guard.segsize < size_of::() + layout_size { - return Err(ShmError::SegmentMalformed); + let msg = format!( + "Clockbound segment size is smaller than expected [{} < {}].", + mmap_guard.segsize, + size_of::() + layout_size + ); + return Err(ShmError::SegmentMalformed(msg)); } // SAFETY: segment size has been checked to ensure `cursor` move leads to a valid cast @@ -321,7 +329,10 @@ impl ShmReader { if CLOCKBOUND_SHM_LATEST_VERSION < min_version || CLOCKBOUND_SHM_LATEST_VERSION < max_version { - return Err(ShmError::SegmentVersionNotSupported); + let msg = format!( + "Clockbound shared memory segment supports versions {min_version} to {max_version} which does not include this reader version {CLOCKBOUND_SHM_LATEST_VERSION}", + ); + return Err(ShmError::SegmentVersionNotSupported(msg)); } // Atomically read the current generation in the shared memory segment @@ -393,7 +404,9 @@ impl ShmReader { } // Attempts to read the snapshot have failed. - Err(ShmError::SegmentNotInitialized) + Err(ShmError::SegmentNotInitialized(String::from( + "Failed to read the SHM segment after all attempts", + ))) } } @@ -558,8 +571,10 @@ mod t_reader { // Assert that creating a reader on an unsupported shared memory segment version // returns Err(ShmError::SegmentVersionNotSupported). - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), ShmError::SegmentVersionNotSupported); + assert!(matches!( + result.unwrap_err(), + ShmError::SegmentVersionNotSupported(_) + )); } /// Assert that creating a reader and taking a snapshot when the @@ -618,7 +633,8 @@ mod t_reader { // Assert that taking a snapshot of an unsupported shared memory segment version // returns Err(ShmError::SegmentVersionNotSupported). let result = reader.snapshot(); - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), ShmError::SegmentVersionNotSupported); + assert!( + matches!(result.unwrap_err(), ShmError::SegmentVersionNotSupported(msg) if msg.starts_with("Clockbound shared memory segment supports versions")) + ); } } diff --git a/clock-bound/src/shm/shm_header.rs b/clock-bound/src/shm/shm_header.rs index aa42732..1fc624f 100644 --- a/clock-bound/src/shm/shm_header.rs +++ b/clock-bound/src/shm/shm_header.rs @@ -2,6 +2,7 @@ use std::mem::{MaybeUninit, size_of}; use std::sync::atomic; use crate::{shm::ShmError, syserror}; +use tracing::error; /// The magic number that identifies a `ClockErrorBound` shared memory segment. pub const SHM_MAGIC: [u32; 2] = [0x414D_5A4E, 0x4342_0200]; @@ -46,9 +47,13 @@ impl ShmHeader { size_of::(), ) } { - ret if ret < 0 => return syserror!("read SHM segment"), + ret if ret < 0 => return syserror!(String::from("Failed to read SHM segment")), ret if (ret as usize) < size_of::() => { - return Err(ShmError::SegmentNotInitialized); + return Err(ShmError::SegmentNotInitialized(format!( + "SHM segment too short [{} < {}]", + ret, + size_of::(), + ))); } _ => (), } @@ -99,19 +104,27 @@ impl ShmHeader { /// Check whether a `ShmHeader` is valid fn is_valid(&self) -> Result<(), ShmError> { if !self.matches_magic(&SHM_MAGIC) { - return Err(ShmError::SegmentNotInitialized); + let msg = String::from("ClockBound SHM header does not have a matching magic number."); + error!(msg); + return Err(ShmError::SegmentMalformed(msg)); } if !self.has_valid_version() { - return Err(ShmError::SegmentVersionNotSupported); + let msg = String::from("ClockBound SHM header does not have a valid version number."); + error!(msg); + return Err(ShmError::SegmentVersionNotSupported(msg)); } if !self.is_initialized() { - return Err(ShmError::SegmentNotInitialized); + let msg = String::from("ClockBound SHM header is not initialized"); + error!(msg); + return Err(ShmError::SegmentNotInitialized(msg)); } if !self.is_well_formed() { - return Err(ShmError::SegmentMalformed); + let msg = String::from("ClockBound SHM segment is not well formed."); + error!(msg); + return Err(ShmError::SegmentMalformed(msg)); } Ok(()) } diff --git a/clock-bound/src/shm/writer.rs b/clock-bound/src/shm/writer.rs index 3d9050d..9f71843 100644 --- a/clock-bound/src/shm/writer.rs +++ b/clock-bound/src/shm/writer.rs @@ -135,15 +135,21 @@ impl ShmWriter { /// /// The segment is usable if it can be opened at `path` and it can be read by a `ShmReader`. fn is_usable_segment(path: &Path) -> Result<(), ShmError> { - let path_cstring = CString::new(path.as_os_str().as_bytes()) - .map_err(|_| ShmError::SegmentNotInitialized)?; + let path_cstring = CString::new(path.as_os_str().as_bytes()).map_err(|_| { + ShmError::SegmentNotInitialized(format!( + "SHM segment path is not a valid C string [{}]", + path.to_string_lossy() + )) + })?; match ShmReader::new_with_max_version_unchecked(path_cstring.as_c_str()) { Ok((_reader, max_version)) => { if max_version == CLOCKBOUND_SHM_LATEST_VERSION { Ok(()) } else { - Err(ShmError::SegmentVersionNotSupported) + Err(ShmError::SegmentVersionNotSupported(format!( + "Existing SHM segment maximum version ({max_version}) does not match daemon version ({CLOCKBOUND_SHM_LATEST_VERSION})" + ))) } } Err(err) => Err(err), diff --git a/clock-bound/src/vmclock/shm.rs b/clock-bound/src/vmclock/shm.rs index 1f9f146..ef0bd6a 100644 --- a/clock-bound/src/vmclock/shm.rs +++ b/clock-bound/src/vmclock/shm.rs @@ -66,7 +66,11 @@ impl VMClockShmHeader { #[expect(clippy::missing_panics_doc, reason = "slices appropriately sized")] pub fn read(vector: &Vec) -> Result { if vector.len() < size_of::() { - return syserror!("Insufficient bytes to create a VMClockShmHeader."); + return syserror!(format!( + "VMClockShmHeader is shorter than expected size [{} < {}].", + vector.len(), + size_of::() + )); } let slice = vector.as_slice(); @@ -115,18 +119,21 @@ impl VMClockShmHeader { /// Check whether a `VMClockShmHeader` is valid fn is_valid(&self) -> Result<(), ShmError> { if !self.matches_magic() { - error!("VMClockShmHeader does not have a matching magic number."); - return Err(ShmError::SegmentMalformed); + let msg = String::from("VMClockShmHeader does not have a matching magic number."); + error!(msg); + return Err(ShmError::SegmentMalformed(msg)); } if !self.has_valid_version() { - error!("VMClockShmHeader does not have a valid version number."); - return Err(ShmError::SegmentNotInitialized); + let msg = String::from("VMClockShmHeader does not have a valid version number."); + error!(msg); + return Err(ShmError::SegmentVersionNotSupported(msg)); } if !self.is_well_formed() { - error!("VMClockShmHeader is not well formed."); - return Err(ShmError::SegmentMalformed); + let msg = String::from("VMClockShmHeader is not well formed."); + error!(msg); + return Err(ShmError::SegmentMalformed(msg)); } Ok(()) } diff --git a/clock-bound/src/vmclock/shm_reader.rs b/clock-bound/src/vmclock/shm_reader.rs index 0c7dea0..44b1f9f 100644 --- a/clock-bound/src/vmclock/shm_reader.rs +++ b/clock-bound/src/vmclock/shm_reader.rs @@ -34,23 +34,35 @@ impl MmapGuard { /// Create a new `MmapGuard`. /// /// Memory map the provided open File. - fn new(mut file: File) -> Result { + fn new(path: &str) -> Result { + let mut file = match File::open(path) { + Ok(f) => f, + Err(e) => { + error!("VMClockShmReader::new(): {:?}", e); + return Err(ShmError::SegmentNotInitialized(format!( + "Failed to open SHM segment at {path}" + ))); + } + }; + let mut buffer = vec![]; let Ok(bytes_read) = file.read_to_end(&mut buffer) else { - return syserror!("Failed to read SHM segment"); + return syserror!(String::from("Failed to read SHM segment")); }; if bytes_read == 0_usize { - error!("MmapGuard: Read zero bytes."); - return Err(ShmError::SegmentNotInitialized); + let msg = String::from("MmapGuard: Read zero bytes."); + error!(msg); + return Err(ShmError::SegmentNotInitialized(msg)); } else if bytes_read < size_of::() { - error!( + let msg = format!( "MmapGuard: Number of bytes read ({:?}) is less than the size of VMClockShmHeader ({:?}).", bytes_read, size_of::() ); - return Err(ShmError::SegmentMalformed); + error!(msg); + return Err(ShmError::SegmentMalformed(msg)); } debug!("MMapGuard: Reading the VMClockShmHeader ..."); @@ -77,7 +89,7 @@ impl MmapGuard { }; if segment == libc::MAP_FAILED { - return syserror!("mmap SHM segment"); + return syserror!(String::from("Failed to mmap the SHM segment")); } Ok(MmapGuard { @@ -157,17 +169,11 @@ impl VMClockShmReader { /// returned. #[expect(clippy::missing_errors_doc, reason = "todo")] pub fn new(path: &str) -> Result { - debug!("VMClockShmReader::new(): path is: {:?}", path); - let file = match File::open(path) { - Ok(f) => f, - Err(e) => { - error!("VMClockShmReader::new(): {:?}", e); - return Err(ShmError::SegmentNotInitialized); - } - }; - - debug!("VMClockShmReader::new(): Creating a MmapGuard ..."); - let mmap_guard = MmapGuard::new(file)?; + debug!( + "VMClockShmReader::new(): Creating a MmapGuard at path: {:?}", + path + ); + let mmap_guard = MmapGuard::new(path)?; // Create a cursor to pick the addresses of the various elements of interest in the shared // memory segment. @@ -193,11 +199,11 @@ impl VMClockShmReader { let version = unsafe { &*version_ptr }; let version_number = version.load(atomic::Ordering::Acquire); if version_number != VMCLOCK_SUPPORTED_VERSION { - error!( - "VMClock shared memory segment has version {:?} which is not supported by this version of the VMClockShmReader.", - version_number + let msg = format!( + "VMClock shared memory segment has version {version_number} which is not supported by this version of the VMClockShmReader." ); - return Err(ShmError::SegmentVersionNotSupported); + error!(msg); + return Err(ShmError::SegmentVersionNotSupported(msg)); } // Log the counter_id in the shared memory segment. @@ -217,8 +223,13 @@ impl VMClockShmReader { // Move to the end of the header and map the VMClockShmBody data, but only if the segment // size allows it and matches our expectation. if mmap_guard.segsize < (size_of::() + size_of::()) { - error!("VMClockShmReader::new(): Segment size is smaller than expected."); - return Err(ShmError::SegmentMalformed); + let msg = format!( + "VMClockShmReader::new(): Segment size is smaller than expected [{} < {}].", + mmap_guard.segsize, + size_of::() + size_of::() + ); + error!(msg); + return Err(ShmError::SegmentMalformed(msg)); } // SAFETY: segment size has been checked to ensure `cursor` move leads to a valid cast cursor = unsafe { cursor.add(size_of::()) }; @@ -258,11 +269,11 @@ impl VMClockShmReader { // We are validating the version prior to each snapshot to protect // against a Hypervisor which has implemented an unsupported VMClock version. if version != VMCLOCK_SUPPORTED_VERSION { - error!( - "VMClock shared memory segment has version {:?} which is not supported by this version of the VMClockShmReader.", - version + let msg = format!( + "VMClock shared memory segment has version {version} which is not supported by this version of the VMClockShmReader." ); - return Err(ShmError::SegmentVersionNotSupported); + error!(msg); + return Err(ShmError::SegmentVersionNotSupported(msg)); } // Atomically read the current seq_count in the shared memory segment @@ -315,7 +326,9 @@ impl VMClockShmReader { } // Attempts to read the snapshot have failed. - Err(ShmError::SegmentNotInitialized) + Err(ShmError::SegmentNotInitialized(String::from( + "Failed to read the SHM segment after all attempts", + ))) } } @@ -445,11 +458,7 @@ mod t_reader { let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); remove_path_if_exists(vmclock_shm_path); - let expected = ShmError::SegmentNotInitialized; - match VMClockShmReader::new(&vmclock_shm_path) { - Err(actual) => assert_eq!(expected, actual), - _ => assert!(false), - } + assert!(VMClockShmReader::new(&vmclock_shm_path).is_err()); } /// Assert that the reader will return an error when it tries to open a file that is empty. @@ -459,11 +468,7 @@ mod t_reader { let vmclock_shm_temppath = vmclock_shm_tempfile.into_temp_path(); let vmclock_shm_path = vmclock_shm_temppath.to_str().unwrap(); - let expected = ShmError::SegmentNotInitialized; - match VMClockShmReader::new(&vmclock_shm_path) { - Err(actual) => assert_eq!(expected, actual), - _ => assert!(false), - } + assert!(VMClockShmReader::new(&vmclock_shm_path).is_err()); } /// Assert that the reader will return an error when it tries to read a file @@ -503,11 +508,7 @@ mod t_reader { }; write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); - let expected = ShmError::SegmentVersionNotSupported; - match VMClockShmReader::new(&vmclock_shm_path) { - Err(actual) => assert_eq!(expected, actual), - _ => assert!(false), - } + assert!(VMClockShmReader::new(&vmclock_shm_path).is_err()); } /// Assert that the reader will return an error when it tries to read a file @@ -547,11 +548,7 @@ mod t_reader { }; write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); - let expected = ShmError::SegmentMalformed; - match VMClockShmReader::new(&vmclock_shm_path) { - Err(actual) => assert_eq!(expected, actual), - _ => assert!(false), - } + assert!(VMClockShmReader::new(&vmclock_shm_path).is_err()); } /// Assert that the reader will return an error when it tries to read a file @@ -592,10 +589,6 @@ mod t_reader { }; write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); - let expected = ShmError::SegmentMalformed; - match VMClockShmReader::new(&vmclock_shm_path) { - Err(actual) => assert_eq!(expected, actual), - _ => assert!(false), - } + assert!(VMClockShmReader::new(&vmclock_shm_path).is_err()); } } diff --git a/clock-bound/src/vmclock/shm_writer.rs b/clock-bound/src/vmclock/shm_writer.rs index bb16da8..e5516ee 100644 --- a/clock-bound/src/vmclock/shm_writer.rs +++ b/clock-bound/src/vmclock/shm_writer.rs @@ -134,7 +134,10 @@ impl VMClockShmWriter { Err(err) => Err(err), } } else { - Err(ShmError::SegmentNotInitialized) + Err(ShmError::SegmentNotInitialized(format!( + "SHM segment path is not a valid C string [{}]", + path.to_string_lossy() + ))) } } From c8a647b63dd403c35a714211ea9902826cbf26a7 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:38:23 -0500 Subject: [PATCH 173/177] fix typo in top-level readme (#199) Co-authored-by: Nick Matthews --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 704392d..15248f1 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,6 @@ timestamps synchronized by the ClockBound daemon, and examples provided. #### Custom Client -The [ClockBound Protocol](docs/PROTOCOL.md) is provided so that one can create custom clients. - -Clients can be created in any programming language that can read from a shared memory segment that is backed by a file. [ClockBound Protocol](docs/protocol.md) - Reference provided to create a custom client. Clients can be created in any programming language that can read from a shared memory segment that is backed by a file. From e6560a5bf7ce252f8879f7b86aaec89244f7c853 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Fri, 28 Nov 2025 13:39:21 -0800 Subject: [PATCH 174/177] [docs] Point clock-bound crate to repo README (#202) This patch points to the README maintain at the source repo root, rather than the crate root. This should let crates.io render the README.md correctly. --- clock-bound/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index c6194cd..a6789f9 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -2,6 +2,7 @@ name = "clock-bound" description = "A crate to provide error bounded timestamp intervals." license = "MIT OR Apache-2.0" +readme = "../README.md" # Relative path to the README authors.workspace = true categories.workspace = true From 5944c069c4d6742cc84ae3856b49a659c6399ee5 Mon Sep 17 00:00:00 2001 From: Julien Ridoux Date: Fri, 28 Nov 2025 13:44:13 -0800 Subject: [PATCH 175/177] Ridouxj/changelog (#201) Update the CHANGELOG This commit brings in and merges the CHANGELOG.md that were previously maintained in the distinct clockbound daemon and client crates. Also update the Changelog with a summary of the changes brought in with ClockBound 3.0. --- clock-bound/CHANGELOG.md | 124 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 clock-bound/CHANGELOG.md diff --git a/clock-bound/CHANGELOG.md b/clock-bound/CHANGELOG.md new file mode 100644 index 0000000..a462d02 --- /dev/null +++ b/clock-bound/CHANGELOG.md @@ -0,0 +1,124 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [3.0.0-alpha.0] - 2025-11-24 + +### Added + +- The `clockbound` daemon auto-detects and synchronizes from NTP time servers and a PHC device if available. +- The `clockbound` daemon supports the VMClock device for EC2 instances running on Linux. +- The `clockbound` daemon maintains the operating system clock synchronized as well as populating its shared memory + segment with clock estimates. +- The `clockbound` daemon now writes to two distinct shared memory paths: `/var/run/clockbound/shm0` and `/var/run/clockbound/shm1`. +- The ClockBound 3.x clients (Rust and FFI) solely rely on the content of the shared memory segment to return the clock + status, as well as the current time and associated clock error bound as `earliest/latest` timestamps. + +### Changed + +- The FFI interface for ClockBound 3.0 clients has been simplified, and error management improved. +- The shared memory segment written to at `/var/run/clockbound/shm1` follows a new layout. See + [protocol](https://github.com/aws/clock-bound/blob/main/docs/protocol.md) details. + +### Deprecated + +- The support for ClockBound 2.x clients is deprecated and will be removed in a future release. + +### Removed + +- The `clockbound` daemon does not require `chronyd`. +- The `clockbound` daemon only accepts the following CLI parameters: `--log-dir` + +## [2.0.3] - 2025-08-13 + +### Changed + +- Updates the polling rate of clockbound to be once every 100 milliseconds. + +## [2.0.2] - 2025-07-30 + +### Removed + +- In dependency 'clock-bound-vmclock', the Cargo.toml no longer specifies logging level filter features for the + 'tracing' crate. + +## [2.0.1] - 2025-05-26 + +### Changed + +- Fix bug in clock status transitions after a clock disruption. +- Log more details when ChronyClient query_tracking fails. +- Documentation: + - Update clock status documentation. + - Update finite state machine image to match the underlying source code. + +## [2.0.0] - 2025-04-21 + +### Added + +- VMClock is utilized for being informed of clock disruptions. By default, ClockBound requires VMClock. +- CLI option `--disable-clock-disruption-support`. Using this option disables clock disruption support and causes + ClockBound to skip the VMClock requirement. +- ClockBound shared memory format version 2. This new shared memory format is not backwards compatible with the shared + memory format used in prior ClockBound releases. See [PROTOCOL.md](../docs/PROTOCOL.md) for more details. + +### Changed + +- The default ClockBound shared memory path has changed from `/var/run/clockbound/shm` to `/var/run/clockbound/shm0`. + +### Removed + +- Support for writing ClockBound shared memory format version 1. +- Support for reading ClockBound shared memory format version 1. + +## [1.0.0] - 2024-04-05 + +### Changed + +- The communication mechanism used in the ClockBound daemon with clients has changed from using Unix datagram socket to + using shared memory. +- The communication mechanism used to communicate between the ClockBound daemon and Chrony has changed from UDP to Unix + datagram socket. +- ClockBound daemon must be run as the chrony user so that it can communicate with Chrony. +- Types used in the API have changed with this release. + +### Removed + +- Removed support for ClockBound clients that are using the _clock-bound-c_ library which communicates with the + ClockBound daemon using Unix datagram socket. +- Prior to 1.0.0, client functions now(), before(), after() and timing() were supported. With this release, before(), + after() and timing() have been removed. + +## [0.1.4] - 2023-11-16 + +### Added + +- ClockBound now supports [reading error bound from a PHC device](https://github.com/amzn/amzn-drivers/tree/master/kernel/linux/ena) as exposed from ENA driver +- Bump tokio dependency from 1.18.4 to 1.18.5 + +## [0.1.3] - 2023-01-11 + +### Added + +- Bump tokio dependency from 1.17.0 to 1.18.4 + +## [0.1.2] - 2022-03-11 + +### Added + +- Daemon now correctly handles queries originating from abstract sockets. + +## [0.1.1] - 2021-12-28 + +### Added + +- Client support for the `timing` call. + +## [0.1.0] - 2021-11-02 + +### Added + +- Initial working version From ffb5886a0bc34a8453c0d99a7ca2860de25ebe83 Mon Sep 17 00:00:00 2001 From: mk <55758543+mekabir@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:29:58 -0500 Subject: [PATCH 176/177] ntp: fix local stratum interpretation during INIT state (#197) ntp: fix local stratum interpretation during INIT state On start, there isn't a selected clock source so SelectedClockSource is set to INIT and the stratum unspecified (0). Previously, the interpretation of the local stratum based on this would just be initial stratum + 1. This patch fixes the interpretation such that during the INIT state, the local stratum is interpreted to be unspecified (0) as well. --------- Co-authored-by: MOHAMMED KABIR --- clock-bound/src/daemon/io/link_local.rs | 6 +-- clock-bound/src/daemon/io/ntp_source.rs | 6 +-- clock-bound/src/daemon/selected_clock.rs | 53 +++++++++++++++++++++++- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/clock-bound/src/daemon/io/link_local.rs b/clock-bound/src/daemon/io/link_local.rs index d8d4d05..0af5668 100644 --- a/clock-bound/src/daemon/io/link_local.rs +++ b/clock-bound/src/daemon/io/link_local.rs @@ -101,13 +101,13 @@ impl LinkLocal { /// collected the NTP sample we construct the `Event` and push that event through /// to the ring buffer. async fn sample(&mut self) -> Result { - let (refid, stratum) = self.selected_clock.get(); let counter = self.transmit_counter.0; self.transmit_counter += 1; + let (source, stratum) = self.selected_clock.get_with_client_stratum(); let packet = Packet::builder() .transmit_timestamp(Timestamp::new(counter)) - .stratum(stratum.incremented().into()) - .reference_id(refid.into()) + .stratum(stratum.into()) + .reference_id(source.into()) .build(); packet.emit_bytes(&mut self.ntp_buffer); diff --git a/clock-bound/src/daemon/io/ntp_source.rs b/clock-bound/src/daemon/io/ntp_source.rs index a641d8b..ef51008 100644 --- a/clock-bound/src/daemon/io/ntp_source.rs +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -105,13 +105,13 @@ impl NTPSource { /// collected the NTP sample we construct the `Event` and push that event through /// to the ring buffer. async fn sample(&mut self) -> Result { - let (refid, stratum) = self.selected_clock.get(); let counter = self.transmit_counter.0; self.transmit_counter += 1; + let (source, stratum) = self.selected_clock.get_with_client_stratum(); let packet = Packet::builder() .transmit_timestamp(Timestamp::new(counter)) - .stratum(stratum.incremented().into()) - .reference_id(refid.into()) + .stratum(stratum.into()) + .reference_id(source.into()) .extensions(vec![ExtensionField::Fec2V1(self.daemon_info.clone())]) .build(); packet.emit_bytes(&mut self.ntp_buffer); diff --git a/clock-bound/src/daemon/selected_clock.rs b/clock-bound/src/daemon/selected_clock.rs index d008ae1..193d198 100644 --- a/clock-bound/src/daemon/selected_clock.rs +++ b/clock-bound/src/daemon/selected_clock.rs @@ -22,7 +22,7 @@ pub struct SelectedClockSource { } impl SelectedClockSource { - /// Get the current clock source and stratum + /// Get the current clock source and its stratum /// /// Returns a tuple of (`ClockSource`, `Stratum`) representing the current state. pub fn get(&self) -> (ClockSource, Stratum) { @@ -34,6 +34,21 @@ impl SelectedClockSource { (Self::params_to_source(refid, stratum), stratum) } + /// Get the current clock source and the stratum of this client + /// + /// Returns a tuple of (`ClockSource`, `Stratum`) representing the current state. + /// The `Stratum` is of the client per RFC 5905, i.e., stratum 0 during INIT, stratum 16 + /// for loss of synchronization, and source stratum + 1 in other cases. + pub fn get_with_client_stratum(&self) -> (ClockSource, Stratum) { + let (source, stratum) = self.get(); + let client_stratum = match source { + ClockSource::Init => Stratum::Unspecified, + ClockSource::None => Stratum::Unsynchronized, + _ => stratum.incremented(), + }; + (source, client_stratum) + } + /// Set the clock source to PHC pub fn set_to_phc(&self) { self.set(ClockSource::Phc, Stratum::Unspecified); @@ -163,6 +178,7 @@ impl Display for ClockSource { #[cfg(test)] mod tests { use super::*; + use crate::daemon::event::ValidStratumLevel; use rstest::rstest; #[test] @@ -189,6 +205,41 @@ mod tests { assert_eq!(read_stratum, stratum); } + #[rstest] + #[case(ClockSource::Init, Stratum::Unspecified, Stratum::Unspecified)] + #[case(ClockSource::None, Stratum::Unspecified, Stratum::Unsynchronized)] + #[case(ClockSource::Phc, Stratum::Unspecified, Stratum::Level(ValidStratumLevel::new(1).unwrap()))] + #[case(ClockSource::VMClock, Stratum::Unspecified, Stratum::Level(ValidStratumLevel::new(1).unwrap()))] + #[case(ClockSource::Server(0xA9FEA97B), Stratum::Level(ValidStratumLevel::new(1).unwrap()), Stratum::TWO)] + #[case(ClockSource::Server(0xA9FEA97B), Stratum::Level(ValidStratumLevel::new(2).unwrap()), Stratum::Level(ValidStratumLevel::new(3).unwrap()))] + #[case(ClockSource::Server(0xA9FEA97B), Stratum::Level(ValidStratumLevel::new(15).unwrap()), Stratum::Unsynchronized)] + fn get_with_client_stratum_maps_correctly_per_clocksource( + #[case] selected_source: ClockSource, + #[case] source_stratum: Stratum, + #[case] expected_client_stratum: Stratum, + ) { + let clock = SelectedClockSource::default(); + + // Set up the clock state based on the source type + match selected_source { + ClockSource::Init => {} // Default state + ClockSource::None => clock.set_to_none(), + ClockSource::Phc => clock.set_to_phc(), + ClockSource::VMClock => clock.set_to_vmclock(), + ClockSource::Server(id) => { + clock.set_to_server( + // Fine to re-interpret IPv6 since its md5 hash is truncated to the first 4 octets anyway + std::net::IpAddr::from(std::net::Ipv4Addr::from(id)), + source_stratum, + ); + } + } + + let (result_source, result_stratum) = clock.get_with_client_stratum(); + assert_eq!(result_source, selected_source); + assert_eq!(result_stratum, expected_client_stratum); + } + #[test] fn convenience_methods() { let clock = SelectedClockSource::default(); From 4072097bab61e516c15377a581b649df49852021 Mon Sep 17 00:00:00 2001 From: Nick Matthews <48697751+nickmatthews1020@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:45:40 -0500 Subject: [PATCH 177/177] Clockbound version 3.0.0-alpha.1 --- Cargo.lock | 42 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- clock-bound/CHANGELOG.md | 8 ++++++++ clock-bound/Cargo.toml | 2 +- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4289b00..7d26289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,7 +236,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "clock-bound" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "approx", "bon", @@ -271,7 +271,7 @@ dependencies = [ [[package]] name = "clock-bound-adjust-clock" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -281,7 +281,7 @@ dependencies = [ [[package]] name = "clock-bound-adjust-clock-test" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "clock-bound", "rstest 0.26.1", @@ -304,7 +304,7 @@ dependencies = [ [[package]] name = "clock-bound-client-generic" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -322,7 +322,7 @@ dependencies = [ [[package]] name = "clock-bound-ff-tester" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "anyhow", "approx", @@ -348,7 +348,7 @@ dependencies = [ [[package]] name = "clock-bound-ffi" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "byteorder", "clock-bound", @@ -360,7 +360,7 @@ dependencies = [ [[package]] name = "clock-bound-now" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "chrono", "clap", @@ -374,7 +374,7 @@ dependencies = [ [[package]] name = "clock-bound-phc-offset" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -418,7 +418,7 @@ dependencies = [ [[package]] name = "clock-bound-vmclock-client-example" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "byteorder", "clock-bound", @@ -428,7 +428,7 @@ dependencies = [ [[package]] name = "clock-bound-vmclock-client-test" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "byteorder", "clock-bound", @@ -1018,7 +1018,7 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "link-local" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "clock-bound", "rand 0.9.2", @@ -1187,7 +1187,7 @@ dependencies = [ [[package]] name = "ntp-source" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "clock-bound", "md5", @@ -1286,7 +1286,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phc" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "clock-bound", "rand 0.9.2", @@ -1861,9 +1861,9 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test-log" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" +checksum = "37d53ac171c92a39e4769491c4b4dde7022c60042254b5fc044ae409d34a24d4" dependencies = [ "test-log-macros", "tracing-subscriber", @@ -1871,9 +1871,9 @@ dependencies = [ [[package]] name = "test-log-macros" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" +checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" dependencies = [ "proc-macro2", "quote", @@ -2266,7 +2266,7 @@ dependencies = [ [[package]] name = "vmclock" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "clock-bound", "rand 0.9.2", @@ -2276,7 +2276,7 @@ dependencies = [ [[package]] name = "vmclock-updater" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" dependencies = [ "byteorder", "clap", @@ -2534,9 +2534,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index f7fe916..e17012c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,5 +39,5 @@ exclude = [] keywords = ["aws", "ntp", "ec2", "time"] publish = false repository = "https://github.com/aws/clock-bound" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" diff --git a/clock-bound/CHANGELOG.md b/clock-bound/CHANGELOG.md index a462d02..328bc89 100644 --- a/clock-bound/CHANGELOG.md +++ b/clock-bound/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0-alpha.1] - 2025-12-02 + +### Changed + +- Update client logic to align 'free-running' definition with daemon (60 seconds of stale shared memory segment instead of 5). +- Improved ShmError messages. +- Fix to local stratum interpretation during initialization. + ## [3.0.0-alpha.0] - 2025-11-24 ### Added diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml index a6789f9..f19a31e 100644 --- a/clock-bound/Cargo.toml +++ b/clock-bound/Cargo.toml @@ -2,7 +2,7 @@ name = "clock-bound" description = "A crate to provide error bounded timestamp intervals." license = "MIT OR Apache-2.0" -readme = "../README.md" # Relative path to the README +readme = "../README.md" authors.workspace = true categories.workspace = true

  • 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