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 new file mode 100644 index 0000000..1b82a46 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +### 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 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/.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/.github/workflows/ntp_source.yml b/.github/workflows/ntp_source.yml new file mode 100644 index 0000000..4f39951 --- /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_Source_Tests: + name: NTP Source 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 source test!" + - run: ./ntp-source-test \ 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 diff --git a/.github/workflows/pr_comment_slack.yml b/.github/workflows/pr_comment_slack.yml new file mode 100644 index 0000000..bda2853 --- /dev/null +++ b/.github/workflows/pr_comment_slack.yml @@ -0,0 +1,20 @@ +name: pr_comment_slack +permissions: + contents: read + +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 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 diff --git a/.github/workflows/pr_review_comment_slack.yml b/.github/workflows/pr_review_comment_slack.yml new file mode 100644 index 0000000..5c6ab01 --- /dev/null +++ b/.github/workflows/pr_review_comment_slack.yml @@ -0,0 +1,19 @@ +name: pr_review_comment_slack +permissions: + contents: read + +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 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 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..6850ed5 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,25 @@ +name: Rust + +permissions: + contents: read + +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 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 d574bbc..7d26289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,109 +3,105 @@ version = 4 [[package]] -name = "addr2line" -version = "0.21.0" +name = "aho-corasick" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ - "gimli", + "memchr", ] [[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" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ - "memchr", + "libc", ] [[package]] name = "anstream" -version = "0.6.12" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b09b5178381e0874812a9b157f7fe84982617e48f71f4e3235482775e5b540" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.6" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[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.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.2" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.97" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] -name = "arrayvec" -version = "0.7.4" +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[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.69" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" @@ -115,15 +111,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.2" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bon" -version = "2.3.0" +version = "3.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97493a391b4b18ee918675fb8663e53646fd09321c58b46afa04e8ce2499c869" +checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" dependencies = [ "bon-macros", "rustversion", @@ -131,17 +127,31 @@ dependencies = [ [[package]] name = "bon-macros" -version = "2.3.0" +version = "3.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2af3eac944c12cdf4423eab70d310da0a8e5851a18ffb192c0a5e3f7ae1663" +checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" dependencies = [ "darling", "ident_case", + "prettyplease", "proc-macro2", "quote", - "syn 2.0.58", + "rustversion", + "syn", ] +[[package]] +name = "bumpalo" +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" @@ -150,60 +160,45 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.5.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.0.83" +version = "1.2.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" dependencies = [ - "libc", + "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 = "chrony-candm" -version = "0.1.1" +version = "1.0.4" 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", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "chrony-candm-derive" -version = "0.1.0" +name = "chrono" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6cebed9b99e24249d179ca6b41be7198776e72206d8943fe3ee8ff0e301849d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] name = "clap" -version = "4.5.1" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c918d541ef2913577a0f9566e9ce27cb35b6df072075769e0b26cb5a554520da" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -211,9 +206,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.1" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3e7391dad68afb0c2ede1bf619f579a3dc9c2ec67f089baa397123a2f3d1eb" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -223,129 +218,256 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.0" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "307bc0538d5f0f83b8248db3087aa92fe504e4691294d0c96c0eabc33f47ba47" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.58", + "syn", ] [[package]] name = "clap_lex" -version = "0.7.0" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] -name = "clock-bound-client" -version = "2.0.3" +name = "clock-bound" +version = "3.0.0-alpha.1" dependencies = [ + "approx", + "bon", "byteorder", - "clock-bound-shm", - "clock-bound-vmclock", + "bytes", + "chrono", + "clap", "errno", + "futures", + "hex-literal", "libc", + "md5", + "mockall", + "mockall_double", "nix", + "nom", + "rand 0.9.2", + "reqwest", + "rstest 0.26.1", + "serde", + "serde_json", "tempfile", + "thiserror 2.0.17", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tracing-test", ] [[package]] -name = "clock-bound-d" -version = "2.0.3" +name = "clock-bound-adjust-clock" +version = "3.0.0-alpha.1" dependencies = [ "anyhow", - "bon", - "byteorder", - "chrony-candm", + "chrono", "clap", + "clock-bound", +] + +[[package]] +name = "clock-bound-adjust-clock-test" +version = "3.0.0-alpha.1" +dependencies = [ + "clock-bound", + "rstest 0.26.1", + "tokio", + "tracing", + "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", - "lazy_static", - "libc", - "mockall", - "mockall_double", + "errno", "nix", - "retry", - "rstest", - "serial_test", - "socket2", +] + +[[package]] +name = "clock-bound-client-generic" +version = "3.0.0-alpha.1" +dependencies = [ + "anyhow", + "chrono", + "clap", + "clock-bound", + "clock-bound-client", + "nix", + "serde", + "serde_json", + "thiserror 2.0.17", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "clock-bound-ff-tester" +version = "3.0.0-alpha.1" +dependencies = [ + "anyhow", + "approx", + "bon", + "clap", + "clock-bound", + "mockall", + "nalgebra", + "num-traits", + "rand 0.8.5", + "rand_chacha 0.3.1", + "rstest 0.25.0", + "serde", + "serde_json", + "statrs", "tempfile", + "test-log", + "thiserror 2.0.17", "tracing", "tracing-subscriber", + "varpro", ] [[package]] name = "clock-bound-ffi" -version = "2.0.3" +version = "3.0.0-alpha.1" dependencies = [ "byteorder", - "clock-bound-shm", - "clock-bound-vmclock", + "clock-bound", "errno", "libc", "nix", "tempfile", ] +[[package]] +name = "clock-bound-now" +version = "3.0.0-alpha.1" +dependencies = [ + "chrono", + "clap", + "clock-bound-client-generic", + "serde", + "serde_json", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "clock-bound-phc-offset" +version = "3.0.0-alpha.1" +dependencies = [ + "anyhow", + "clap", + "clock-bound", + "clock-bound-client-generic", + "libc", + "nix", + "serde", + "serde_json", + "thiserror 2.0.17", + "tracing", + "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", - "tempfile", ] [[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", - "tempfile", "tracing", "tracing-subscriber", ] [[package]] name = "clock-bound-vmclock-client-example" -version = "2.0.3" +version = "3.0.0-alpha.1" dependencies = [ "byteorder", - "clock-bound-client", + "clock-bound", "errno", "nix", ] [[package]] name = "clock-bound-vmclock-client-test" -version = "2.0.3" +version = "3.0.0-alpha.1" dependencies = [ "byteorder", - "clock-bound-client", + "clock-bound", "errno", "nix", ] [[package]] name = "colorchoice" -version = "1.0.0" +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 = "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 = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "darling" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ "darling_core", "darling_macro", @@ -353,29 +475,55 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.58", + "syn", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.58", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3b3318a6ce94bae6f71c71dfbb5c91059ea2afa3c2ac86d8fb9b1f6ea5de83" + [[package]] name = "downcast" version = "0.11.0" @@ -384,18 +532,18 @@ checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +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.2", ] [[package]] @@ -404,23 +552,38 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + [[package]] name = "fnv" 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.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -433,9 +596,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -443,15 +606,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -460,32 +623,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.58", + "syn", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-timer" @@ -495,9 +658,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -513,153 +676,431 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.12" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.1" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "wasi 0.13.3+wasi-0.2.2", - "windows-targets 0.52.0", + "r-efi", + "wasip2", ] -[[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" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hex" -version = "0.4.3" +name = "hex-literal" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" [[package]] -name = "ident_case" -version = "1.0.1" +name = "http" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] [[package]] -name = "indexmap" -version = "2.7.1" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "equivalent", - "hashbrown", + "bytes", + "http", ] [[package]] -name = "itoa" -version = "1.0.10" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] [[package]] -name = "lazy_static" -version = "1.4.0" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "libc" -version = "0.2.170" +name = "hyper" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] [[package]] -name = "linux-raw-sys" -version = "0.9.2" +name = "hyper-util" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +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 = "lock_api" -version = "0.4.12" +name = "iana-time-zone" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ - "autocfg", - "scopeguard", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "log" -version = "0.4.20" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] [[package]] -name = "memchr" -version = "2.7.1" +name = "icu_collections" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] [[package]] -name = "memoffset" -version = "0.7.1" +name = "icu_locale_core" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ - "autocfg", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "miniz_oxide" -version = "0.7.2" +name = "icu_normalizer" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "adler", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "mio" -version = "0.8.11" +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 = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "mockall" -version = "0.13.1" +name = "icu_properties_data" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +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.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "link-local" +version = "3.0.0-alpha.1" +dependencies = [ + "clock-bound", + "rand 0.9.2", + "tempfile", + "tokio", + "tracing-subscriber", +] + +[[package]] +name = "linux-raw-sys" +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" +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 = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" dependencies = [ "cfg-if", "downcast", @@ -678,7 +1119,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.58", + "syn", ] [[package]] @@ -690,7 +1131,36 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.58", + "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 0.8.5", + "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]] @@ -706,86 +1176,151 @@ 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 = "ntp-source" +version = "3.0.0-alpha.1" +dependencies = [ + "clock-bound", + "md5", + "rand 0.9.2", + "tokio", + "tracing-subscriber", +] + [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] -name = "num_enum" -version = "0.5.11" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "num_enum_derive", + "num-integer", + "num-traits", ] [[package]] -name = "num_enum_derive" -version = "0.5.11" +name = "num-complex" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro2", - "quote", - "syn 1.0.109", + "num-traits", ] [[package]] -name = "object" -version = "0.32.2" +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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "memchr", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", ] [[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 = "overload" -version = "0.1.1" +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] -name = "parking_lot" -version = "0.12.3" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phc" +version = "3.0.0-alpha.1" dependencies = [ - "lock_api", - "parking_lot_core", + "clock-bound", + "rand 0.9.2", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", ] [[package]] -name = "parking_lot_core" -version = "0.9.10" +name = "pin-project" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.0", + "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.13" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -793,11 +1328,29 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] name = "predicates" @@ -826,42 +1379,48 @@ dependencies = [ ] [[package]] -name = "proc-macro-crate" -version = "1.3.1" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "once_cell", - "toml_edit 0.19.15", + "proc-macro2", + "syn", ] [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.22.24", + "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.35" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 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 = "rand" version = "0.8.5" @@ -869,8 +1428,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]] @@ -880,7 +1449,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]] @@ -889,23 +1468,39 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.12", + "getrandom 0.2.16", ] [[package]] -name = "redox_syscall" -version = "0.5.10" +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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ - "bitflags 2.4.2", + "num-traits", + "rand 0.8.5", ] +[[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.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -915,9 +1510,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -926,9 +1521,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "relative-path" @@ -937,49 +1532,95 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] -name = "retry" -version = "2.0.0" +name = "reqwest" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9166d72162de3575f950507683fac47e30f6f2c3836b71b7fbc61aa517c9c5f4" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ - "rand", + "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.22.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" dependencies = [ - "futures", "futures-timer", - "rstest_macros", + "futures-util", + "rstest_macros 0.25.0", "rustc_version", ] +[[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 0.26.1", +] + [[package]] name = "rstest_macros" -version = "0.22.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" dependencies = [ "cfg-if", "glob", - "proc-macro-crate 3.3.0", + "proc-macro-crate", "proc-macro2", "quote", "regex", "relative-path", "rustc_version", - "syn 2.0.58", + "syn", "unicode-ident", ] [[package]] -name = "rustc-demangle" -version = "0.1.23" +name = "rstest_macros" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] [[package]] name = "rustc_version" @@ -992,110 +1633,97 @@ 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.10.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[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 = "safe_arch" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea091f6cac2595aa38993f04f4ee692ed43757035c36e67c180b6828356385b1" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" dependencies = [ - "sdd", + "bytemuck", ] [[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" +name = "semver" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "584e070911c7017da6cb2eb0788d09f43d789029b5877d3e5ecc8acf86ceee21" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] -name = "semver" -version = "1.0.26" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] [[package]] -name = "serde" -version = "1.0.197" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 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", + "serde_core", ] [[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" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.58", + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] @@ -1108,34 +1736,71 @@ dependencies = [ ] [[package]] -name = "siphasher" -version = "0.3.11" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "slab" -version = "0.4.9" +name = "signal-hook-registry" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ - "autocfg", + "libc", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" -version = "1.13.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.60.2", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "nalgebra", + "num-traits", + "rand 0.8.5", ] [[package]] @@ -1146,9 +1811,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "1.0.109" +version = "2.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" dependencies = [ "proc-macro2", "quote", @@ -1156,127 +1821,320 @@ dependencies = [ ] [[package]] -name = "syn" -version = "2.0.58" +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 = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "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 = "test-log" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d53ac171c92a39e4769491c4b4dde7022c60042254b5fc044ae409d34a24d4" +dependencies = [ + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 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]] +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]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", ] [[package]] -name = "tempfile" -version = "3.18.0" +name = "time" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ - "cfg-if", - "fastrand", - "getrandom 0.3.1", - "once_cell", - "rustix", - "windows-sys 0.52.0", + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", ] [[package]] -name = "termtree" -version = "0.5.1" +name = "time-core" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] -name = "thread_local" -version = "1.1.7" +name = "time-macros" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ - "cfg-if", - "once_cell", + "num-conv", + "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.36.0" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.58", + "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" +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.6.8" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] [[package]] name = "toml_edit" -version = "0.19.15" +version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ "indexmap", "toml_datetime", - "winnow 0.5.40", + "toml_parser", + "winnow", ] [[package]] -name = "toml_edit" -version = "0.22.24" +name = "toml_parser" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "indexmap", - "toml_datetime", - "winnow 0.7.3", + "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.10.0", + "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.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", "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.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", @@ -1295,9 +2153,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", @@ -1305,46 +2163,124 @@ 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 = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "serde", "serde_json", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", "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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[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 = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[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 = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "varpro" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "c6c9b6e39df7efd76d02809389db80f63f1e81b6326759db04a9620c6264f24a" +dependencies = [ + "distrs", + "levenberg-marquardt", + "nalgebra", + "num-traits", + "thiserror 1.0.69", +] + +[[package]] +name = "vmclock" +version = "3.0.0-alpha.1" +dependencies = [ + "clock-bound", + "rand 0.9.2", + "tokio", + "tracing-subscriber", +] [[package]] name = "vmclock-updater" -version = "2.0.3" +version = "3.0.0-alpha.1" dependencies = [ "byteorder", "clap", - "clock-bound-vmclock", + "clock-bound", "errno", "libc", "nix", @@ -1353,197 +2289,363 @@ dependencies = [ ] [[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] [[package]] name = "wasi" -version = "0.13.3+wasi-0.2.2" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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-rt", + "wit-bindgen", ] [[package]] -name = "winapi" -version = "0.3.9" +name = "wasm-bindgen" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "wasm-bindgen-futures" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "wasm-bindgen-macro" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] [[package]] -name = "windows-sys" -version = "0.48.0" +name = "wasm-bindgen-macro-support" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ - "windows-targets 0.48.5", + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", ] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "wasm-bindgen-shared" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ - "windows-targets 0.52.0", + "unicode-ident", ] [[package]] -name = "windows-targets" -version = "0.48.5" +name = "web-sys" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" 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", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "windows-targets" -version = "0.52.0" +name = "wide" +version = "0.7.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" 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", + "bytemuck", + "safe_arch", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.0" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "windows_aarch64_msvc" -version = "0.52.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows_i686_gnu" -version = "0.48.5" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] [[package]] -name = "windows_i686_gnu" -version = "0.52.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] [[package]] -name = "windows_i686_msvc" -version = "0.48.5" +name = "windows-sys" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] [[package]] -name = "windows_i686_msvc" -version = "0.52.0" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" +name = "windows-targets" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "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_x86_64_gnu" -version = "0.52.0" +name = "windows_aarch64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" +name = "windows_aarch64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.0" +name = "windows_i686_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" +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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.5.40" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] [[package]] -name = "winnow" -version = "0.7.3" +name = "wit-bindgen" +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 = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "memchr", + "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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fa6694ed34d6e57407afbccdeecfa268c470a7d2a5b0cf49ce9fcc345afb90" +dependencies = [ + "zerocopy-derive", ] [[package]] -name = "wit-bindgen-rt" -version = "0.33.0" +name = "zerocopy-derive" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" dependencies = [ - "bitflags 2.4.2", + "proc-macro2", + "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/Cargo.toml b/Cargo.toml index bdb0c51..e17012c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,33 +1,43 @@ [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", "clock-bound-ffi", - "clock-bound-client", - "clock-bound-d", + "clock-bound-ff-tester", "examples/client/rust", "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", + "test/vmclock", + "test/clock-bound-client-generic", + "test/clock-bound-now", + "test/clock-bound-phc-offset", ] -resolver = "2" +resolver = "3" [workspace.package] authors = [ - "Jacob Wisniewski ", - "Julien Ridoux ", - "Tam Phan ", - "Ryan Luu ", - "Wenhao Piao ", - "Daniel Franke ", - "Thoth Gunter ", + "Jacob Wisniewski ", + "Jennifer Solidum ", + "Julien Ridoux ", + "Mohammed Kabir ", + "Myles Neloms ", + "Nick Matthews ", + "Ryan Luu ", + "Shamik Chakraborty ", + "Tam Phan ", + "Thoth Gunter ", + "Wei-Han Huang ", + "Wenhao Piao ", ] categories = [ "date-and-time" ] -edition = "2021" +edition = "2024" exclude = [] keywords = ["aws", "ntp", "ec2", "time"] -publish = true +publish = false repository = "https://github.com/aws/clock-bound" -version = "2.0.3" +version = "3.0.0-alpha.1" + 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/README.md b/README.md index a69fc8a..15248f1 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,90 @@ 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" +``` -Clients can be created in any programming language that can read from a shared memory segment that is backed by a file. +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 + +[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 +148,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/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-client/src/lib.rs b/clock-bound-client/src/lib.rs deleted file mode 100644 index 3156d82..0000000 --- a/clock-bound-client/src/lib.rs +++ /dev/null @@ -1,573 +0,0 @@ -//! 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; -use errno::Errno; -use nix::sys::time::TimeSpec; -use std::path::Path; - -pub const CLOCKBOUND_SHM_DEFAULT_PATH: &str = "/var/run/clockbound/shm0"; - -pub struct ClockBoundClient { - vmclock: VMClock, -} - -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. - /// - 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 }) - } - - /// 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. - 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 }) - } - - /// Creates and returns a new ClockBoundClient, specifying a shared - /// memory paths that are being used by the ClockBound daemon and by the VMClock, - /// respectively. - pub fn new_with_paths( - clockbound_shm_path: &str, - vmclock_shm_path: &str, - ) -> Result { - // Validate that the provided shared memory paths 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); - } - - let vmclock = VMClock::new(clockbound_shm_path, vmclock_shm_path)?; - - Ok(ClockBoundClient { vmclock }) - } - - /// Obtains the clock error bound and clock status at the current moment. - pub fn now(&mut self) -> Result { - let (earliest, latest, clock_status) = self.vmclock.now()?; - - Ok(ClockBoundNowResult { - earliest, - latest, - clock_status, - }) - } -} - -#[derive(Hash, PartialEq, Eq, Clone, Debug)] -pub enum ClockBoundErrorKind { - Syscall, - SegmentNotInitialized, - SegmentMalformed, - CausalityBreach, - SegmentVersionNotSupported, -} - -#[derive(Debug)] -pub struct ClockBoundError { - pub kind: ClockBoundErrorKind, - pub errno: Errno, - pub detail: String, -} - -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::new(), - }; - - ClockBoundError { - kind, - errno, - detail, - } - } -} - -/// Result of the `ClockBoundClient::now()` function. -#[derive(PartialEq, Clone, Debug)] -pub struct ClockBoundNowResult { - pub earliest: TimeSpec, - pub latest: TimeSpec, - pub clock_status: ClockStatus, -} - -#[cfg(test)] -mod lib_tests { - use super::*; - use clock_bound_shm::{ClockErrorBound, ShmWrite, ShmWriter}; - use clock_bound_vmclock::shm::VMClockClockStatus; - use byteorder::{NativeEndian, WriteBytesExt}; - use std::ffi::CStr; - use std::fs::{File, OpenOptions}; - use std::io::Write; - use std::path::Path; - /// We make use of tempfile::NamedTempFile to ensure that - /// local files that are created during a test get removed - /// afterwards. - use tempfile::NamedTempFile; - - // TODO: this macro is defined in more than one crate, and the code needs to be refactored to - // remove duplication once most sections are implemented. For now, a bit of redundancy is ok to - // avoid having to think about dependencies between crates. - macro_rules! write_clockbound_memory_segment { - ($file:ident, - $magic_0:literal, - $magic_1:literal, - $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 - ); - - // Convert the ceb 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( - (&ceb as *const ClockErrorBound) as *const u8, - ::core::mem::size_of::(), - ) - }; - - $file - .write_u32::($magic_0) - .expect("Write failed magic_0"); - $file - .write_u32::($magic_1) - .expect("Write failed magic_1"); - $file - .write_u32::($segsize) - .expect("Write failed segsize"); - $file - .write_u16::($version) - .expect("Write failed version"); - $file - .write_u16::($generation) - .expect("Write failed generation"); - $file - .write_all(slice) - .expect("Write failed ClockErrorBound"); - $file.sync_all().expect("Sync to disk failed"); - }; - } - - /// 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, - } - - 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 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"); - } else { - std::fs::remove_file(path_shm).expect("failed to remove file"); - } - } - } - - #[test] - fn test_new_with_path_does_not_exist() { - 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); - let result = ClockBoundClient::new_with_path(clockbound_shm_path); - assert!(result.is_err()); - } - - /// Assert that the shared memory segment can be open, read and and closed. Only a sanity test. - #[test] - fn test_new_with_paths_sanity_check() { - 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_clockbound_memory_segment!(clockbound_shm_file, 0x414D5A4E, 0x43420200, 800, 2, 10); - - 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 { - 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, - }; - write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); - - let mut clockbound = - match ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path) { - Ok(c) => c, - Err(e) => { - eprintln!("{:?}", e); - panic!("ClockBoundClient::new_with_paths() failed"); - } - }; - - let now_result = match clockbound.now() { - Ok(result) => result, - Err(e) => { - eprintln!("{:?}", e); - panic!("ClockBoundClient::now() failed"); - } - }; - - assert_eq!(now_result.clock_status, ClockStatus::Unknown); - } - - #[test] - fn test_new_with_paths_does_not_exist() { - // Test both clockbound and vmclock files do not exist. - 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); - 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); - let result = ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path); - assert!(result.is_err()); - - // Test clockbound file exists but vmclock file does not exist. - 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_clockbound_memory_segment!(clockbound_shm_file, 0x414D5A4E, 0x43420200, 800, 2, 10); - 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); - let result = ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path); - assert!(result.is_err()); - remove_path_if_exists(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); - 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 { - 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, - }; - write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); - - let result = ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path); - assert!(result.is_err()); - } - - /// Assert that the new() runs and returns with a ClockBoundClient if the default shared - /// memory path exists, or with ClockBoundError if shared memory segment does not exist. - /// 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] - fn test_new_sanity_check() { - let result = ClockBoundClient::new(); - if Path::new(CLOCKBOUND_SHM_DEFAULT_PATH).exists() { - assert!(result.is_ok()); - } else { - assert!(result.is_err()); - } - } - - #[test] - 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(); - 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_clockbound_memory_segment!(clockbound_shm_file, 0x414D5A4E, 0x43420200, 800, 2, 10); - - 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 { - 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, - }; - 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 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 - ); - writer.write(&ceb); - - let mut clockbound = - match ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path) { - Ok(c) => c, - Err(e) => { - eprintln!("{:?}", e); - panic!("ClockBoundClient::new_with_paths() failed"); - } - }; - - // Validate now() has a Result with a successful value. - let now_result = clockbound.now(); - assert!(now_result.is_ok()); - - // 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, - ); - writer.write(&ceb); - - // Validate now has Result with an error. - let now_result = clockbound.now(); - assert!(now_result.is_err()); - } - - /// Test conversions from ShmError to ClockBoundError. - - #[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); - // 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); - } - - #[test] - fn test_shmerror_clockbounderror_conversion_segmentnotinitialized() { - let shm_error = ShmError::SegmentNotInitialized; - // Perform the conversion. - let clockbounderror = ClockBoundError::from(shm_error); - assert_eq!( - ClockBoundErrorKind::SegmentNotInitialized, - clockbounderror.kind - ); - assert_eq!(Errno(0), clockbounderror.errno); - assert_eq!(String::new(), clockbounderror.detail); - } - - #[test] - fn test_shmerror_clockbounderror_conversion_segmentmalformed() { - let shm_error = ShmError::SegmentMalformed; - // Perform the conversion. - let clockbounderror = ClockBoundError::from(shm_error); - assert_eq!(ClockBoundErrorKind::SegmentMalformed, clockbounderror.kind); - assert_eq!(Errno(0), clockbounderror.errno); - assert_eq!(String::new(), clockbounderror.detail); - } - - #[test] - fn test_shmerror_clockbounderror_conversion_causalitybreach() { - let shm_error = ShmError::CausalityBreach; - // Perform the conversion. - let clockbounderror = ClockBoundError::from(shm_error); - assert_eq!(ClockBoundErrorKind::CausalityBreach, clockbounderror.kind); - assert_eq!(Errno(0), clockbounderror.errno); - assert_eq!(String::new(), clockbounderror.detail); - } -} 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-ff-tester/Cargo.toml b/clock-bound-ff-tester/Cargo.toml new file mode 100644 index 0000000..acf3fd2 --- /dev/null +++ b/clock-bound-ff-tester/Cargo.toml @@ -0,0 +1,46 @@ +[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 = false +repository.workspace = true +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 = [ + "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" } +tracing-subscriber = { version = "0.3", features = [ + "std", + "fmt", + "json", + "env-filter", +] } +tracing = "0.1.41" + +[dev-dependencies] +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-shm/LICENSE b/clock-bound-ff-tester/LICENSE.Apache-2.0 similarity index 99% rename from clock-bound-shm/LICENSE rename to clock-bound-ff-tester/LICENSE.Apache-2.0 index 7a4a3ea..d645695 100644 --- a/clock-bound-shm/LICENSE +++ b/clock-bound-ff-tester/LICENSE.Apache-2.0 @@ -199,4 +199,4 @@ 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 + 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/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/repro_replay.rs b/clock-bound-ff-tester/src/bin/repro_replay.rs new file mode 100644 index 0000000..40bc402 --- /dev/null +++ b/clock-bound-ff-tester/src/bin/repro_replay.rs @@ -0,0 +1,64 @@ +//! 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::{ + repro::{self, ReproEvent}, + 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 repro_events = repro::repro_events_from_log_file(&args.logfile)?; + + // needed to output new clock parameters + daemon::subscriber::init(args.output_dir); + + 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/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/bin/sim_ll.rs b/clock-bound-ff-tester/src/bin/sim_ll.rs new file mode 100644 index 0000000..4acc013 --- /dev/null +++ b/clock-bound-ff-tester/src/bin/sim_ll.rs @@ -0,0 +1,72 @@ +//! Simulation tests against ClockBound's Link Local +#![expect(clippy::cast_possible_truncation)] + +use std::sync::Arc; + +use clock_bound::daemon::{ + clock_sync_algorithm::{ClockSyncAlgorithm, Selector, 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())) + .selector(Selector::new(Skew::from_ppm(15.0))) + .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/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 new file mode 100644 index 0000000..bee7821 --- /dev/null +++ b/clock-bound-ff-tester/src/lib.rs @@ -0,0 +1,10 @@ +//! Feed Forward Time sync algorithm tester + +pub mod events; +pub mod repro; +pub mod simulation; + +pub mod time; + +pub mod sim_ll; +pub mod sim_phc; diff --git a/clock-bound-ff-tester/src/repro.rs b/clock-bound-ff-tester/src/repro.rs new file mode 100644 index 0000000..8db042b --- /dev/null +++ b/clock-bound-ff-tester/src/repro.rs @@ -0,0 +1,310 @@ +//! Convert logs into `ff-tester` types in a Scenario + +use std::{ + fs::File, + io::{BufRead, Read}, + path::Path, +}; + +use crate::events::v1; +use crate::time::CbBridge; +use clock_bound::daemon::{ + clock_parameters::ClockParameters, + event, + receiver_stream::RoutableEvent, + 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 repro_events_from_log_file(file_path: impl AsRef) -> anyhow::Result> { + let file = File::open(file_path)?; + repro_events_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 repro_events_from_reader(input: impl Read) -> anyhow::Result> { + let reader = std::io::BufReader::new(input); + + let mut events = 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; + } + }; + + 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)); + } + } + } + + if events.is_empty() { + anyhow::bail!("No events found in log file"); + } + + Ok(events) +} + +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(), + 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(), + } +} + +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(), + 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(), + } +} + +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")) + } + 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")), + } +} + +#[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 +/// to get the [`NTP event`](clock_bound::daemon::event::Ntp) and [`ClockParameters`] +#[derive(Debug, Clone, serde::Deserialize)] +struct Line { + 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 +/// +/// 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: RoutableEvent = 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_ntp_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_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!( + 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 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_11_06_2025.log"); + + let events = repro_events_from_reader(example_log.as_bytes()).unwrap(); + + let param_count = events + .iter() + .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-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-ff-tester/src/sim_ll.rs b/clock-bound-ff-tester/src/sim_ll.rs new file mode 100644 index 0000000..23653c3 --- /dev/null +++ b/clock-bound-ff-tester/src/sim_ll.rs @@ -0,0 +1,184 @@ +//! 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, + receiver_stream::RoutableEvent, + 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_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_link_local(event); + self.algorithm.feed(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_link_local(e: &Event) -> RoutableEvent { + use crate::time::CbBridge; + let EventKind::Ntp(n) = &e.variants else { + panic!("Expected NTP event, found {e:?}"); + }; + + let event = 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(); + + RoutableEvent::LinkLocal(event) +} + +// now-ish. Oct 27 2025 +pub 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::{Selector, 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())) + .selector(Selector::new(Skew::from_ppm(15.0))) + .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())) + .selector(Selector::new(Skew::from_ppm(15.0))) + .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-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-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.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..17667c6 --- /dev/null +++ b/clock-bound-ff-tester/src/time/estimate_instant.rs @@ -0,0 +1,87 @@ +//! 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, +//! 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 { + const INSTANT_PREFIX: &'static str = "EstimateInstant"; + const DURATION_PREFIX: &'static str = "EstimateDuration"; +} + +/// 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..29bc02d --- /dev/null +++ b/clock-bound-ff-tester/src/time/true_instant.rs @@ -0,0 +1,75 @@ +//! 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 { + const INSTANT_PREFIX: &'static str = "TrueInstant"; + const DURATION_PREFIX: &'static str = "TrueDuration"; +} + +/// 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-ffi/Cargo.toml b/clock-bound-ffi/Cargo.toml index 12443a9..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 @@ -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 = "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-ffi/include/clockbound.h b/clock-bound-ffi/include/clockbound.h index 54c2d69..ca24d08 100644 --- a/clock-bound-ffi/include/clockbound.h +++ b/clock-bound-ffi/include/clockbound.h @@ -4,7 +4,8 @@ #include -#define CLOCKBOUND_SHM_DEFAULT_PATH "/var/run/clockbound/shm0" +#define CLOCKBOUND_ERROR_DETAIL_SIZE 128 +#define CLOCKBOUND_SHM_DEFAULT_PATH "/var/run/clockbound/shm1" #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; /* @@ -69,41 +74,47 @@ 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 + * 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` * 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_open_with(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 24333e4..97fb35c 100644 --- a/clock-bound-ffi/src/lib.rs +++ b/clock-bound-ffi/src/lib.rs @@ -5,17 +5,22 @@ // 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::client::{ClockBoundClient, ClockBoundError, ClockBoundErrorKind}; +use clock_bound::shm::{CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH, ClockBoundNowResult, ClockStatus}; +use clock_bound::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; use core::ptr; -use nix::sys::time::TimeSpec; -use std::ffi::{c_char, CStr}; +use errno::Errno; +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, @@ -25,54 +30,72 @@ pub enum clockbound_err_kind { CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED, } +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 + } + 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 + } + } + } +} + /// 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, + pub errno: c_int, + pub detail: [c_char; CLOCKBOUND_ERROR_DETAIL_SIZE], } -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: ShmError) -> Self { - let kind = match value { - ShmError::SyscallError(_, _) => clockbound_err_kind::CLOCKBOUND_ERR_SYSCALL, - ShmError::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 => { - clockbound_err_kind::CLOCKBOUND_ERR_SEGMENT_VERSION_NOT_SUPPORTED +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() } }; - let errno = match value { - ShmError::SyscallError(errno, _) => errno.0, - _ => 0, - }; - - let detail = match value { - ShmError::SyscallError(_, detail) => detail.as_ptr(), - _ => ptr::null(), - }; + // 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); + } - clockbound_err { - kind, - errno, - detail, + // Ensure null termination if we had to truncate. + if copy_len >= CLOCKBOUND_ERROR_DETAIL_SIZE { + self.detail[CLOCKBOUND_ERROR_DETAIL_SIZE - 1] = 0; } } } @@ -83,34 +106,18 @@ 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_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) - } + /// Obtain error-bounded timestamps and the `ClockStatus`. + 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,47 +148,40 @@ 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 +/// 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. -/// -#[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); - 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() { - err.write(e.into()) - } - return ptr::null_mut(); - } - }; - - let ctx = clockbound_ctx { - err: Default::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. - return Box::leak(Box::new(ctx)); +#[unsafe(no_mangle)] +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 { + let clockbound_shm_path = CString::new(CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH).unwrap(); + let vmclock_shm_path = CString::new(VMCLOCK_SHM_DEFAULT_PATH).unwrap(); + clockbound_open_with(clockbound_shm_path.as_ptr(), vmclock_shm_path.as_ptr(), err) + } } /// Open and create a reader to the Clockbound shared memory segment and the VMClock shared memory segment. @@ -192,54 +192,110 @@ pub unsafe extern "C" fn clockbound_open( /// /// # Safety /// Rely on the caller to pass valid pointers. -#[no_mangle] -pub unsafe extern "C" fn clockbound_vmclock_open( +/// +// # 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_open_with( 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); - 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); - 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, + // Safety: Rely on caller to pass valid pointers + let clockbound_shm_path_cstr = unsafe { CStr::from_ptr(clockbound_shm_path) }; + let clockbound_shm_path = match clockbound_shm_path_cstr.to_str() { + Ok(path) => path, Err(e) => { - if !err.is_null() { - err.write(e.into()) + 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 + if let Some(rust_err) = err.as_mut() { + rust_err.write_error(cb_err); + } } return ptr::null_mut(); } }; - let ctx = clockbound_ctx { - err: Default::default(), - clockbound_shm_reader: None, - vmclock: Some(vmclock), + // Safety: Rely on caller to pass valid pointers + let vmclock_shm_path_cstr = unsafe { CStr::from_ptr(vmclock_shm_path) }; + let vmclock_shm_path = match vmclock_shm_path_cstr.to_str() { + Ok(path) => path, + Err(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 + if let Some(rust_err) = err.as_mut() { + rust_err.write_error(cb_err); + } + } + 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. - return Box::leak(Box::new(ctx)); + let clockbound_client = + match ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path) { + Ok(client) => client, + 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 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 { clockbound_client }; + 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 /// /// Rely on the caller to pass valid pointers. -#[no_mangle] -pub unsafe extern "C" fn clockbound_close(ctx: *mut clockbound_ctx) -> *const clockbound_err { - std::mem::drop(Box::from_raw(ctx)); +#[unsafe(no_mangle)] +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() } @@ -252,35 +308,41 @@ 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] -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn clockbound_now( ctx: *mut clockbound_ctx, output: *mut clockbound_now_result, + err: *mut clockbound_err, ) -> *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() { + // 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 &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; } }; - 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::from(cb_now)); + } ptr::null() } #[cfg(test)] mod t_ffi { use super::*; - use clock_bound_shm::ClockErrorBound; use byteorder::{LittleEndian, NativeEndian, WriteBytesExt}; + use clock_bound::shm::{ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion}; use std::ffi::CString; use std::fs::OpenOptions; use std::io::Write; @@ -300,8 +362,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(ClockErrorBoundLayoutVersion::V2); // Convert the ceb struct into a slice so we can write it all out, fairly magic. // Definitely needs the #[repr(C)] layout. @@ -362,6 +424,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() { @@ -372,7 +506,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(); @@ -403,49 +544,54 @@ 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( + let ctx = clockbound_open_with( clockbound_path_cstring.as_ptr(), vmclock_path_cstring.as_ptr(), &mut err, ); 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_UNKNOWN ); - 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-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/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-shm/src/lib.rs b/clock-bound-shm/src/lib.rs deleted file mode 100644 index 3c1d8af..0000000 --- a/clock-bound-shm/src/lib.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! ClockBound Shared Memory -//! -//! 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. - -// TODO: prevent clippy from checking for dead code. The writer module is only re-exported publicly -// 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; - -use errno::Errno; -use nix::sys::time::{TimeSpec, TimeValLike}; -use std::ffi::CStr; - -use common::{clock_gettime_safe, CLOCK_MONOTONIC, CLOCK_REALTIME}; - -const CLOCKBOUND_RESTART_GRACE_PERIOD: TimeSpec = TimeSpec::new(5, 0); - -/// Convenience macro to build a ShmError::SyscallError with extra info from errno and custom -/// origin information. -#[macro_export] -macro_rules! syserror { - ($origin:expr) => { - Err($crate::ShmError::SyscallError( - ::errno::errno(), - ::std::ffi::CStr::from_bytes_with_nul(concat!($origin, "\0").as_bytes()).unwrap(), - )) - }; -} - -/// Error condition returned by all low-level ClockBound APIs. -#[derive(Debug, Copy, 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), - - /// The shared memory segment is not initialized. - SegmentNotInitialized, - - /// The shared memory segment is initialized but malformed. - SegmentMalformed, - - /// Failed causality check when comparing timestamps. - CausalityBreach, - - /// The shared memory segment version is not supported. - SegmentVersionNotSupported, -} - -/// Definition of mutually exclusive clock status exposed to the reader. -#[repr(C)] -#[derive(Debug, Copy, Clone, PartialEq)] -pub enum ClockStatus { - /// The status of the clock is unknown. - /// In this clock status, error-bounded timestamps should not be trusted. - Unknown = 0, - - /// The clock is kept accurate by the synchronization daemon. - /// In this clock status, error-bounded timestamps can be trusted. - Synchronized = 1, - - /// The clock is free running and not updated by the synchronization daemon. - /// In this clock status, error-bounded timestamps can be trusted. - FreeRunning = 2, - - /// The clock has been disrupted and the accuracy of time cannot be bounded. - /// In this clock status, error-bounded timestamps should not be trusted. - Disrupted = 3, -} - -/// 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 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. -/// -/// 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 ClockErrorBound { - /// 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 - /// trusted. This is a useful signal that the communication with the synchronization daemon is - /// has failed, for example. - void_after: TimeSpec, - - /// An absolute upper bound on the accuracy of the `CLOCK_REALTIME` clock with regards to true - /// time at the instant represented by `as_of`. - 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. - pub 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 - /// since `bound_nsec` was computed: - /// `bound_nsec += max_drift_ppb * (now - as_of)` - max_drift_ppb: u32, - - /// 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. - pub clock_disruption_support_enabled: bool, - - /// Padding. - _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 { - /// Create a new ClockErrorBound struct. - pub fn new( - as_of: TimeSpec, - void_after: TimeSpec, - bound_nsec: i64, - disruption_marker: u64, - max_drift_ppb: u32, - clock_status: ClockStatus, - clock_disruption_support_enabled: bool, - ) -> ClockErrorBound { - ClockErrorBound { - as_of, - void_after, - bound_nsec, - disruption_marker, - max_drift_ppb, - clock_status, - clock_disruption_support_enabled, - _padding: [0u8; 7], - } - } - - /// 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 ... - 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 - // 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)?; - - self.compute_bound_at(real, mono) - } - - /// Compute the bound on clock error at a given point in time. - /// - /// The time at which the bound is computed is defined by the (real, mono) pair of timestamps - /// read from the realtime and monotonic clock respectively, *roughly* at the same time. The - /// 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. - fn compute_bound_at( - &self, - real: TimeSpec, - mono: TimeSpec, - ) -> Result<(TimeSpec, TimeSpec, ClockStatus), ShmError> { - // 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); - } - - // 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. - // TODO: this may not be the most ergonomic decision, putting a pin here to revisit this - // decision once the client code is fleshed out. - 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 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. - ClockStatus::FreeRunning - } else { - // If beyond void_after, no guarantee is provided anymore. - ClockStatus::Unknown - } - } - }; - - // Calculate the duration that has elapsed between the instant when the CEB parameters were - // snapshot'ed from the SHM segment (approximated by `as_of`), and the instant when the - // request to calculate the CEB was actually requested (approximated by `mono`). This - // duration is used to compute the growth of the error bound due to local dispersion - // between polling chrony and now. - // - // To avoid miscalculation in case the synchronization daemon is restarted, a - // CLOCK_MONOTONIC is used, since it is designed to not jump. Because we want this to be - // fast, and the exact accuracy is not critical here, we use CLOCK_MONOTONIC_COARSE on - // platforms that support it. - // - // But ... there is a catch. When validating causality of these events that is, `as_of` - // should always be older than `mono`, we observed this test to sometimes fail, with `mono` - // being older by a handful of nanoseconds. The root cause is not completely understood, - // but points to the clock resolution and/or update strategy and/or propagation of the - // updates through the VDSO memory page. See this for details: - // https://t.corp.amazon.com/P101954401. - // - // The following implementation is a mitigation. - // 1. if as_of <= mono is younger than as_of, calculate the duration (happy path) - // 2. if as_of - epsilon < mono < as_of, set the duration to 0 - // 3. if mono < as_of - epsilon, return an error - // - // In short, this relaxes the sanity check a bit to accept some imprecision in the clock - // reading routines. - // - // What is a good value for `epsilon`? - // The CLOCK_MONOTONIC_COARSE resolution is a function of the HZ kernel variable defining - // the last kernel tick that drives this clock (e.g. HZ=250 leads to a 4 millisecond - // resolution). We could use the `clock_getres()` system call to retrieve this value but - // this makes diagnosing over different platform / OS configurations more complex. Instead - // settling on an arbitrary default value of 1 millisecond. - let causality_blur = self.as_of - TimeSpec::new(0, 1000); - - let duration = if mono >= self.as_of { - // Happy path, no causality doubt - mono - self.as_of - } else if mono > causality_blur { - // Causality is "almost" broken. We are within a range that could be due to the clock - // precision. Let's approximate this to equality between mono and as_of. - TimeSpec::new(0, 0) - } else { - // Causality is breached. - return Err(ShmError::CausalityBreach); - }; - - // Inflate the bound on clock error with the maximum drift the clock may be experiencing - // 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, - ); - - // Build the (earliest, latest) interval within which true time exists. - let earliest = real - updated_bound; - let latest = real + updated_bound; - - Ok((earliest, latest, clock_status)) - } -} - -#[cfg(test)] -mod t_lib { - use super::*; - - // Convenience macro to build ClockBoundError for unit tests - macro_rules! clockbound { - (($asof_tv_sec:literal, $asof_tv_nsec:literal), ($after_tv_sec:literal, $after_tv_nsec:literal)) => { - ClockErrorBound::new( - TimeSpec::new($asof_tv_sec, $asof_tv_nsec), // as_of - TimeSpec::new($after_tv_sec, $after_tv_nsec), // void_after - 10000, // bound_nsec - 0, // disruption_marker - 1000, // max_drift_ppb - ClockStatus::Synchronized, // clock_status - true, // clock_disruption_support_enabled - ) - }; - } - - /// Assert the bound on clock error is computed correctly - #[test] - fn compute_bound_ok() { - let ceb = clockbound!((0, 0), (10, 0)); - let real = TimeSpec::new(2, 0); - let mono = TimeSpec::new(2, 0); - - let (earliest, latest, status) = ceb - .compute_bound_at(real, mono) - .expect("Failed to compute bound"); - - // 2 seconds have passed since the bound was snapshot, hence 2 microsec of drift on top of - // the default 10 microsec put in the ClockBoundError data - assert_eq!(earliest.tv_sec(), 1); - 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 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 real = TimeSpec::new(20, 0); // realtime clock way ahead - let mono = TimeSpec::new(4, 0); - - let (earliest, latest, status) = ceb - .compute_bound_at(real, mono) - .expect("Failed to compute bound"); - - // 4 seconds have passed since the bound was snapshot, hence 4 microsec of drift on top of - // the default 10 microsec put in the ClockBoundError data - assert_eq!(earliest.tv_sec(), 19); - 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 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 real = TimeSpec::new(8, 0); - let mono = TimeSpec::new(8, 0); - - let (earliest, latest, status) = ceb - .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); - assert_eq!(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 real = TimeSpec::new(10, 0); - let mono = TimeSpec::new(10, 0); // Passed void_after - - let (earliest, latest, status) = ceb - .compute_bound_at(real, mono) - .expect("Failed to compute bound"); - - // 10 seconds have passed since the bound was snapshot, hence 10 microsec of drift on top of - // the default 10 microsec put in the ClockBoundError data - assert_eq!(earliest.tv_sec(), 9); - 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 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 real = TimeSpec::new(5, 0); - let mono = TimeSpec::new(5, 0); - ceb.max_drift_ppb = 2_000_000_000; - - assert!(ceb.compute_bound_at(real, mono).is_err()); - } - - /// Assert errors are returned if the ClockBoundError data snapshot has been taken after - /// reading clocks at 'now' - #[test] - fn compute_bound_causality_break() { - let ceb = clockbound!((5, 0), (10, 0)); - let real = TimeSpec::new(1, 0); - let mono = TimeSpec::new(1, 0); - - let res = ceb.compute_bound_at(real, mono); - - assert!(res.is_err()); - } -} 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/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-vmclock/src/lib.rs b/clock-bound-vmclock/src/lib.rs deleted file mode 100644 index 92d0be3..0000000 --- a/clock-bound-vmclock/src/lib.rs +++ /dev/null @@ -1,269 +0,0 @@ -use std::ffi::CString; -use tracing::debug; - -use crate::shm_reader::VMClockShmReader; -use clock_bound_shm::{ClockStatus, ShmError, ShmReader}; -use nix::sys::time::TimeSpec; - -pub mod shm; -pub mod shm_reader; -pub mod shm_writer; - -/// 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. - 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 ... - 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 { - 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)); - } - } - } - - debug!("clock_status: {:?}", clock_status); - Ok((earliest, latest, clock_status)) - } -} - -#[cfg(test)] -mod t_lib { - use super::*; - - use clock_bound_shm::{ClockErrorBound, ShmWrite, ShmWriter}; - use std::path::Path; - - use crate::shm::{VMClockClockStatus, VMClockShmBody}; - use crate::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); - } - } - } -} diff --git a/clock-bound/CHANGELOG.md b/clock-bound/CHANGELOG.md new file mode 100644 index 0000000..328bc89 --- /dev/null +++ b/clock-bound/CHANGELOG.md @@ -0,0 +1,132 @@ +# 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.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 + +- 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 diff --git a/clock-bound/Cargo.toml b/clock-bound/Cargo.toml new file mode 100644 index 0000000..f19a31e --- /dev/null +++ b/clock-bound/Cargo.toml @@ -0,0 +1,110 @@ +[package] +name = "clock-bound" +description = "A crate to provide error bounded timestamp intervals." +license = "MIT OR Apache-2.0" +readme = "../README.md" + +authors.workspace = true +categories.workspace = true +edition = "2024" +exclude.workspace = true +keywords.workspace = true +publish = true +repository.workspace = true +version.workspace = true + +[dependencies] +bon = { version = "3.8.0" } +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", +] } +md5 = "0.8.0" +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 } +serde_json = "1.0.145" +thiserror = { version = "2.0", optional = true } +tokio = { version = "1.47.1", features = [ + "fs", + "net", + "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 = [ + "std", + "fmt", + "json", + "registry", +] } +futures = "0.3" +rand = "0.9.2" +tokio-retry = "0.3" + +[dev-dependencies] +approx = "0.5" +hex-literal = "0.4" +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] +client = [] + +daemon = [ + "dep:clap", + "dep:serde", + "dep:tokio", + "dep:tokio-util", + "dep:chrono", + "dep:bytes", + "dep:nom", + "dep:reqwest", + "dep:thiserror", + "dep:tracing-appender", + "nix/feature", + "nix/ioctl", + "nix/time", + "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"] + +[[bin]] +name = "clockbound" +required-features = ["daemon"] + +[package.metadata.generate-rpm] +name = "clockbound" +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" }, +] diff --git a/clock-bound-vmclock/LICENSE b/clock-bound/LICENSE.Apache-2.0 similarity index 99% rename from clock-bound-vmclock/LICENSE rename to clock-bound/LICENSE.Apache-2.0 index 7a4a3ea..d645695 100644 --- a/clock-bound-vmclock/LICENSE +++ b/clock-bound/LICENSE.Apache-2.0 @@ -199,4 +199,4 @@ 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 + 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/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/assets/clockbound.service b/clock-bound/assets/clockbound.service new file mode 100644 index 0000000..d323901 --- /dev/null +++ b/clock-bound/assets/clockbound.service @@ -0,0 +1,16 @@ +[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 +# 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 + +[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..12c372b --- /dev/null +++ b/clock-bound/assets/configure_phc @@ -0,0 +1,105 @@ +#!/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 "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." + exit 0 +else + echo "PHC is not enabled for the ENA driver (phc_enable=0). Continuing." +fi + +# 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 diff --git a/clock-bound/src/bin/clockbound.rs b/clock-bound/src/bin/clockbound.rs new file mode 100644 index 0000000..b931d05 --- /dev/null +++ b/clock-bound/src/bin/clockbound.rs @@ -0,0 +1,37 @@ +//! ClockBound daemon +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 { + #[clap(long, default_value = "/var/log/clockbound")] + log_dir: PathBuf, +} + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() { + let args = Args::parse(); + subscriber::init(&args.log_dir); + let cancellation_token = CancellationToken::new(); + let d = Daemon::construct(cancellation_token.clone()).await; + let d = Box::new(d); + 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/client.rs b/clock-bound/src/client.rs new file mode 100644 index 0000000..7196537 --- /dev/null +++ b/clock-bound/src/client.rs @@ -0,0 +1,799 @@ +//! A client library to communicate with ClockBound daemon. This client library is written in pure Rust. +//! +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}; +pub use crate::vmclock::shm::VMCLOCK_SHM_DEFAULT_PATH; +use crate::vmclock::shm_reader::VMClockShmReader; +use errno::Errno; +use std::ffi::CString; +use std::path::Path; + +/// The `ClockBoundClient` +/// +/// Use it to return current time, the clock error bound and clock status associated with it. +pub struct ClockBoundClient { + clockbound_shm: ClockBoundSHM, + vmclock_shm: VMClockSHM, +} + +impl ClockBoundClient { + /// 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). + /// + /// 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 { + Self::new_with_path(CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH) + } + + /// 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 { + Self::new_with_paths(clockbound_shm_path, VMCLOCK_SHM_DEFAULT_PATH) + } + + /// 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 { + // 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 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() + { + 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_disrupted = match self.vmclock_shm.disruption_marker()? { + Some(marker) => marker != cb_snap.disruption_marker(), + None => false, + }; + + // If the clock is disrupted, overwrite the status + if is_disrupted { + clock_bound_now_result.clock_status = ClockStatus::Disrupted; + } + + Ok(clock_bound_now_result) + } +} + +/// `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 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); + } + + let shm_path = CString::new(clockbound_shm_path).expect("CString::new failed"); + let shm_reader = ShmReader::new(shm_path.as_c_str())?; + + Ok(ClockBoundSHM { + clockbound_shm_path: String::from(clockbound_shm_path), + clockbound_shm_reader: shm_reader, + }) + } + + /// 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() + } +} + +/// `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 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)?); + } + + 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(Debug)] +pub struct ClockBoundError { + pub kind: ClockBoundErrorKind, + pub errno: Errno, + pub detail: String, +} + +impl From for ClockBoundError { + fn from(value: ShmError) -> Self { + 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 { + kind, + errno, + detail, + } + } +} + +#[derive(Hash, PartialEq, Eq, Clone, Debug)] +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, + SegmentNotInitialized, + SegmentMalformed, + CausalityBreach, + SegmentVersionNotSupported, +} + +#[cfg(test)] +mod lib_tests { + use super::*; + use crate::shm::{ClockErrorBound, ShmWrite, ShmWriter}; + use crate::shm::{ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion}; + use crate::vmclock::shm::{VMClockClockStatus, VMClockShmBody}; + use crate::vmclock::shm_writer::{VMClockShmWrite, VMClockShmWriter}; + + use byteorder::{NativeEndian, WriteBytesExt}; + use nix::sys::time::TimeSpec; + use std::fs::{File, OpenOptions}; + use std::io::Write; + use std::path::Path; + /// We make use of tempfile::NamedTempFile to ensure that + /// local files that are created during a test get removed + /// afterwards. + use tempfile::NamedTempFile; + + // TODO: this macro is defined in more than one crate, and the code needs to be refactored to + // remove duplication once most sections are implemented. For now, a bit of redundancy is ok to + // avoid having to think about dependencies between crates. + macro_rules! write_clockbound_memory_segment { + ($file:ident, + $magic_0:literal, + $magic_1:literal, + $segsize:literal, + $version:literal, + $generation:literal) => { + // Build a default ClockErrorBound layout version 2 + let ceb = ClockErrorBoundGeneric::builder() + .clock_disruption_support_enabled(true) + .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. + let slice = unsafe { + ::core::slice::from_raw_parts( + (&ceb as *const ClockErrorBound) as *const u8, + ::core::mem::size_of::(), + ) + }; + + $file + .write_u32::($magic_0) + .expect("Write failed magic_0"); + $file + .write_u32::($magic_1) + .expect("Write failed magic_1"); + $file + .write_u32::($segsize) + .expect("Write failed segsize"); + $file + .write_u16::($version) + .expect("Write failed version"); + $file + .write_u16::($generation) + .expect("Write failed generation"); + $file + .write_all(slice) + .expect("Write failed ClockErrorBound"); + $file.sync_all().expect("Sync to disk failed"); + }; + } + + 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)] + 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, + } + + 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"); + } + + /// 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 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); + } + } + } + + #[test] + fn test_new_with_path_does_not_exist() { + 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 result = ClockBoundClient::new_with_path(clockbound_shm_path); + assert!(result.is_err()); + } + + /// Assert that the shared memory segment can be open, read and and closed. Only a sanity test. + #[test] + fn test_new_with_paths_sanity_check() { + 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_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(); + 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 { + 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, + }; + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + let mut clockbound = + match ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path) { + Ok(c) => c, + Err(e) => { + eprintln!("{:?}", e); + panic!("ClockBoundClient::new_with_paths() failed"); + } + }; + + let now_result = match clockbound.now() { + Ok(result) => result, + Err(e) => { + eprintln!("{:?}", e); + panic!("ClockBoundClient::now() failed"); + } + }; + + assert_eq!(now_result.clock_status, ClockStatus::Disrupted); + } + + #[test] + fn test_new_with_paths_does_not_exist() { + // Test both clockbound and vmclock files do not exist. + 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); + let result = ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path); + assert!(result.is_err()); + + // Test clockbound file exists but vmclock file does not exist. + 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_clockbound_memory_segment!(clockbound_shm_file, 0x414D5A4E, 0x43420200, 800, 2, 10); + 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); + let result = ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path); + assert!(result.is_err()); + 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_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(); + let mut vmclock_shm_file = OpenOptions::new() + .write(true) + .open(vmclock_shm_path) + .expect("open vmclock file failed"); + let vmclock_content = 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, + }; + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + let result = ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path); + assert!(result.is_err()); + } + + /// Assert that the new() runs and returns with a ClockBoundClient if the default shared + /// memory path exists, or with ClockBoundError if shared memory segment does not exist. + /// 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_CLIENT_DEFAULT_PATH).exists() { + assert!(result.is_ok()); + } else { + assert!(result.is_err()); + } + } + + #[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(); + 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"); + // 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(); + 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 { + 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, + }; + write_vmclock_content(&mut vmclock_shm_file, &vmclock_content); + + 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); + + let mut clockbound = + match ClockBoundClient::new_with_paths(clockbound_shm_path, vmclock_shm_path) { + Ok(c) => c, + Err(e) => { + eprintln!("{:?}", e); + panic!("ClockBoundClient::new_with_paths() failed"); + } + }; + + // Validate now() has a Result with a successful value. + let now_result = clockbound.now(); + assert!(now_result.is_ok()); + + // Write out data with a extremely high max_drift_ppb value so that + // the client will have an error when calling now(). + 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(ClockErrorBoundLayoutVersion::V3); + writer.write(&ceb); + + // Validate now has Result with an error. + let now_result = clockbound.now(); + assert!(now_result.is_err()); + } + + /// Test conversions from ShmError to ClockBoundError. + + #[test] + fn test_shmerror_clockbounderror_conversion_syscallerror() { + let errno = Errno(1); + 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, clockbounderror.detail); + } + + #[test] + fn test_shmerror_clockbounderror_conversion_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!( + ClockBoundErrorKind::SegmentNotInitialized, + clockbounderror.kind + ); + assert_eq!(Errno(0), clockbounderror.errno); + assert_eq!(detail, clockbounderror.detail); + } + + #[test] + fn test_shmerror_clockbounderror_conversion_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!(detail, clockbounderror.detail); + } + + #[test] + fn test_shmerror_clockbounderror_conversion_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!(detail, clockbounderror.detail); + } +} diff --git a/clock-bound/src/daemon.rs b/clock-bound/src/daemon.rs new file mode 100644 index 0000000..897ac21 --- /dev/null +++ b/clock-bound/src/daemon.rs @@ -0,0 +1,300 @@ +//! Clock Synchronization Daemon + +pub mod async_ring_buffer; + +pub mod io; + +pub mod clock_parameters; + +pub mod clock_state; + +pub mod clock_sync_algorithm; + +pub mod time; + +pub mod event; + +pub mod receiver_stream; + +pub mod subscriber; + +pub mod selected_clock; + +use std::sync::Arc; + +use crate::daemon::{ + clock_sync_algorithm::{ClockSyncAlgorithm, Selector, source::NtpSource}, + io::ClockDisruptionEvent, + io::ntp::{DaemonInfo, NTPSourceReceiver, NTPSourceSender}, + receiver_stream::{ReceiverStream, RoutableEvent}, + 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, +}; +use tokio_util::task::TaskTracker; + +use rand::{RngCore, rng}; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +/// The maximum dispersion growth every second +/// +/// 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. +/// +/// This number is based on CPU spec sheet error tolerances +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, + clock_sync_algorithm: ClockSyncAlgorithm, + receiver_stream: ReceiverStream, + clock_disruption_receiver: watch::Receiver, + 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(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 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 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(); + 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); + 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 = io_front_end.phc().map(|io_phc| { + clock_sync_algorithm::source::Phc::new( + io_phc.device_path().to_owned(), + MAX_DISPERSION_GROWTH, + ) + }); + 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(ntp_sources) + .maybe_phc(phc) + .selected_clock(selected_clock.clone()) + .selector(Selector::new(MAX_DISPERSION_GROWTH)) + .build(); + + // 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(); + + // 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_disruption_receiver.clone(), + clock_state_cancellation_token.clone(), + disruption_marker, + clock_disruption_support_enabled, + ); + (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, + receiver_stream, + clock_disruption_receiver, + cancellation_token, + clock_state_handle, + } + } + + /// 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({ + #[expect( + clippy::missing_panics_doc, + reason = "struct always initialized with Some" + )] + 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 + Ok(()) = self.clock_disruption_receiver.changed() => { + self.handle_disruption(); + } + 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; + } + } + } + } + + fn handle_event(&mut self, routable_event: RoutableEvent) { + if let Some(params) = self.clock_sync_algorithm.feed(routable_event) { + use crate::daemon::async_ring_buffer::SendError; + + 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 + info!( + ?clock_parameters, + "Trying to send a value when there was a disruption event. dropping." + ); + } + Err(SendError::BufferClosed(e)) => { + error!( + ?e, + "Trying to send a value when the buffer is closed. Panicking." + ); + panic!("Unable to communicate with clock state. {e:?}"); + } + } + } + } + + /// Handle a clock disruption event + fn handle_disruption(&mut self) { + let Self { + io_front_end: _, + clock_sync_algorithm, + receiver_stream, + clock_disruption_receiver, + cancellation_token: _, + clock_state_handle, + } = self; + + let ClockStateHandle { + clock_state: _, + tx, + cancellation_token: _, + task_tracker: _, + } = clock_state_handle; + + let val = clock_disruption_receiver.borrow_and_update().clone(); + if val.disruption_marker.is_some() { + 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 + /// - `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) + } +} + +struct ClockStateHandle { + clock_state: Option, + tx: Sender, + cancellation_token: CancellationToken, + task_tracker: TaskTracker, +} 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..dd24c48 --- /dev/null +++ b/clock-bound/src/daemon/async_ring_buffer.rs @@ -0,0 +1,480 @@ +//! 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<(), SendError> { + let mut guard = self.inner.lock().unwrap(); + if guard.receiver_dropped { + return Err(BufferClosedError.into()); + } + if let Some(Side::Receiver) = guard.disruption_handled { + return Err(SendError::Disrupted(value)); + } + guard.push(value); + drop(guard); + self.notifier.notify_one(); + 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 { + 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 the 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(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); + } + } + self.notifiee.notified().await; + } + } + + /// 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 { + 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, + disruption_handled: Option, +} + +impl Buffer { + fn new(capacity: usize) -> Self { + Self { + data: VecDeque::with_capacity(capacity), + capacity, + sender_dropped: false, + receiver_dropped: false, + disruption_handled: None, + } + } + + /// 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() + } + + 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)] +#[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 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); + } + + // 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(); + } +} diff --git a/clock-bound/src/daemon/clock_parameters.rs b/clock-bound/src/daemon/clock_parameters.rs new file mode 100644 index 0000000..441486a --- /dev/null +++ b/clock-bound/src/daemon/clock_parameters.rs @@ -0,0 +1,289 @@ +//! Calculated clock parameters +//! +//! The output of the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) + +use crate::daemon::time::{ + Duration, Instant, TscCount, + tsc::{Period, Skew}, +}; + +/// Clock parameters +/// +/// These values are calculated by the [`ClockSyncAlgorithm`](super::clock_sync_algorithm) +/// and used by the [`ClockState`](super::clock_state) +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +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, + /// 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 { + /// 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 +/// 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 +} + +#[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 + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // 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 + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // 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 + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // 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 + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // 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 + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // 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 + 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); + } + + #[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 + as_of_monotonic: Instant::from_days(1) + Duration::from_nanos(500), // 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 + 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 new file mode 100644 index 0000000..713e628 --- /dev/null +++ b/clock-bound/src/daemon/clock_state.rs @@ -0,0 +1,570 @@ +//! Adjust system clock and clockbound shared memory +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; + +use crate::daemon::MAX_DISPERSION_GROWTH_PPB; +use crate::daemon::async_ring_buffer::Receiver; +use crate::daemon::clock_parameters::ClockParameters; +#[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::ClockDisruptionEvent; +use crate::daemon::io::tsc::ReadTscImpl; +use crate::daemon::subscriber::CLOCK_METRICS_TARGET; +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, + 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 +/// of UTC (`ClockBound` clock), and `ClockStateWriter` which manages writing +/// the `ClockErrorBound` to SHM segment for the client to read. +pub(crate) struct ClockState { + state_writer: Box, + clock_adjuster: Box, + clock_parameters: Option, + interval: tokio::time::Interval, + clock_disruption_receiver: watch::Receiver, + clock_params_receiver: Receiver, + cancellation_token: CancellationToken, +} + +impl ClockState { + pub fn new( + 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)); + Self { + state_writer: clock_state_writer, + clock_adjuster, + interval, + clock_params_receiver, + clock_disruption_receiver, + clock_parameters: None, + cancellation_token, + } + } + + pub fn construct( + clock_params_receiver: Receiver, + clock_disruption_receiver: watch::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. + // + // 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(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) + .disruption_marker(disruption_marker) + .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), + clock_params_receiver, + clock_disruption_receiver, + cancellation_token, + ) + } + + 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"); + // 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! { + 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); + }, + () = 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 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" + ); + } + } + + /// 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); + + // 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); + + // Handle SHM0 (`ClockErrorboundV2`) after SHM1. + let clock_status_v2 = self.determine_clock_error_bound_v2_status(parameters); + self.state_writer + .handle_clock_parameters_shm0(parameters, clock_status_v2); + } + } + + /// 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(crate) fn handle_clock_parameters( + &mut self, + // This is needed to tell ClockAdjust what frequency to use. + clock_parameters: ClockParameters, + ) { + self.clock_parameters = Some(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). + 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 { + clock_adjuster, + state_writer: clock_state_writer, + clock_params_receiver, + clock_disruption_receiver, + interval: _, + clock_parameters, + cancellation_token: _, + } = self; + + 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(disruption_marker); + + tracing::info!("Handled clock disruption event"); + } + } +} + +#[cfg(test)] +mod tests { + use mockall::predicate::eq; + use rstest::rstest; + + 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::*; + + 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), + as_of_monotonic: Instant::new(0), + } + } + + #[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() + .never() + .return_const(()); + + let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); + mock_clock_state_writer + .expect_handle_clock_parameters_shm0() + .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); + clock_state.handle_clock_parameters(clock_parameters.clone()); + assert_eq!(clock_state.clock_parameters, Some(clock_parameters)); + } + + #[tokio::test] + async fn handle_disruption() { + 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), + as_of_monotonic: Instant::from_nanos(1000), + }; + let expected_clock_parameters = clock_parameters.clone(); + mock_clock_adjuster + .expect_handle_disruption() + .once() + .with(eq(disruption_marker)) + .return_const(()); + let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); + mock_clock_state_writer + .expect_handle_disruption() + .once() + .withf(move |param: &ClockParameters, marker: &u64| { + *param == expected_clock_parameters && *marker == 123 + }) + .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_disruption_sender + .send(ClockDisruptionEvent { + disruption_marker: Some(disruption_marker), + }) + .unwrap(); + clock_state.handle_disruption(); + } + + #[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() + .never() + .return_const(()); + + let mut mock_clock_state_writer: MockClockStateWrite = MockClockStateWrite::new(); + mock_clock_state_writer + .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 (_, 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; + clock_state.handle_tick(tokio::time::Instant::now()); + } + + #[tokio::test(start_paused = true)] + async fn handle_tick_with_parameters() { + let mut sequence = mockall::Sequence::new(); + 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(); + + let expected_clock_params_clone = expected_clock_params.clone(); + mock_clock_adjuster + .expect_handle_clock_parameters() + .once() + .withf(move |actual_instant, actual_clock_params| { + *actual_instant == expected_instant + && *actual_clock_params == expected_clock_params_clone + }) + .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 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_shm0() + .once() + .withf(move |actual_clock_params, actual_clock_status| { + *actual_clock_params == expected_clock_params_clone + && *actual_clock_status == expected_clock_error_bound_v2_status + }) + .in_sequence(&mut sequence) + .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); + 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(); + let (_, clock_disruption_receiver) = + watch::channel::(ClockDisruptionEvent::default()); + 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, + clock_disruption_receiver, + 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_adjust.rs b/clock-bound/src/daemon/clock_state/clock_adjust.rs new file mode 100644 index 0000000..f10102f --- /dev/null +++ b/clock-bound/src/daemon/clock_state/clock_adjust.rs @@ -0,0 +1,397 @@ +//! 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::{error, info, trace}; + +use crate::{ + daemon::{ + clock_parameters::ClockParameters, + time::{Duration, tsc::Skew}, + }, + shm::ClockStatus, +}; + +mod ntp_adjtime; +mod state_machine; +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 { + fn handle_clock_parameters( + &mut self, + now: tokio::time::Instant, + 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 { + state: State, + ntp_adjtime: T, +} + +impl ClockAdjust for ClockAdjuster { + fn handle_clock_parameters( + &mut self, + now: tokio::time::Instant, + clock_parameters: &ClockParameters, + ) { + self.handle_event(now, clock_parameters); + } + + 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: _, + 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 + .ntp_adjtime + .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}"); + } + _ => {} + } + *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.. + 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, + } + } +} + +use state_machine::{ + ClockAdjusted, Disrupted, InitialPhaseCorrectHalted, InitialSnapshotRetrieved, Initialized, + PhaseCorrectHalted, SnapshotRetrieved, State, +}; + +impl ClockAdjuster { + pub fn new(ntp_adjtime: T) -> Self { + Self { + state: State::Initialized(Initialized), + ntp_adjtime, + } + } + + /// 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. + /// + /// 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.to_initial_phase_correct_halted(&self.ntp_adjtime), + ); + } + State::Initialized(inner) => { + self.state = State::InitialPhaseCorrectHalted( + inner.to_initial_phase_correct_halted(&self.ntp_adjtime, clock_params), + ); + } + 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::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::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::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"); + } + } + 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"); + } + } + } + } +} + +#[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 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), + as_of_monotonic: Instant::new(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; + 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); + } + + #[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() + InitialPhaseCorrectHalted::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() + InitialSnapshotRetrieved::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() + ClockAdjusted::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() + PhaseCorrectHalted::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() + SnapshotRetrieved::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 new file mode 100644 index 0000000..cf4e120 --- /dev/null +++ b/clock-bound/src/daemon/clock_state/clock_adjust/ntp_adjtime.rs @@ -0,0 +1,323 @@ +//! 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`. +#[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 + } +} +#[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 +/// 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 {} + +#[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. + /// + /// # 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/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..4c05168 --- /dev/null +++ b/clock-bound/src/daemon/clock_state/clock_adjust/state_machine.rs @@ -0,0 +1,964 @@ +//! `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 +//! │ (unreliable) │ | (unreliable) | and clock adjustments are reset in its handler) +//! └─────────────────┘ └─────────────────┘ +//! | | +//! | halt PLL | halt PLL +//! | step clock | +//! v | +//! ┌──────────────────────────┐ | +//! │ InitialPhaseCorrectHalted│ <────────┘ +//! │ (unreliable) │ +//! └──────────────────────────┘ +//! | wait PHASE_CORRECTION_HALT_DURATION, then take snapshot A +//! | +//! v +//! ┌──────────────────────────┐ +//! │ InitialSnapshotRetrieved │ +//! │ (unreliable) │ +//! └──────────────────────────┘ +//! | wait INITIAL_SNAPSHOT_RETRIEVED_DURATION then +//! | take snapshot B + +//! | calc frequency + +//! v apply corrections +//! ┌─────────────────┐ +//! │ ClockAdjusted │ +//! │ 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│ +//! | (reliable). | │ (reliable) │ +//! └──────────────────┘ └──────────────────┘ +//! │ ^ +//! │ │ +//! └────────────────────────────────────────┘ +//! wait PHASE_CORRECTION_HALT_DURATION, then take snapshot A +//! ``` +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 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)) + .expect("failed to halt phase correction"); + debug!("Disrupted now transitioning to InitialPhaseCorrectHalted"); + InitialPhaseCorrectHalted { + instant: tokio::time::Instant::now(), + } + } +} +#[derive(Debug, PartialEq)] +pub(super) struct Initialized; +impl Initialized { + #[must_use] + #[allow(clippy::unused_self)] + pub(super) fn to_initial_phase_correct_halted( + &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)) + .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()) + .expect("failed to step clock"); + debug!("Initialized now transitioning to InitialPhaseCorrectHalted"); + InitialPhaseCorrectHalted { + instant: tokio::time::Instant::now(), + } + } +} +#[derive(Debug, PartialEq)] +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 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), + } + } +} +#[derive(Debug, PartialEq)] +pub(super) struct InitialSnapshotRetrieved { + pub(super) instant: tokio::time::Instant, + 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 to_clock_adjusted( + &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) + .expect("failed to adjust clock frequency and phase correction"); + debug!("InitialSnapshotRetrieved now transitioning to ClockAdjusted"); + ClockAdjusted { + instant: tokio::time::Instant::now(), + } + } +} +#[derive(Debug, PartialEq)] +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 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)) + .expect("failed to halt phase correction"); + debug!("ClockAdjusted now transitioning to PhaseCorrectHalted"); + PhaseCorrectHalted { + instant: tokio::time::Instant::now(), + } + } +} +#[derive(Debug, PartialEq)] +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 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), + } + } +} +#[derive(Debug, PartialEq)] +pub(super) struct SnapshotRetrieved { + pub(super) instant: tokio::time::Instant, + 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 to_clock_adjusted( + &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) + .expect("failed to adjust clock frequency and phase correction"); + debug!("SnapshotRetrieved now transitioning to ClockAdjusted"); + 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_seconds = + (new_tsc_value.get() - old_tsc_value.get()) as f64 * clock_params.period.get(); + 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 = + (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() + .expect("failed to retrieve ntp_adjtime parameters from kernel"); + let system_clock = SystemClockMeasurement::now(); + Self { + system_clock, + kernel_state, + } + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + 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), + as_of_monotonic: Instant::new(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.to_initial_phase_correct_halted(&mock_ntp_adjtime); + } + + #[test] + #[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 + .expect_apply_phase_correction() + .once() + .return_once(move |_: Duration| Err(NtpAdjTimeError::BadState(1))); + let disrupted = Disrupted; + let _ = disrupted.to_initial_phase_correct_halted(&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 + .to_initial_phase_correct_halted(&mock_ntp_adjtime, &test_clock_parameters()); + } + + #[test] + #[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 + .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 + .to_initial_phase_correct_halted(&mock_ntp_adjtime, &test_clock_parameters()); + } + + #[test] + #[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 + .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 + .to_initial_phase_correct_halted(&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.to_initial_snapshot_retrieved(&mock_ntp_adjtime); + } + + #[test] + #[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 + .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.to_initial_snapshot_retrieved(&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.to_clock_adjusted( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } + + #[test] + #[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 + .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.to_clock_adjusted( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } + + #[test] + #[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 + .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.to_clock_adjusted( + &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.to_phase_correct_halted(&mock_ntp_adjtime); + } + + #[test] + #[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 + .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.to_phase_correct_halted(&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.to_snapshot_retrieved(&mock_ntp_adjtime); + } + + #[test] + #[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 + .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.to_snapshot_retrieved(&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.to_clock_adjusted( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } + + #[test] + #[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 + .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.to_clock_adjusted( + &mock_ntp_adjtime, + &test_clock_parameters(), + &test_clock_snapshot(), + ); + } + + #[test] + #[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 + .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.to_clock_adjusted( + &mock_ntp_adjtime, + &test_clock_parameters(), + &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), + as_of_monotonic: Instant::new(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), + as_of_monotonic: Instant::new(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), + as_of_monotonic: Instant::new(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), + as_of_monotonic: Instant::new(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), + as_of_monotonic: Instant::new(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), + as_of_monotonic: Instant::new(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), + as_of_monotonic: Instant::new(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() + ); + } +} 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..d3b8c3f --- /dev/null +++ b/clock-bound/src/daemon/clock_state/clock_state_writer.rs @@ -0,0 +1,539 @@ +//! Write clockbound shared memory and manage clock state +use nix::sys::time::TimeSpec; +use tracing::info; + +use crate::{ + daemon::{ + clock_parameters::ClockParameters, + io::tsc::ReadTscImpl, + time::{ + ClockExt, Duration, Instant, TscCount, + clocks::{ClockBound, RealTime}, + tsc::{Period, Skew}, + }, + }, + shm::{ + ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ClockStatus, + ShmWrite, ShmWriter, + }, +}; + +/// 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_disruption_support_enabled: bool, + // 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, +} + +#[cfg_attr(test, mockall::automock)] +pub trait ClockStateWrite: Send + Sync { + fn initialize_ceb_v2_shm(&mut self); + 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, + ); + fn handle_disruption(&mut self, clock_parameters: &ClockParameters, new_disruption_marker: u64); +} + +impl ClockStateWrite for ClockStateWriter { + /// 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 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 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_shm0( + &mut self, + clock_parameters: &ClockParameters, + clock_status: ClockStatus, + ) { + // 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 + /// + /// 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, + 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_0: _, + shm_writer_1: _, + max_drift_ppb: _, + disruption_marker, + } = self; + *disruption_marker = new_disruption_marker; + + // Write to the latest and greatest SHM segment first, older one(s) afterwards. + self.write_shm1(clock_parameters, ClockStatus::Disrupted); + self.write_shm0(clock_parameters, ClockStatus::Disrupted); + + tracing::info!("Handled clock disruption event"); + } +} + +#[bon::bon] +impl ClockStateWriter { + #[builder] + pub fn new( + clock_disruption_support_enabled: bool, + shm_writer_0: T, + shm_writer_1: T, + max_drift_ppb: u32, + disruption_marker: u64, + ) -> Self { + Self { + clock_disruption_support_enabled, + shm_writer_0, + shm_writer_1, + max_drift_ppb, + disruption_marker, + } + } + + /// 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, 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(as_of) + .void_after(void_after) + .bound_nsec(bound_nsec) + .disruption_marker(self.disruption_marker) + .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); + } + + /// 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(); + + // 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); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::time::{Duration, Instant, TscCount, tsc::Period}; + use mockall::mock; + use rstest::rstest; + + 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), + as_of_monotonic: Instant::from_nanos(time_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 = 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; + 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) + .disruption_marker(disruption_marker) + .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); + 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(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) + .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_v3 == *ceb) + .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.write_shm0(&clock_parameters, clock_status); + clock_state_writer.write_shm1(&clock_parameters, clock_status); + } + + #[test] + fn handle_clock_parameters_shm0() { + 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() + .withf(move |ceb: &ClockErrorBound| { + ceb.void_after() + == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(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 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(max_drift_ppb) + .disruption_marker(disruption_marker) + .build(); + clock_state_writer.handle_clock_parameters_shm0(&clock_parameters, clock_status); + } + + #[test] + #[should_panic] + 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) + .time_nanos(1_000_000_000) + .call(); + let clock_disruption_support_enabled = false; + let max_drift_ppb = 0; + let disruption_marker = 0; + 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_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_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] + fn test_initialize_ceb_v2_shm() { + let clock_disruption_support_enabled = false; + let expected_max_drift_ppb = 15_000; + 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; + let max_drift_ppb = 0; + let initial_disruption_marker = 0; + let final_disruption_marker = 1; + 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() + == ceb.as_of() + TimeSpec::from_duration(std::time::Duration::from_secs(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 + && 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() + .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_0(shm_writer_0) + .shm_writer_1(shm_writer_1) + .max_drift_ppb(max_drift_ppb) + .disruption_marker(initial_disruption_marker) + .build(); + assert_eq!( + clock_state_writer.disruption_marker, + initial_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/daemon/clock_sync_algorithm.rs b/clock-bound/src/daemon/clock_sync_algorithm.rs new file mode 100644 index 0000000..579ac6e --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm.rs @@ -0,0 +1,302 @@ +//! Feed forward clock sync algorithm +#![cfg_attr( + not(test), + 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::{IpAddr, SocketAddr}, + sync::Arc, +}; + +pub use ring_buffer::RingBuffer; + +use crate::daemon::{ + clock_parameters::ClockParameters, event, io::ntp::LINK_LOCAL_ADDRESS, + receiver_stream::RoutableEvent, selected_clock::SelectedClockSource, subscriber::PRIMER_TARGET, +}; + +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, + // A Vector of ff algorithms for ntp source reference clocks + 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 + selector: Selector, +} + +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))] + { + use crate::daemon::event::TscRtt; + let Some(system_clock) = routable_event.system_clock() else { + 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_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; + + 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(test)] + { + self.feed_repro(routable_event) + } + } + + /// 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> { + // 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(&mut self.ntp_sources, sender_address, event) + } + 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?; + + let output = self.selector.update(clock_parameters, source_info); + if output.is_some() { + Self::update_selected_clock(&self.selected_clock, source_info); + } + + 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 stratum = event.data().stratum; + + link_local + .feed(event) + .map(|params| (params, SourceInfo::LinkLocal(stratum))) + } + + /// Feed event into ntp source + fn feed_ntp_source( + ntp_sources: &mut [source::NtpSource], + sender_address: SocketAddr, + 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))) + } + + /// 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 { + 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(), + } + } + + /// 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, + ntp_sources, + phc, + selected_clock, + selector, + } = self; + + selected_clock.set_to_none(); + link_local.handle_disruption(); + 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"); + tracing::info!( + target: PRIMER_TARGET, + disruption = "Disruption occurred", + "handled disruption" + ); + } +} + +#[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}, + }; + + use super::*; + + #[test] + #[tracing_test::traced_test] + fn feed_serializes_events() { + // 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 event = RoutableEvent::LinkLocal(event); + + let mut csa = ClockSyncAlgorithm::builder() + .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(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)); + } + + #[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/ff.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs new file mode 100644 index 0000000..27d4725 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff.rs @@ -0,0 +1,33 @@ +//! Flavours of clock sync algorithms +//! +//! This is where math happens + +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 +#[derive(Debug, Clone, PartialEq)] +pub struct LocalPeriodAndError { + /// period calculation + pub period_local: Period, + /// period error + pub 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/event_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs new file mode 100644 index 0000000..7517b3e --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer.rs @@ -0,0 +1,42 @@ +//! Local and Estimate ring buffers used within a Feed Forward Clock Sync Algorithm + +mod local; +pub use local::{FeedError, 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/estimate.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs new file mode 100644 index 0000000..c5bc479 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/estimate.rs @@ -0,0 +1,436 @@ +//! An estimate event buffer +use std::num::NonZeroUsize; + +use super::Local; +use crate::daemon::{ + clock_sync_algorithm::RingBuffer, + event::TscRtt, + time::{TscCount, tsc::Period}, +}; + +/// An estimate ring buffer +/// +/// # 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, PartialEq, Eq)] +#[cfg_attr(test, derive(bon::Builder))] // Workaround the strict invariants that make testing un-ergonomic +pub struct Estimate { + /// 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); + } + + /// 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 { + /// 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 < local.window() { + // 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 926 seconds to 1025. Last one triggers + TestEvent::pre_and_rtt((i + 925) * 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 1024 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 926 seconds to 1025. Last one triggers + TestEvent::pre_and_rtt((i + 925) * 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(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 { + // 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()); + } + } + + #[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 new file mode 100644 index 0000000..b0030e2 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/event_buffer/local.rs @@ -0,0 +1,288 @@ +//! Local event buffer +use std::{fmt::Debug, num::NonZeroUsize}; + +use crate::daemon::{ + clock_sync_algorithm::RingBuffer, + event::TscRtt, + time::{Duration, TscCount, tsc::Period}, +}; + +/// A local buffer of events +/// +/// 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::expunge_old_events`] to purge old values +/// +/// ## Starvation +/// 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 +/// or even dynamically modified. Changes subject to experimentation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Local { + /// 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, + + window: Duration, +} + +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, + window: Self::SKM_WINDOW, + } + } + + pub fn with_window(capacity: NonZeroUsize, window: Duration) -> Self { + Self { + inner: RingBuffer::new(capacity), + rtt_threshold_multiplier: 5, + window, + } + } + + /// 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, + 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() + } + + /// Returns true if empty + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Clear the internal buffer + pub fn handle_disruption(&mut self) { + let Self { + inner, + rtt_threshold_multiplier: _rtt, + window: _, + } = self; + inner.clear(); + } + + pub fn rtt_threshold_multiplier(&self) -> usize { + self.rtt_threshold_multiplier + } +} + +impl Local { + /// Feed the ring buffer with a new event + /// + /// # Errors + /// - the event is older than events already in the buffer + pub fn feed(&mut self, event: T) -> Result<(), FeedError> { + 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(()) + } + + /// 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 + 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.window / period); + + // 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); + } +} + +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 }, +} + +#[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 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.expunge_old_events(period); + + // 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); + } + } + + #[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 new file mode 100644 index 0000000..b2e9885 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/ntp.rs @@ -0,0 +1,863 @@ +//! The NTP 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::{ + Clock, Duration, Instant, TscDiff, + clocks::MonotonicCoarse, + tsc::{Period, Skew}, + }, +}; + +/// Feed forward time synchronization algorithm for a single NTP source +#[derive(Debug, Clone, PartialEq)] +pub struct Ntp { + /// 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 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 1024. + /// + /// # 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(), + 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`]. + 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) + // sample buffers + 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 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: + // - `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 + #[expect(clippy::missing_panics_doc, reason = "comment above")] + 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, + as_of_monotonic, + }; + + 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 + /// + /// 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> { + let event_rtt = event.rtt(); + 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"); + } + } + // 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 + /// + /// 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 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. + // + // 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 = oldest.calculate_period(newest); + + 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 + 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 local_period_and_error = old.calculate_period_with_error(new); + + tracing::debug!( + ?old, + ?new, + ?local_period_and_error, + "Calculated local period and error" + ); + Some(local_period_and_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; + 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; + } + + 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::{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 + + 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)); + } + + #[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) + ); + } + + #[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) + ); + } + + #[test] + fn feed_two_events() { + 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)) + .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/ff/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs new file mode 100644 index 0000000..fd46302 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/phc.rs @@ -0,0 +1,780 @@ +//! 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::{ + Clock, Duration, TscDiff, + clocks::MonotonicCoarse, + tsc::{Period, Skew}, + }, +}; + +/// Feed forward time synchronization algorithm for a single PHC source +#[derive(Debug, Clone, PartialEq)] +pub struct Phc { + /// 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`] + 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. + /// + /// # 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 < WINDOW / 2, + "Must be able to get at least 2 samples in local buffer" + ); + 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::with_window(local_capacity, WINDOW), + staging: event_buffer::Local::new(staging_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`]. + 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) + // sample buffers + 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 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: + // - `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 + #[expect(clippy::missing_panics_doc, reason = "comment above")] + 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, + as_of_monotonic, + }; + + 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 + /// + /// 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 = "returning passed in value is idiomatic" + )] + fn feed_internal_buffers(&mut self, event: event::Phc) -> Result> { + let event_rtt = event.rtt(); + self.local.feed(event.clone())?; + self.staging.feed(event)?; + + if let Some(uc) = self.uncorrected_clock { + self.local.expunge_old_events(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"); + } + } + // 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 + /// + /// Clears all event buffers and prior-calculations. + pub fn handle_disruption(&mut self) { + // Destructure pattern makes handling new fields mandatory + let Self { + local, + staging, + estimate, + clock_parameters, + uncorrected_clock, + max_dispersion: _, // value currently does not change + } = self; + + local.handle_disruption(); + staging.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 = oldest.calculate_period(newest); + + 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 + 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 local_period_and_error = old.calculate_period_with_error(new); + + tracing::debug!( + ?old, + ?new, + ?local_period_and_error, + "Calculated local period and error" + ); + Some(local_period_and_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(Duration::from_secs(20), 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/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..6e407ae --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ff/uncorrected_clock.rs @@ -0,0 +1,23 @@ +//! A naive uncorrected clock + +use crate::daemon::time::{Instant, TscCount, 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, +} + +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/clock_sync_algorithm/ring_buffer.rs b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs new file mode 100644 index 0000000..985fc9e --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/ring_buffer.rs @@ -0,0 +1,457 @@ +//! Ring buffers used internally in the FF clock sync algorithm + +use std::{collections::VecDeque, fmt::Debug, num::NonZeroUsize}; + +use crate::daemon::{event::TscRtt, time::TscCount}; + +/// 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`]. +#[derive(Debug, Clone, PartialEq, Eq)] +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.is_full() { + 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 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(&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 { + 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()); + 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); + } + + #[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(TscCount::new(250)); + 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(TscCount::new(100)); + assert!(buffer.is_empty()); + } + + #[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/selector.rs b/clock-bound/src/daemon/clock_sync_algorithm/selector.rs new file mode 100644 index 0000000..879421b --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/selector.rs @@ -0,0 +1,301 @@ +//! 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 + as_of_monotonic: Instant::from_days(1), // 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 + as_of_monotonic: Instant::from_days(1), // 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 + as_of_monotonic: Instant::from_days(1), // 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 + as_of_monotonic: Instant::from_days(1), // 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 + as_of_monotonic: Instant::from_days(1), // 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 + as_of_monotonic: Instant::from_days(1), // 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 + as_of_monotonic: Instant::from_days(1), // 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 + as_of_monotonic: Instant::from_days(1), // 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) + } +} 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..af577d4 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/source.rs @@ -0,0 +1,16 @@ +//! 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; +mod ntp_source; +mod phc; + +pub use link_local::LinkLocal; +pub use ntp_source::NtpSource; +pub use phc::Phc; 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..b150086 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/link_local.rs @@ -0,0 +1,42 @@ +//! Link local source + +use crate::daemon::clock_parameters::ClockParameters; +use crate::daemon::clock_sync_algorithm::ff; +use crate::daemon::event; +use crate::daemon::time::Duration; +use crate::daemon::time::tsc::Skew; + +/// A Link Local reference clock source +/// +/// Wraps around an NTP feed-forward clock-sync algorithm +#[derive(Debug, Clone, PartialEq)] +pub struct LinkLocal { + inner: ff::Ntp, +} + +impl LinkLocal { + const POLL_INTERVAL: Duration = Duration::from_secs(2); + + /// Create a new Link Local reference clock source + pub fn new(max_dispersion: Skew) -> Self { + Self { + inner: ff::Ntp::new(Self::POLL_INTERVAL, max_dispersion), + } + } + + /// Feed an event into the link local NTP clock-sync algorithm + #[tracing::instrument(level = "info", skip_all, fields(source = "link_local"))] + 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/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..1b9222a --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/ntp_source.rs @@ -0,0 +1,63 @@ +//! NTP Source source + +use std::net::SocketAddr; + +use crate::daemon::clock_parameters::ClockParameters; +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; +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); + + /// 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::POLL_INTERVAL, 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::POLL_INTERVAL, 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, fields(source = %self.source_address))] + 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/clock_sync_algorithm/source/phc.rs b/clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs new file mode 100644 index 0000000..2525659 --- /dev/null +++ b/clock-bound/src/daemon/clock_sync_algorithm/source/phc.rs @@ -0,0 +1,47 @@ +//! PHC source + +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: String, + 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: String, 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))] + 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.rs b/clock-bound/src/daemon/event.rs new file mode 100644 index 0000000..2d57d58 --- /dev/null +++ b/clock-bound/src/daemon/event.rs @@ -0,0 +1,61 @@ +//! Clock synchronization events +//! +//! These are the in-memory representations of NTP and PHC reads. +mod ntp; +pub use ntp::{Ntp, NtpData, Stratum, TryFromU8Error, ValidStratumLevel}; + +mod phc; +pub use phc::{Phc, PhcData}; + +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 { + /// NTP Event + Ntp(Ntp), + /// PHC Event + Phc(Phc), +} + +/// 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() + } + + /// The TSC midpoint + fn tsc_midpoint(&self) -> TscCount { + self.tsc_pre().midpoint(self.tsc_post()) + } +} + +/// Struct containing a system clock read and a TSC read +#[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, +} + +impl SystemClockMeasurement { + /// Create a new [`SystemClockMeasurement`] + pub fn now() -> Self { + let pre = read_timestamp_counter_begin(); + let system_time = RealTime.get_time(); + let post = read_timestamp_counter_end(); + let tsc = pre.midpoint(post); + 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 new file mode 100644 index 0000000..1ec2653 --- /dev/null +++ b/clock-bound/src/daemon/event/ntp.rs @@ -0,0 +1,772 @@ +//! NTP Time synchronization events +use std::{ + error::Error, + fmt::{Display, Formatter}, +}; + +use super::TscRtt; +use crate::daemon::{ + clock_sync_algorithm::ff::{LocalPeriodAndError, UncorrectedClock}, + time::{Duration, Instant, TscCount, tsc::Period}, +}; + +/// 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, serde::Serialize, serde::Deserialize)] +pub struct Ntp { + /// TSC value before sending event + tsc_pre: TscCount, + /// TSC value after sending event + tsc_post: TscCount, + /// NTP Packet data + data: NtpData, + #[cfg(not(test))] + #[serde(skip)] + system_clock: Option, +} + +#[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, + #[cfg(not(test))] system_clock: Option, + ) -> Option { + if tsc_post > tsc_pre { + Some(Self { + tsc_pre, + tsc_post, + data: ntp_data, + #[cfg(not(test))] + system_clock, + }) + } 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 + } + + /// system time getter + #[cfg(not(test))] + pub fn system_clock(&self) -> Option<&super::SystemClockMeasurement> { + self.system_clock.as_ref() + } + + /// 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 + /// - `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 + /// + /// # Panics + /// - 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) + } + + /// 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 (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 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 old_server_midpoint = old + .data + .server_recv_time + .midpoint(old.data.server_send_time); + let new_server_midpoint = new + .data + .server_recv_time + .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); + + // 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)); + + // 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, + error, + } + } + + /// 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) + } + + /// 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 { + fn tsc_pre(&self) -> TscCount { + self.tsc_pre + } + + fn tsc_post(&self) -> TscCount { + self.tsc_post + } +} + +/// NTP-specific data +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +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, serde::Serialize, serde::Deserialize, +)] +#[serde(try_from = "u8", into = "u8")] +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, + } + } + + /// 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 { + 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::*; + 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() + .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)); + 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))); + } + + 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: 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 + }) + .build() + .unwrap() + } + + #[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( + // 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( + #[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(&event2); + approx::assert_abs_diff_eq!(period.get(), expected_period.get()); + } + + #[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 + ); + } + + // 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 + ); + } + + 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 new file mode 100644 index 0000000..d291f10 --- /dev/null +++ b/clock-bound/src/daemon/event/phc.rs @@ -0,0 +1,504 @@ +//! PHC Time synchronization events + +use crate::daemon::{ + clock_sync_algorithm::ff::{LocalPeriodAndError, UncorrectedClock}, + time::{Duration, Instant, TscCount, tsc::Period}, +}; + +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(not(test))] + #[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(not(test))] system_clock: Option, + ) -> Option { + if tsc_post <= tsc_pre { + return None; + } + + Some(Self { + tsc_pre, + tsc_post, + data, + #[cfg(not(test))] + 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(not(test))] + pub fn system_clock(&self) -> Option<&super::SystemClockMeasurement> { + self.system_clock.as_ref() + } + + /// 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 + /// + /// # Panics + /// Panics if the events are the same (specifically if the `tsc_post` values are equal) + 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. + /// + /// # Panics + /// Panics if the two events are equal + 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) + }; + + // 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); + + // 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)); + + // 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, + error, + } + } + + /// 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 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 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. + 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 { + 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::*; + use rstest::rstest; + + #[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(); + } + + #[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(&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] phc_event: Phc, + #[case] uncorrected_clock: UncorrectedClock, + #[case] expected_offset: Duration, + ) { + let client_midpoint = phc_event.tsc_pre.midpoint(phc_event.tsc_post); + println!( + "tsc_pre: {:?}", + uncorrected_clock.time_at(phc_event.tsc_pre) + ); + println!( + "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 = phc_event.calculate_offset(uncorrected_clock); + + approx::assert_abs_diff_eq!( + offset.as_seconds_f64(), + expected_offset.as_seconds_f64(), + 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.rs b/clock-bound/src/daemon/io.rs new file mode 100644 index 0000000..2fee271 --- /dev/null +++ b/clock-bound/src/daemon/io.rs @@ -0,0 +1,500 @@ +//! Perform IO on clock events +//! +//! 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)] + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, watch}; +use tokio_util::task::TaskTracker; +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 imds; +use imds::InstanceType; + +mod link_local; +use link_local::LinkLocal; + +mod ntp_source; +use ntp_source::NTPSource; + +mod phc; +use phc::Phc; + +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 { + /// The link local source. + 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. + clock_disruption_channels: ClockDisruptionChannels, + /// Shared reference to the current selected clock source + selected_clock: Arc, + /// Daemon metadata + daemon_info: DaemonInfo, + /// `tokio::task::TaskTracker` to manage lifecycle of the individual IO tasks + task_tracker: TaskTracker, +} + +impl SourceIO { + /// Constructs a new `SourceIO` object and constructs the necessary resources. + 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(), + phc: None, + vmclock: None, + clock_disruption_channels: ClockDisruptionChannels { sender, receiver }, + selected_clock, + daemon_info, + task_tracker, + } + } + + /// Initializes the IO task for sampling the Link Local NTP source. + /// + /// # Panics + /// - 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) { + info!("Creating link local source."); + + 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(); + + let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) + .await + .unwrap(); + + let link_local = LinkLocal::construct( + socket, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + self.selected_clock.clone(), + ); + Some(Source { + state: SourceState::Initialized(link_local), + ctrl_sender, + }) + }; + } + + info!("Source link local update complete."); + } + + /// Initializes 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, source: ntp::NTPSourceSender) { + let (server_address, event_sender) = source; + info!( + "Creating IO source from ntp server at {:#?}.", + server_address.ip().to_string() + ); + + 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(); + + let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) + .await + .unwrap(); + + let ntp_source = NTPSource::construct( + socket, + server_address, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + self.selected_clock.clone(), + self.daemon_info.clone(), + ); + + let source = Source { + state: SourceState::Initialized(ntp_source), + ctrl_sender, + }; + self.ntp_sources.insert(server_address, source); + } + + info!("Source NTP update complete."); + } + + /// Initializes 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."); + } + + 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() + } + + /// Initializes the IO task for sampling the VMClock shared memory file. + /// + /// 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."); + + 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 + .unwrap_or_else(|_| panic!("vmclock device not found {vmclock_shm_path}")); + + let source = Source { + state: SourceState::Initialized(vmclock), + ctrl_sender, + }; + Some(source) + }; + } + } + + /// 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() + } + + /// 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, + ctrl_sender: _, + }) = &mut self.link_local + { + debug!("Attempting to spawn link local source."); + if let SourceState::Initialized(mut link_local) = state.transition_to_running() { + 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."); + } + } else { + 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() { + 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."); + } + } else { + debug!("Could not spawn a PHC 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() { + self.task_tracker.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.ntp_sources { + debug!("Attempting to spawn {key:?} ntp source."); + if let SourceState::Initialized(mut ntp_source) = + ntp_source.state.transition_to_running() + { + 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 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: _, + 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."); + } +} + +/// Communication channels for sending and receiving clock disruption events. +struct ClockDisruptionChannels { + sender: watch::Sender, + receiver: watch::Receiver, +} + +#[derive(Clone, Debug, Default)] +pub struct ClockDisruptionEvent { + pub disruption_marker: Option, +} + +#[derive(Debug)] +pub enum ControlRequest { + Shutdown, +} + +/// A helper struct packaging the source state and its control sender together. +#[derive(Debug)] +struct Source { + state: SourceState, + ctrl_sender: mpsc::Sender, +} + +/// 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::default()); + + let socket = UdpSocket::bind(ntp::UNSPECIFIED_SOCKET_ADDRESS) + .await + .unwrap(); + + let link_local = LinkLocal::construct( + socket, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + Arc::new(SelectedClockSource::default()), + ); + 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 info = DaemonInfo { + major_version: 2, + minor_version: 100, + startup_id: 0xABCD_BCDE_CDEF_DEFA, + }; + + 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/clock-bound/src/daemon/io/imds.rs b/clock-bound/src/daemon/io/imds.rs new file mode 100644 index 0000000..ace10df --- /dev/null +++ b/clock-bound/src/daemon/io/imds.rs @@ -0,0 +1,153 @@ +//! 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 IMDSError { + #[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(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); + + /// 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 { + 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 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 + { + 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) + } + } + } + + /// 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!( + "{}/latest/meta-data/instance-type", + Self::IMDS_ORIGIN + )) + .header("X-aws-ec2-metadata-token", &*token) + .send(), + ) + .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) + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[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()) + } +} 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..0af5668 --- /dev/null +++ b/clock-bound/src/daemon/io/link_local.rs @@ -0,0 +1,338 @@ +//! Link Local IO Source + +use std::{num::Wrapping, sync::Arc}; +use thiserror::Error; +use tokio::{ + io, + net::UdpSocket, + sync::{mpsc, watch}, + time::{self, Instant, Interval, MissedTickBehavior, interval}, +}; +use tracing::{debug, info}; + +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::{ClockDisruptionEvent, ControlRequest}; +use crate::daemon::{ + async_ring_buffer::{self, SendError}, + event::{self}, + io::ntp::{self, SamplePacketError, packet::Timestamp}, + selected_clock::SelectedClockSource, +}; + +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("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}")] + TscOrder { pre: u64, post: u64 }, + #[error("IO failure on socket clear")] + 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 { + socket: UdpSocket, + event_sender: async_ring_buffer::Sender, + ctrl_receiver: mpsc::Receiver, + clock_disruption_receiver: watch::Receiver, + ntp_buffer: [u8; Packet::MIN_SIZE], + interval: Interval, + mode: Mode, + selected_clock: Arc, + transmit_counter: Wrapping, +} + +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, + selected_clock: Arc, + ) -> Self { + let mut link_local_interval = interval(LINK_LOCAL_BURST_INTERVAL_DURATION); + link_local_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + LinkLocal { + socket, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ntp_buffer: [0u8; Packet::MIN_SIZE], + interval: link_local_interval, + mode: Mode::burst(), + selected_clock, + transmit_counter: Wrapping(0), + } + } + + /// 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 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.into()) + .reference_id(source.into()) + .build(); + packet.emit_bytes(&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?; + debug!(?ntp_event, "Received packet."); + Ok(ntp_event) + } + + /// NTP Link Local task runner. + /// + /// 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. + /// + /// # 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! { + biased; // priority order is disruption, commands, and ticks + 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."); + } + ctrl_req = self.ctrl_receiver.recv() => { + // Ctrl logic here. + 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; + } + + + } + } + 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 handle_disruption(&mut self) { + let LinkLocal { + socket: _socket, + event_sender, + ctrl_receiver: _ctrl_receiver, + clock_disruption_receiver, + ntp_buffer: _ntp_buffer, + interval: ll_interval, + mode, + selected_clock: _selected_clock, + transmit_counter: _, + } = self; + + 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 + /// [`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, + selected_clock: _selected_clock, + transmit_counter: _, + } = 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, + Arc, + ) { + 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, + }); + + 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; + link_local.handle_disruption(); + + assert!(matches!(link_local.mode, Mode::Burst(_))); + assert_eq!( + link_local.interval.period(), + LINK_LOCAL_BURST_INTERVAL_DURATION + ); + } + + #[tokio::test] + async fn validate_to_normal_mode() { + let (mut link_local, _, _) = create_link_local().await; + link_local.handle_disruption(); + 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 new file mode 100644 index 0000000..85dbb4a --- /dev/null +++ b/clock-bound/src/daemon/io/ntp.rs @@ -0,0 +1,161 @@ +//! Ntp IO Source constants + +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::{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. +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 = + SocketAddrV4::new(Ipv4Addr::new(169, 254, 169, 123), 123); +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), + 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); + +/// 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(not(test))] + 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(not(test))] + { + builder.system_clock(system_clock_reading).build() + } + #[cfg(test)] + { + 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.rs b/clock-bound/src/daemon/io/ntp/packet.rs new file mode 100644 index 0000000..6e75efa --- /dev/null +++ b/clock-bound/src/daemon/io/ntp/packet.rs @@ -0,0 +1,503 @@ +//! 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 bon::Builder; +use nom::Parser; +use nom::number::{be_i8, be_u8}; +use thiserror::Error; + +mod extension; +mod header; +mod short; +mod timestamp; + +pub use extension::{ExtensionField, Fec2V1Value}; +pub use header::{LeapIndicator, Mode, Version}; +pub use short::Short; +pub use timestamp::Timestamp; + +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 +/// +/// See each of the fields for more information +#[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 { + 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 MIN_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, + extensions: Vec::new(), + } + } + + /// Emit packet into bytes + /// + /// ## Panics + /// 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); + 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()); + + 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 + 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, + extensions: Vec::new(), + }; + Ok((input, rv)) + } +} + +impl TryFrom for NtpData { + type Error = TryFromPacketError; + + fn try_from(value: Packet) -> Result { + 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; + + 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()); + } + + #[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(); + (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, expected); + } + + #[test] + fn display_does_not_panic() { + let packet = Packet::new_request(0xd00f_d00f); + let _ = packet.to_string(); + } + + #[test] + fn conversion_packet_to_ntp_data() { + let (_, packet) = Packet::parse_from_bytes(NTP_PACKET).unwrap(); + let ntp_data = NtpData::try_from(packet).unwrap(); + + 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!( + ntp_data.server_recv_time, + ClockBoundInstant::from_time(1738189694, 689357564) + ); + assert_eq!( + ntp_data.server_send_time, + ClockBoundInstant::from_time(1738189694, 689379474) + ); + 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); + } + + #[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); + } +} 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..8ee81b1 --- /dev/null +++ b/clock-bound/src/daemon/io/ntp/packet/extension.rs @@ -0,0 +1,243 @@ +//! 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 { + /// 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 + /// + /// # 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); + } +} 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..f550604 --- /dev/null +++ b/clock-bound/src/daemon/io/ntp/packet/timestamp.rs @@ -0,0 +1,209 @@ +//! 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 + } + + /// 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. + // 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/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 new file mode 100644 index 0000000..ef51008 --- /dev/null +++ b/clock-bound/src/daemon/io/ntp_source.rs @@ -0,0 +1,217 @@ +//! NTP Server IO Source + +use std::{net::SocketAddr, num::Wrapping, sync::Arc}; +use thiserror::Error; +use tokio::{ + io, + net::UdpSocket, + sync::{mpsc, watch}, + time::{self, Interval, MissedTickBehavior, interval}, +}; +use tracing::{debug, info}; + +use crate::daemon::{ + async_ring_buffer::{self, SendError}, + event::{self}, + io::{ + ClockDisruptionEvent, ControlRequest, DaemonInfo, + ntp::{ + self, SamplePacketError, + packet::{ExtensionField, Timestamp}, + }, + }, + selected_clock::SelectedClockSource, +}; + +use super::ntp::{NTP_SOURCE_INTERVAL_DURATION, NTP_SOURCE_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("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}")] + TscOrder { pre: u64, post: u64 }, + #[error("IO failure on socket clear")] + 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. +#[derive(Debug)] +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::MIN_SIZE + ExtensionField::MIN_SIZE], + interval: Interval, + selected_clock: Arc, + daemon_info: DaemonInfo, + transmit_counter: Wrapping, +} + +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, + selected_clock: Arc, + daemon_info: DaemonInfo, + ) -> Self { + let mut ntp_source_interval = interval(NTP_SOURCE_INTERVAL_DURATION); + ntp_source_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + NTPSource { + socket, + address, + event_sender, + ctrl_receiver, + clock_disruption_receiver, + ntp_buffer: [0u8; Packet::MIN_SIZE + ExtensionField::MIN_SIZE], + interval: ntp_source_interval, + selected_clock, + daemon_info, + transmit_counter: Wrapping(0), + } + } + + /// 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 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.into()) + .reference_id(source.into()) + .extensions(vec![ExtensionField::Fec2V1(self.daemon_info.clone())]) + .build(); + packet.emit_bytes(&mut self.ntp_buffer); + let ntp_event = ntp::sample_packet( + &self.socket, + self.address, + &mut self.ntp_buffer, + NTP_SOURCE_TIMEOUT, + counter, + ) + .await?; + debug!(?ntp_event, "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! { + biased; // priority order is disruption, commands, and ticks + val = self.clock_disruption_receiver.changed() => { + if let Err(e) = val { + tracing::error!(?e, "Clock disruption receiver dropped."); + break; + } + info!("Received clock disruption signal."); + self.handle_disruption(); + } + ctrl_req = self.ctrl_receiver.recv() => { + // Ctrl logic here. + 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; + } + } + } + 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/io/phc.rs b/clock-bound/src/daemon/io/phc.rs new file mode 100644 index 0000000..2cda7c1 --- /dev/null +++ b/clock-bound/src/daemon/io/phc.rs @@ -0,0 +1,1229 @@ +//! 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 std::path::PathBuf; +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_millis(500); + +/// 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 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")] + 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, + /// Path to the PHC device. + device_path: String, +} + +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, device_path) = + 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, + 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 + 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, String), 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 + .into_iter() + .next() + { + 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(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, ptp_device_path)) + } 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 + val = self.clock_disruption_receiver.changed() => { + if let Err(e) = val { + tracing::error!(?e, "Clock disruption receiver dropped."); + break; + } + // Clock Disruption logic here + info!("Received clock disruption signal."); + self.handle_disruption(); + } + ctrl_req = self.ctrl_receiver.recv() => { + // Ctrl logic here. + 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; + } + } + } + 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(&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"); + } +} + +#[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 { + 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) + } +} + +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 || { + let counter_pre = read_timestamp_counter_begin(); + // 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)) + }), + ) + .await?; + + result?.map_err(PhcError::from) + } +} + +#[derive(Debug, Clone, Default)] +pub struct PhcClockErrorBoundReader { + sysfs_phc_error_bound_path: PathBuf, +} + +#[cfg_attr(test, mockall::automock)] +impl PhcClockErrorBoundReader { + pub(crate) fn new(phc_clock_error_bound_path: PathBuf) -> 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 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 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 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 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_path_buf()); + 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 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_path_buf()); + 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 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 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 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 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 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 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 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 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 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] + #[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)); + + 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 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 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 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 { pci_slot_name } => { + assert_eq!(pci_slot_name, "0000:27:00.0"); + } + _ => panic!("Expected PhcClockErrorBoundFileNotFound error"), + } + } +} diff --git a/clock-bound/src/daemon/io/tsc.rs b/clock-bound/src/daemon/io/tsc.rs new file mode 100644 index 0000000..a1d4b95 --- /dev/null +++ b/clock-bound/src/daemon/io/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 +} diff --git a/clock-bound/src/daemon/io/vmclock.rs b/clock-bound/src/daemon/io/vmclock.rs new file mode 100644 index 0000000..3b4d90a --- /dev/null +++ b/clock-bound/src/daemon/io/vmclock.rs @@ -0,0 +1,374 @@ +//! VMClock Source + +use thiserror::Error; +use tokio::{ + fs, io, + sync::{mpsc, watch}, + time::{Duration, Interval, MissedTickBehavior, interval}, +}; +use tracing::{info, warn}; + +use crate::shm::ShmError; +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. +#[derive(Debug, PartialEq)] +pub enum ClockDisruptionStatus { + Normal, + Disrupted(u64), +} + +#[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: Reader, + /// 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 = 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); + + Ok(VMClock { + path: vmclock_shm_path.into(), + reader, + previous_shm_body: vmclock_snapshot, + interval: interval(VMCLOCK_TIMEOUT), + ctrl_receiver, + clock_disruption_sender, + }) + } + + /// 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()?; + + // 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( + vmclock_snapshot.disruption_marker, + )); + } + 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 let ClockDisruptionStatus::Disrupted(disruption_marker) = s { + self.clock_disruption_sender.send(ClockDisruptionEvent{ disruption_marker: Some(disruption_marker)}).unwrap(); + info!(?self, "A clock disruption event occurred and a disruption event was sent."); + } + }, + Err(e) => warn!(?e, "Failed to sample the VMClock.") + } + } + ctrl_req = self.ctrl_receiver.recv() => { + // Ctrl logic here. + match ctrl_req { + None => { + // this select can happen if `SourceIO` drops the ctrl_sender + break; + }, + Some(ControlRequest::Shutdown) => { + info!("Received shutdown signal. Exiting."); + 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::{Seek, 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::default()); + 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::default()); + 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::default()); + 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::default()); + 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 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::default()); + let mut vmclock = + VMClock::construct(vmclock_shm_path, ctrl_receiver, clock_disruption_sender) + .await + .unwrap(); + + // Update the shared memory file + 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::Disrupted(vmclock_content.disruption_marker) + ); + } +} diff --git a/clock-bound/src/daemon/receiver_stream.rs b/clock-bound/src/daemon/receiver_stream.rs new file mode 100644 index 0000000..d287bd3 --- /dev/null +++ b/clock-bound/src/daemon/receiver_stream.rs @@ -0,0 +1,304 @@ +//! 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::{self, TscRtt}; +use crate::daemon::time::TscCount; + +use super::io::ntp::NTPSourceReceiver; + +#[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 + Send>>; + +#[derive(Debug, bon::Builder)] +pub struct ReceiverStream { + #[builder(default)] + ntp_sources: HashMap>, + link_local: Receiver, + phc: Option>, +} + +/// `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` + pub fn add_ntp_source(&mut self, source: NTPSourceReceiver) { + 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) { + if self.ntp_sources.contains_key(id) { + self.ntp_sources.remove(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(); + + // 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| { + 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| 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. + // 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. + #[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(event_result) = result_stream.next().await else { + info!("Aggregate stream is empty, no futures to await"); + return None; + }; + + 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)] +pub enum RoutableEvent { + LinkLocal(event::Ntp), + NtpSource(SocketAddr, event::Ntp), + Phc(event::Phc), +} + +impl RoutableEvent { + /// Get the system clock info + #[cfg(not(test))] + pub fn system_clock(&self) -> Option<&crate::daemon::event::SystemClockMeasurement> { + match self { + RoutableEvent::LinkLocal(data) | RoutableEvent::NtpSource(_, data) => { + data.system_clock() + } + RoutableEvent::Phc(data) => data.system_clock(), + } + } +} + +impl TscRtt for RoutableEvent { + fn tsc_pre(&self) -> TscCount { + match self { + RoutableEvent::LinkLocal(data) | RoutableEvent::NtpSource(_, data) => data.tsc_pre(), + RoutableEvent::Phc(data) => data.tsc_pre(), + } + } + + fn tsc_post(&self) -> TscCount { + match self { + RoutableEvent::LinkLocal(data) | RoutableEvent::NtpSource(_, data) => data.tsc_post(), + RoutableEvent::Phc(data) => data.tsc_post(), + } + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use super::*; + use crate::daemon::async_ring_buffer::create; + use crate::daemon::event::{Ntp, NtpData, PhcData, Stratum}; + use crate::daemon::time::{Duration, Instant, TscCount}; + + #[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(link_local_rx) + .ntp_sources(HashMap::from([(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(); + + 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::LinkLocal(data) => { + counter += 1; + assert_eq!( + RoutableEvent::LinkLocal(dummy_ntp_data.clone()), + RoutableEvent::LinkLocal(data) + ); + } + RoutableEvent::NtpSource(ip, data) => { + counter += 1; + assert_eq!( + RoutableEvent::NtpSource(dummy_ntp_source_ip, dummy_ntp_data.clone()), + RoutableEvent::NtpSource(ip, data) + ); + } + RoutableEvent::Phc(_data) => { + assert!(false, "Phc event delivery has yet to be implemented") + } + }; + } + assert!( + counter.eq(&num_events), + "{}", + format!("{:#?} :: {:#?}", counter, num_events) + ); + } + + #[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); + + 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(link_local_rx).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(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); + + assert!(rx_stream.ntp_sources.is_empty()); + } +} diff --git a/clock-bound/src/daemon/selected_clock.rs b/clock-bound/src/daemon/selected_clock.rs new file mode 100644 index 0000000..193d198 --- /dev/null +++ b/clock-bound/src/daemon/selected_clock.rs @@ -0,0 +1,339 @@ +//! Thread-safe storage for the currently selected clock source + +use std::{ + fmt::Display, + 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 +/// +/// 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 its 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) + } + + /// 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); + } + + /// Set the clock source to a remote NTP server + 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 set_to_none(&self) { + self.set(ClockSource::None, Stratum::Unsynchronized); + } + + /// Set the clock source to VMClock + pub fn set_to_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, + _ => { + 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, + } + } + + 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, + /// 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, +} + +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(refid) => refid, + ClockSource::VMClock => u32::from_be_bytes(*b"XVMC"), + } + } +} + +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 { + ClockSource::Init => write!(f, "INIT"), + ClockSource::None => write!(f, "None"), + ClockSource::Phc => write!(f, "PHC"), + 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"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::event::ValidStratumLevel; + 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(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); + + let (read_source, read_stratum) = clock.get(); + assert_eq!(read_source, source); + 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(); + + // Test PHC + clock.set_to_phc(); + let (source, stratum) = clock.get(); + assert_eq!(source, ClockSource::Phc); + assert_eq!(stratum, Stratum::Unspecified); + + // Test VMClock + clock.set_to_vmclock(); + let (source, stratum) = clock.get(); + assert_eq!(source, ClockSource::VMClock); + assert_eq!(stratum, Stratum::Unspecified); + + // 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(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.set_to_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(0xC0A80101), "Server([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(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(); + assert_eq!(clock.to_string(), "INIT (stratum 0)"); + + clock.set_to_phc(); + assert_eq!(clock.to_string(), "PHC (stratum 0)"); + + 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.set_to_none(); + assert_eq!(clock.to_string(), "None (stratum 16)"); + } +} diff --git a/clock-bound/src/daemon/subscriber.rs b/clock-bound/src/daemon/subscriber.rs new file mode 100644 index 0000000..c1bde9f --- /dev/null +++ b/clock-bound/src/daemon/subscriber.rs @@ -0,0 +1,62 @@ +//! 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; + +use tracing::Level; +use tracing_subscriber::{ + EnvFilter, Layer, filter::filter_fn, layer::SubscriberExt, util::SubscriberInitExt, +}; + +pub const PRIMER_TARGET: &str = "clock_bound::primer"; +pub const CLOCK_METRICS_TARGET: &str = "clock_bound::clock_metrics"; + +/// 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::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::hourly(&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( + EnvFilter::builder() + .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(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"); + tracing::info!(primer_log_file = %log_directory.as_ref().display(), "Initialized log directory"); +} diff --git a/clock-bound/src/daemon/time.rs b/clock-bound/src/daemon/time.rs new file mode 100644 index 0000000..f598739 --- /dev/null +++ b/clock-bound/src/daemon/time.rs @@ -0,0 +1,14 @@ +//! 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 clocks; +pub mod inner; +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/clocks.rs b/clock-bound/src/daemon/time/clocks.rs new file mode 100644 index 0000000..91f7834 --- /dev/null +++ b/clock-bound/src/daemon/time/clocks.rs @@ -0,0 +1,177 @@ +//! Clocks used in ClockBound +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 { + 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 { + 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) + } +} + +/// 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) + 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_timespec(now) + } +} + +/// 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) + 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_timespec(now) + } +} + +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) + 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_timespec(now) + } +} + +#[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. + #[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(); + 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) + #[test] + fn test_monotonic_raw() { + let monotonic_raw = MonotonicRaw; + let now = monotonic_raw.get_time(); + 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 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 new file mode 100644 index 0000000..446e81b --- /dev/null +++ b/clock-bound/src/daemon/time/inner.rs @@ -0,0 +1,1073 @@ +//! Time representation +//! +//! 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}, +}; + +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 {} + +/// Abstraction for time type whose tick unit is approximately one femtosecond +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. +/// +/// 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, Serialize, Deserialize)] +pub struct ClockOffsetAndRtt { + /// Offset + offset: Diff, + /// RTT + rtt: Diff, +} + +impl ClockOffsetAndRtt { + pub(crate) 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. +#[derive( + Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +#[serde(transparent)] +#[repr(transparent)] +pub struct Time { + instant: i128, + _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 { + self.instant + } +} + +impl From> for i128 { + fn from(value: Time) -> Self { + value.instant + } +} + +// 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 { + 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; + } +} + +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 + } + + /// 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() + } + + /// 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, rounded since the Unix Epoch + pub const fn as_nanos(self) -> i128 { + (self.get() + FEMTOS_PER_NANO / 2) / FEMTOS_PER_NANO + } + + /// Returns the total number of microseconds, rounded, since the Unix Epoch + pub const fn as_micros(self) -> i128 { + (self.get() + FEMTOS_PER_MICRO / 2) / FEMTOS_PER_MICRO + } + + /// Returns the total number of milliseconds, rounded, since the Unix Epoch + pub const fn as_millis(self) -> i128 { + (self.get() + FEMTOS_PER_MILLI / 2) / FEMTOS_PER_MILLI + } + + /// Returns the total number of seconds, rounded, since the Unix Epoch + 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 minutes, rounded, since the Unix Epoch + pub const fn as_minutes(self) -> i128 { + const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE; + (self.get() + SCALE_FACTOR / 2) / SCALE_FACTOR + } + + /// Returns the total number of hours, rounded, since the Unix Epoch + pub const fn as_hours(self) -> i128 { + 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, rounded, since the Unix Epoch + pub const fn as_days(self) -> i128 { + const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE * MINS_PER_HOUR * HOURS_PER_DAY; + (self.get() + SCALE_FACTOR / 2) / SCALE_FACTOR + } +} + +/// 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( + Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, serde::Serialize, serde::Deserialize, +)] +#[serde(transparent)] +#[repr(transparent)] +pub struct Diff { + duration: i128, + _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 { + 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; + + 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 + } +} + +impl MulAssign for Diff { + fn mul_assign(&mut self, rhs: usize) { + self.duration *= rhs as i128; + } +} + +impl Neg for Diff { + type Output = Self; + + fn neg(self) -> Self::Output { + Self { + duration: -self.duration, + _marker: PhantomData, + } + } +} + +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) + } + + /// Create a new [`Diff`] from the number of seconds in `f64` format + /// + /// 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).round() as i128) + } + + /// Create a new [`Diff`] from the number of milliseconds in `f64` format + /// + /// 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).round() as i128) + } + + /// Create a new [`Diff`] from the number of microseconds in `f64` format + /// + /// 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).round() as i128) + } + + /// Create a new [`Diff`] from the number of nanoseconds in `f64` format + /// + /// 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).round() 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, rounded + pub const fn as_picos(self) -> i128 { + (self.get() + FEMTOS_PER_PICO / 2) / FEMTOS_PER_PICO + } + + /// Returns the total number of nanoseconds, rounded + pub const fn as_nanos(self) -> i128 { + (self.get() + FEMTOS_PER_NANO / 2) / FEMTOS_PER_NANO + } + + /// Returns the total number of microseconds, rounded + pub const fn as_micros(self) -> i128 { + (self.get() + FEMTOS_PER_MICRO / 2) / FEMTOS_PER_MICRO + } + + /// Returns the total number of milliseconds, rounded + pub const fn as_millis(self) -> i128 { + (self.get() + FEMTOS_PER_MILLI / 2) / FEMTOS_PER_MILLI + } + + /// 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, rounded + pub const fn as_minutes(self) -> i128 { + const SCALE_FACTOR: i128 = FEMTOS_PER_SEC * SECS_PER_MINUTE; + (self.get() + SCALE_FACTOR / 2) / SCALE_FACTOR + } + + /// Returns the total number of hours, rounded + pub const fn as_hours(self) -> i128 { + 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, rounded + pub const fn as_days(self) -> i128 { + 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`. + /// 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_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 { + tv.tv_sec -= 1; + tv.tv_usec += 1_000_000_000_i64; + } + tv + } +} + +// print in .__ format. +fn debug_femtos( + f: &mut std::fmt::Formatter<'_>, + 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!("{sign}{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!("{sign}{secs}.{millis:0>3}")) + .finish(); + } + if nanos == 0 { + return f + .debug_tuple(prefix) + .field(&format_args!("{sign}{secs}.{millis:0>3}_{micros:0>3}")) + .finish(); + } + let formatted = format_args!("{sign}{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; +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 rstest::rstest; + use std::sync::{Arc, Mutex}; + + use super::*; + use crate::daemon::time::{Duration, Instant, instant::Utc}; + + #[derive(Clone, Copy)] + struct 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); + } + + #[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(), 2); + 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_trunc(), 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_trunc(), 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(), 2); + } + + #[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); + } + + #[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_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); + } + + #[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_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); + } + + #[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); + } +} 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/instant.rs b/clock-bound/src/daemon/time/instant.rs new file mode 100644 index 0000000..f9d8e7f --- /dev/null +++ b/clock-bound/src/daemon/time/instant.rs @@ -0,0 +1,76 @@ +//! 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, Serialize, Deserialize, +)] +pub struct Utc; + +impl super::inner::Type 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 +/// +/// 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 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/daemon/time/timex.rs b/clock-bound/src/daemon/time/timex.rs new file mode 100644 index 0000000..92bc4b7 --- /dev/null +++ b/clock-bound/src/daemon/time/timex.rs @@ -0,0 +1,660 @@ +//! 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; +use libc::{ + ADJ_SETOFFSET, MOD_FREQUENCY, MOD_NANO, MOD_OFFSET, MOD_STATUS, MOD_TIMECONST, STA_FREQHOLD, + STA_NANO, STA_PLL, timex, +}; +use tracing::warn; + +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); + +/// Newtype wrapping `libc::timex` to provide valid +/// constructors for each type of `adjtimex`/`ntp_adjtime` operation. +#[derive(Debug, PartialEq, Clone)] +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 + } + + /// 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. + 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 + } + + /// 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. + /// + /// 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 { + 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); + } + 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_FREQUENCY | MOD_OFFSET | MOD_TIMECONST | MOD_NANO | MOD_STATUS, + offset: phase_correction.as_nanos_trunc() as i64, + 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, + // 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, + }) + } + + #[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 + /// 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, + }) + } + + /// 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, + }) + } + + // 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 { + fn as_ref(&self) -> &timex { + &self.0 + } +} + +#[cfg(test)] +mod test { + use libc::{STA_NANO, timeval}; + use rstest::rstest; + + use super::*; + + #[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 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 + 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); + } + + #[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); + } + + // 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); + } +} diff --git a/clock-bound/src/daemon/time/tsc.rs b/clock-bound/src/daemon/time/tsc.rs new file mode 100644 index 0000000..9cb6166 --- /dev/null +++ b/clock-bound/src/daemon/time/tsc.rs @@ -0,0 +1,818 @@ +//! Time stamp counter (TSC) values +#![expect(clippy::cast_possible_truncation)] +#![expect(clippy::cast_precision_loss)] + +use crate::daemon::time::Instant; +use crate::daemon::time::inner::FemtoType; + +use super::Duration; +use super::inner::{Diff, Time}; +use std::ops::Neg; +use std::{ + fmt::Display, + ops::{Div, Mul, MulAssign}, +}; + +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`] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct 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; + +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 + /// + /// `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) + } +} + +#[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 +/// 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(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 { + 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) + } +} + +#[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) + } +} + +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 * FEMTOS_PER_SEC_F64; + Duration::from_femtos(duration_femtos.round() as i128) + } +} + +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 / FEMTOS_PER_SEC_F64; + TscDiff::new(raw.round() as i128) + } +} + +impl Mul for Frequency { + type Output = TscDiff; + + fn mul(self, rhs: Duration) -> Self::Output { + rhs * self + } +} + +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 PPB: f64 = 1.0e-9; + const PPM: f64 = 1.0e-6; + const PERCENT: f64 = 0.01; + + /// Construct a new skew from parts per million (ppm) + pub const fn from_ppm(skew: f64) -> Self { + Self(skew * Self::PPM) + } + + /// Construct a new skew from parts per billion (ppb) + pub const fn from_ppb(skew: f64) -> Self { + 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 + pub const fn from_percent(skew: f64) -> Self { + Self(skew * Self::PERCENT) + } + + /// Get the inner value + pub const fn get(self) -> f64 { + 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) + } + + /// 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 { + if period.get() == 0.0 { + return Self(0.0); + } + 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 + /// 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 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() as f64 * 2.0_f64.powi(-16)) + } + } + + /// 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 + } + + /// `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())) + } +} + +impl Neg for Skew { + type Output = Self; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Display for Skew { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ppm", self.0 / Self::PPM) + } +} + +#[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, +/// `[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 +/// +/// ## 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(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 + /// + /// # 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.as_seconds_f64()) + } + + /// Get the inner duration in seconds + pub fn get(self) -> f64 { + 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 { + 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) + } +} + +impl Mul for TscDiff { + type Output = Duration; + + fn mul(self, rhs: Period) -> Self::Output { + let dur_seconds = self.get() as f64 * rhs.get(); + Duration::from_seconds_f64(dur_seconds) + } +} + +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_seconds_f64() / rhs.get() as f64; + Period::from_seconds(period) + } +} + +impl Div for Duration { + type Output = TscDiff; + + fn div(self, rhs: Period) -> Self::Output { + let diff = self.as_seconds_f64() / rhs.get(); + TscDiff::new(diff.round() as i128) + } +} + +#[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::*; + 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_abs_diff_eq!(period.get(), 0.1); + } + + #[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_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(-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); + 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_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(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); + 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); + 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_abs_diff_eq!(period.get(), 0.001); + } + + #[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); + } + + #[test] + fn period_display() { + 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); + } + + #[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(); + } + + #[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)"); + } + + #[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); + } +} diff --git a/clock-bound/src/lib.rs b/clock-bound/src/lib.rs new file mode 100644 index 0000000..2af26fd --- /dev/null +++ b/clock-bound/src/lib.rs @@ -0,0 +1,11 @@ +//! ClockBound + +#[cfg(feature = "client")] +pub mod client; + +pub mod shm; + +pub mod vmclock; + +#[cfg(feature = "daemon")] +pub mod daemon; diff --git a/clock-bound/src/shm.rs b/clock-bound/src/shm.rs new file mode 100644 index 0000000..bd03486 --- /dev/null +++ b/clock-bound/src/shm.rs @@ -0,0 +1,1192 @@ +//! ClockBound Shared Memory +//! +//! 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. + +// TODO: prevent clippy from checking for dead code. The writer module is only re-exported publicly +// 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; +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; +use errno::Errno; +use nix::sys::time::{TimeSpec, TimeValLike}; +use std::error::Error; +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_V1; + +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 +/// origin information. +#[macro_export] +macro_rules! syserror { + ($msg:expr) => { + Err($crate::shm::ShmError::SyscallError($msg, ::errno::errno())) + }; +} + +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), + 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, + } + } +} + +impl ClockBoundSnapshot for ClockErrorBound { + fn now(&self) -> Result { + match self { + ClockErrorBound::V2(ceb) => ceb.now(), + ClockErrorBound::V3(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)] + as_of_tsc: u64, + + #[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)] + period: f64, + + #[builder(default)] + period_err: f64, + + #[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`. + 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 { + ClockErrorBoundLayoutVersion::V2 => 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, + )), + 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, + )), + } + } +} + +#[derive(Copy, Clone)] +pub enum ClockErrorBoundLayoutVersion { + V2, + V3, +} + +impl TryFrom for ClockErrorBoundLayoutVersion { + type Error = ShmError; + fn try_from(value: u8) -> Result { + match value { + 2 => Ok(ClockErrorBoundLayoutVersion::V2), + 3 => Ok(ClockErrorBoundLayoutVersion::V3), + _ => Err(ShmError::SegmentVersionNotSupported(format!( + "Found version {value}", + ))), + } + } +} + +impl TryFrom for ClockErrorBoundLayoutVersion { + type Error = ShmError; + fn try_from(value: u16) -> Result { + match value { + 2 => Ok(ClockErrorBoundLayoutVersion::V2), + 3 => Ok(ClockErrorBoundLayoutVersion::V3), + _ => Err(ShmError::SegmentVersionNotSupported(format!( + "Found version {value}", + ))), + } + } +} + +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 { + pub earliest: TimeSpec, + pub latest: TimeSpec, + pub clock_status: ClockStatus, +} + +/// Error condition returned by all low-level ClockBound APIs. +/// +#[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(String, Errno), + + /// The shared memory segment is not initialized. + SegmentNotInitialized(String), + + /// The shared memory segment is initialized but malformed. + SegmentMalformed(String), + + /// Failed causality check when comparing timestamps. + CausalityBreach(String), + + /// The shared memory segment version is not supported. + SegmentVersionNotSupported(String), +} + +impl fmt::Display for ShmError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ShmError::SyscallError(msg, errno) => { + write!(f, "Errno: {errno:?} Details: {msg}") + } + ShmError::SegmentNotInitialized(msg) => { + write!(f, "The shared memory segment is not initialized [{msg}].") + } + ShmError::SegmentMalformed(msg) => { + write!( + f, + "The shared memory segment is initialized but malformed [{msg}]." + ) + } + ShmError::CausalityBreach(msg) => { + write!( + f, + "Failed causality check when comparing timestamps [{msg}]." + ) + } + ShmError::SegmentVersionNotSupported(msg) => { + write!( + f, + "The shared memory segment version is not supported [{msg}]." + ) + } + } + } +} + +impl Error for ShmError {} + +/// Definition of mutually exclusive clock status exposed to the reader. +/// +/// 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. + /// In this clock status, error-bounded timestamps should not be trusted. + Unknown = 0, + + /// The clock is kept accurate by the synchronization daemon. + /// In this clock status, error-bounded timestamps can be trusted. + Synchronized = 1, + + /// The clock is free running and not updated by the synchronization daemon. + /// In this clock status, error-bounded timestamps can be trusted. + FreeRunning = 2, + + /// The clock has been disrupted and the accuracy of time cannot be bounded. + /// In this clock status, error-bounded timestamps should not be trusted. + Disrupted = 3, +} + +/// 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 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. +/// +/// 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 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. + as_of: TimeSpec, + + /// 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, + + /// An absolute upper bound on the accuracy of the `CLOCK_REALTIME` clock with regards to true + /// time at the instant represented by `as_of`. + 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 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, + + /// 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, + + /// Padding. + _padding: [u8; 7], +} + +impl ClockErrorBoundV2 { + /// Create a new `ClockErrorBound` struct. + pub fn new( + as_of: TimeSpec, + void_after: TimeSpec, + bound_nsec: i64, + disruption_marker: u64, + max_drift_ppb: u32, + clock_status: ClockStatus, + clock_disruption_support_enabled: bool, + ) -> ClockErrorBoundV2 { + ClockErrorBoundV2 { + as_of, + void_after, + bound_nsec, + disruption_marker, + max_drift_ppb, + clock_status, + clock_disruption_support_enabled, + _padding: [0u8; 7], + } + } + + /// 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: + /// (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 { + // 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)?; + + self.compute_bound_at(real, mono) + } + + /// Compute the bound on clock error at a given point in time. + /// + /// The time at which the bound is computed is defined by the (real, mono) pair of timestamps + /// read from the realtime and monotonic clock respectively, *roughly* at the same time. The + /// 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, + mono: TimeSpec, + ) -> 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(format!( + "max_drift_ppb too large [{}]", + self.max_drift_ppb, + ))); + } + + // 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. + // TODO: this may not be the most ergonomic decision, putting a pin here to revisit this + // decision once the client code is fleshed out. + 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 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 { + // The last update is recent enough, hence report it + self.clock_status + } + } + }; + + // Calculate the duration that has elapsed between the instant when the CEB parameters were + // snapshot'ed from the SHM segment (approximated by `as_of`), and the instant when the + // request to calculate the CEB was actually requested (approximated by `mono`). This + // duration is used to compute the growth of the error bound due to local dispersion + // between polling chrony and now. + // + // To avoid miscalculation in case the synchronization daemon is restarted, a + // CLOCK_MONOTONIC is used, since it is designed to not jump. Because we want this to be + // fast, and the exact accuracy is not critical here, we use CLOCK_MONOTONIC_COARSE on + // platforms that support it. + // + // But ... there is a catch. When validating causality of these events that is, `as_of` + // should always be older than `mono`, we observed this test to sometimes fail, with `mono` + // being older by a handful of nanoseconds. The root cause is not completely understood, + // but points to the clock resolution and/or update strategy and/or propagation of the + // updates through the VDSO memory page. See this for details: + // https://t.corp.amazon.com/P101954401. + // + // The following implementation is a mitigation. + // 1. if as_of <= mono is younger than as_of, calculate the duration (happy path) + // 2. if as_of - epsilon < mono < as_of, set the duration to 0 + // 3. if mono < as_of - epsilon, return an error + // + // In short, this relaxes the sanity check a bit to accept some imprecision in the clock + // reading routines. + // + // What is a good value for `epsilon`? + // The CLOCK_MONOTONIC_COARSE resolution is a function of the HZ kernel variable defining + // the last kernel tick that drives this clock (e.g. HZ=250 leads to a 4 millisecond + // resolution). We could use the `clock_getres()` system call to retrieve this value but + // this makes diagnosing over different platform / OS configurations more complex. Instead + // settling on an arbitrary default value of 1 millisecond. + let causality_blur = self.as_of - TimeSpec::new(0, 1000); + + let duration = if mono >= self.as_of { + // Happy path, no causality doubt + mono - self.as_of + } else if mono > causality_blur { + // Causality is "almost" broken. We are within a range that could be due to the clock + // precision. Let's approximate this to equality between mono and as_of. + TimeSpec::new(0, 0) + } else { + // Causality is breached. + 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 + // 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 * f64::from(self.max_drift_ppb)) as i64, + ); + + // Build the (earliest, latest) interval within which true time exists. + let earliest = real - updated_bound; + let latest = real + updated_bound; + + Ok(ClockBoundNowResult { + earliest, + latest, + 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)?; + + self.compute_bound_at(real, mono) + } +} + +/// 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(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 + // 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. + 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. + // + // First, amount for the underlying oscillator drifts (possibly at a worse + // possible rate) in between consecutive clock adjustments. + 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, + ); + + // 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 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 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 { + // The last update is recent enough, hence report it + self.clock_status + } + } + }; + + 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::*; + + // Convenience macro to build ClockBoundError for unit tests + macro_rules! clockbound_v2 { + (($asof_tv_sec:literal, $asof_tv_nsec:literal), ($after_tv_sec:literal, $after_tv_nsec:literal)) => { + 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 + 0, // disruption_marker + 1000, // max_drift_ppb + ClockStatus::Synchronized, // clock_status + true, // clock_disruption_support_enabled + ) + }; + } + + /// Assert the bound on clock error is computed correctly + #[test] + fn compute_bound_ok() { + let ceb = clockbound_v2!((0, 0), (10, 0)); + let real = TimeSpec::new(2, 0); + let mono = TimeSpec::new(2, 0); + + let ClockBoundNowResult { + earliest, + latest, + clock_status, + } = ceb + .compute_bound_at(real, mono) + .expect("Failed to compute bound"); + + // 2 seconds have passed since the bound was snapshot, hence 2 microsec of drift on top of + // the default 10 microsec put in the ClockBoundError data + assert_eq!(earliest.tv_sec(), 1); + 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!(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_v2!((0, 0), (10, 0)); + let real = TimeSpec::new(20, 0); // realtime clock way ahead + let mono = TimeSpec::new(4, 0); + + let ClockBoundNowResult { + earliest, + latest, + clock_status, + } = ceb + .compute_bound_at(real, mono) + .expect("Failed to compute bound"); + + // 4 seconds have passed since the bound was snapshot, hence 4 microsec of drift on top of + // the default 10 microsec put in the ClockBoundError data + assert_eq!(earliest.tv_sec(), 19); + 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!(clock_status, ClockStatus::Synchronized); + } + + /// 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(61, 0); + let mono = TimeSpec::new(61, 0); + + let ClockBoundNowResult { + earliest, + latest, + clock_status, + } = ceb + .compute_bound_at(real, mono) + .expect("Failed to compute bound"); + + // 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); + } + + /// 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_v2!((0, 0), (5, 0)); + let real = TimeSpec::new(10, 0); + let mono = TimeSpec::new(10, 0); // Passed void_after + + let ClockBoundNowResult { + earliest, + latest, + clock_status, + } = ceb + .compute_bound_at(real, mono) + .expect("Failed to compute bound"); + + // 10 seconds have passed since the bound was snapshot, hence 10 microsec of drift on top of + // the default 10 microsec put in the ClockBoundError data + assert_eq!(earliest.tv_sec(), 9); + 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!(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_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; + + assert!(ceb.compute_bound_at(real, mono).is_err()); + } + + /// Assert errors are returned if the ClockBoundError data snapshot has been taken after + /// reading clocks at 'now' + #[test] + fn compute_bound_causality_break() { + let ceb = clockbound_v2!((5, 0), (10, 0)); + let real = TimeSpec::new(1, 0); + let mono = TimeSpec::new(1, 0); + + let res = ceb.compute_bound_at(real, mono); + + 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-shm/src/common.rs b/clock-bound/src/shm/common.rs similarity index 86% rename from clock-bound-shm/src/common.rs rename to clock-bound/src/shm/common.rs index cfc625f..d956f5a 100644 --- a/clock-bound-shm/src/common.rs +++ b/clock-bound/src/shm/common.rs @@ -1,6 +1,6 @@ -use crate::{syserror, 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; @@ -15,11 +15,12 @@ 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), - _ => syserror!("clock_gettime"), + _ => syserror!(String::from("clock_gettime failed")), } } diff --git a/clock-bound-shm/src/reader.rs b/clock-bound/src/shm/reader.rs similarity index 66% rename from clock-bound-shm/src/reader.rs rename to clock-bound/src/shm/reader.rs index 6a4034e..1fe96aa 100644 --- a/clock-bound-shm/src/reader.rs +++ b/clock-bound/src/shm/reader.rs @@ -1,27 +1,35 @@ -use errno::{errno, Errno}; -use std::ffi::{c_void, CStr}; +#![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; use std::ptr; -use std::sync::atomic; +use std::sync::atomic::{self, AtomicU16}; -use crate::shm_header::{ShmHeader, CLOCKBOUND_SHM_SUPPORTED_VERSION}; -use crate::{syserror, ClockErrorBound, ShmError}; +use crate::shm::shm_header::{CLOCKBOUND_SHM_LATEST_VERSION, ShmHeader}; +use crate::{ + shm::{ + ClockErrorBound, ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, ClockErrorBoundV2, + ClockErrorBoundV3, ShmError, + }, + syserror, +}; /// 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 { // 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)) @@ -29,7 +37,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. @@ -42,7 +50,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 { @@ -54,9 +62,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)?; @@ -78,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 }) @@ -86,7 +94,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 @@ -100,8 +108,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 @@ -154,7 +162,91 @@ 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 { + // 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 + { + 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)?; + + 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)?; @@ -168,25 +260,36 @@ 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::() { - return Err(ShmError::SegmentMalformed); + let layout_size = match shm_version { + ClockErrorBoundLayoutVersion::V2 => size_of::(), + ClockErrorBoundLayoutVersion::V3 => size_of::(), + }; + + if mmap_guard.segsize < size_of::() + layout_size { + 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 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, - _guard: mmap_guard, - version, - generation, - ceb_shm, - snapshot_ceb: ClockErrorBound::default(), - snapshot_gen: 0, - }) + Ok((mmap_guard, version, generation, ceb_shm)) } /// Return a consistent snapshot of the shared memory segment. @@ -195,11 +298,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 @@ -213,9 +317,22 @@ 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 {:?} which is not supported by this software.", version); - return Err(ShmError::SegmentVersionNotSupported); + } + + // 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 + { + 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 @@ -278,24 +395,25 @@ 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; } // Attempts to read the snapshot have failed. - Err(ShmError::SegmentNotInitialized) + Err(ShmError::SegmentNotInitialized(String::from( + "Failed to read the SHM segment after all attempts", + ))) } } #[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; @@ -319,15 +437,15 @@ 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(ClockErrorBoundLayoutVersion::V2); // Convert the ceb struct into a slice so we can write it all out, fairly magic. // Definitely needs the #[repr(C)] layout. @@ -362,7 +480,7 @@ 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(); @@ -375,7 +493,36 @@ mod t_reader { 0x414D5A4E, 0x43420200, 400, - 2, + 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(); + 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, + 0x0303, 10, (0, 0), (0, 0), @@ -390,9 +537,9 @@ 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); + assert_eq!(ceb.bound_nsec(), 123); } /// Assert that creating a reader when the @@ -424,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 @@ -445,7 +594,7 @@ mod t_reader { 0x414D5A4E, 0x43420200, 400, - 2, + 0x0303, 10, (0, 0), (0, 0), @@ -456,7 +605,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(); @@ -484,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-shm/src/shm_header.rs b/clock-bound/src/shm/shm_header.rs similarity index 82% rename from clock-bound-shm/src/shm_header.rs rename to clock-bound/src/shm/shm_header.rs index 105393a..1fc624f 100644 --- a/clock-bound-shm/src/shm_header.rs +++ b/clock-bound/src/shm/shm_header.rs @@ -1,16 +1,18 @@ -use std::mem::{size_of, MaybeUninit}; +use std::mem::{MaybeUninit, size_of}; use std::sync::atomic; -use crate::{syserror, ShmError}; +use crate::{shm::ShmError, syserror}; +use tracing::error; -/// 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; +pub const CLOCKBOUND_SHM_LATEST_VERSION: u16 = 3_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 +32,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. @@ -44,12 +47,16 @@ 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::(), + ))); } _ => (), - }; + } // SAFETY: we've checked the above return value to ensure header_buf // has been completely initialized by the previous read. @@ -60,6 +67,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 } @@ -67,7 +78,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 @@ -82,30 +101,30 @@ 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); + 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::SegmentNotInitialized); + 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); - } - - // 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 {:?} which is not supported by this software.", version); - return Err(ShmError::SegmentVersionNotSupported); + 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/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 +} diff --git a/clock-bound-shm/src/writer.rs b/clock-bound/src/shm/writer.rs similarity index 72% rename from clock-bound-shm/src/writer.rs rename to clock-bound/src/shm/writer.rs index 0b91c0c..9f71843 100644 --- a/clock-bound-shm/src/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::{c_void, CString}; -use std::io::{Error, ErrorKind}; +use std::ffi::{CString, c_void}; +use std::io::Error; use std::mem::size_of; use std::path::Path; use std::sync::atomic; @@ -10,9 +12,11 @@ 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::{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 { @@ -34,22 +38,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. @@ -60,11 +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. - pub fn new(path: &Path) -> std::io::Result { + #[expect(clippy::missing_errors_doc, reason = "todo")] + 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 @@ -74,7 +81,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. @@ -100,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) @@ -120,27 +133,43 @@ 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)?; - - match ShmReader::new(path_cstring.as_c_str()) { - Ok(_reader) => Ok(()), + 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(format!( + "Existing SHM segment maximum version ({max_version}) does not match daemon version ({CLOCKBOUND_SHM_LATEST_VERSION})" + ))) + } + } Err(err) => Err(err), } } /// 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 // 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 +189,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 +202,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 +223,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 @@ -260,20 +278,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 +304,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); } } } @@ -299,8 +317,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"); @@ -320,19 +338,19 @@ mod t_writer { /// afterwards. use tempfile::NamedTempFile; - use crate::ClockStatus; + use crate::shm::{ClockErrorBoundGeneric, ClockErrorBoundLayoutVersion, 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(ClockErrorBoundLayoutVersion::V2) }; } @@ -357,8 +375,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::V3, + ClockErrorBoundLayoutVersion::V3, + ) + .expect("Failed to create a writer"); writer.write(&ceb); // Read it back into a snapshot @@ -388,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::V3, + ClockErrorBoundLayoutVersion::V3, + ) + .expect("Failed to create a writer"); writer.write(&ceb); // Read it back into a snapshot @@ -412,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); @@ -421,18 +451,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/src/vmclock.rs b/clock-bound/src/vmclock.rs new file mode 100644 index 0000000..45b19b7 --- /dev/null +++ b/clock-bound/src/vmclock.rs @@ -0,0 +1,5 @@ +//! VMClock access + +pub mod shm; +pub mod shm_reader; +pub mod shm_writer; diff --git a/clock-bound-vmclock/src/shm.rs b/clock-bound/src/vmclock/shm.rs similarity index 82% rename from clock-bound-vmclock/src/shm.rs rename to clock-bound/src/vmclock/shm.rs index bcb350f..ef0bd6a 100644 --- a/clock-bound-vmclock/src/shm.rs +++ b/clock-bound/src/vmclock/shm.rs @@ -12,13 +12,14 @@ use std::mem::size_of; use std::str::FromStr; use std::sync::atomic; -use clock_bound_shm::{syserror, ShmError}; +use crate::shm::ShmError; +use crate::syserror; 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. /// @@ -42,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, @@ -58,12 +59,18 @@ 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."); + return syserror!(format!( + "VMClockShmHeader is shorter than expected size [{} < {}].", + vector.len(), + size_of::() + )); } let slice = vector.as_slice(); @@ -109,21 +116,24 @@ 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."); - 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(()) } @@ -151,7 +161,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; @@ -175,6 +185,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. /// @@ -186,19 +197,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 @@ -207,13 +218,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, @@ -238,9 +249,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, @@ -249,29 +260,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. @@ -280,24 +291,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. @@ -311,7 +322,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-vmclock/src/shm_reader.rs b/clock-bound/src/vmclock/shm_reader.rs similarity index 85% rename from clock-bound-vmclock/src/shm_reader.rs rename to clock-bound/src/vmclock/shm_reader.rs index 54af19e..44b1f9f 100644 --- a/clock-bound-vmclock/src/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; @@ -7,14 +9,15 @@ use std::ptr; use std::sync::atomic; use tracing::{debug, error}; -use crate::shm::{VMClockShmBody, VMClockShmHeader}; -use clock_bound_shm::{syserror, ShmError}; +use crate::shm::ShmError; +use crate::syserror; +use crate::vmclock::shm::{VMClockShmBody, VMClockShmHeader}; 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 @@ -28,23 +31,38 @@ struct MmapGuard { } impl MmapGuard { - /// Create a new 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 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!(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!("MmapGuard: Number of bytes read ({:?}) is less than the size of VMClockShmHeader ({:?}).", bytes_read, size_of::()); - return Err(ShmError::SegmentMalformed); + let msg = format!( + "MmapGuard: Number of bytes read ({:?}) is less than the size of VMClockShmHeader ({:?}).", + bytes_read, + size_of::() + ); + error!(msg); + return Err(ShmError::SegmentMalformed(msg)); } debug!("MMapGuard: Reading the VMClockShmHeader ..."); @@ -71,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 { @@ -83,7 +101,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 @@ -97,14 +115,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. @@ -149,18 +167,13 @@ 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) { - 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. @@ -186,8 +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); - return Err(ShmError::SegmentVersionNotSupported); + let msg = format!( + "VMClock shared memory segment has version {version_number} which is not supported by this version of the VMClockShmReader." + ); + error!(msg); + return Err(ShmError::SegmentVersionNotSupported(msg)); } // Log the counter_id in the shared memory segment. @@ -207,12 +223,17 @@ 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::()) }; - 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, @@ -227,15 +248,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 @@ -247,8 +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); - return Err(ShmError::SegmentVersionNotSupported); + let msg = format!( + "VMClock shared memory segment has version {version} which is not supported by this version of the VMClockShmReader." + ); + error!(msg); + return Err(ShmError::SegmentVersionNotSupported(msg)); } // Atomically read the current seq_count in the shared memory segment @@ -294,22 +319,23 @@ 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; } // Attempts to read the snapshot have failed. - Err(ShmError::SegmentNotInitialized) + Err(ShmError::SegmentNotInitialized(String::from( + "Failed to read the SHM segment after all attempts", + ))) } } #[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; @@ -432,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. @@ -446,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 @@ -490,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 @@ -534,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 @@ -579,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-vmclock/src/shm_writer.rs b/clock-bound/src/vmclock/shm_writer.rs similarity index 89% rename from clock-bound-vmclock/src/shm_writer.rs rename to clock-bound/src/vmclock/shm_writer.rs index 3d1bc5d..e5516ee 100644 --- a/clock-bound-vmclock/src/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; @@ -10,9 +11,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::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 { @@ -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) { @@ -132,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() + ))) } } @@ -147,7 +152,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 +162,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 +175,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 +204,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 +249,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 +282,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"); @@ -307,7 +301,7 @@ mod t_writer { /// afterwards. use tempfile::NamedTempFile; - use crate::shm::VMClockClockStatus; + use crate::vmclock::shm::VMClockClockStatus; macro_rules! vmclockshmbody { () => { diff --git a/docs/assets/ClockErrorBound.png b/docs/assets/ClockErrorBound.png index 842fb1b..e185ba4 100644 Binary files a/docs/assets/ClockErrorBound.png and b/docs/assets/ClockErrorBound.png differ 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. 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/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. 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. diff --git a/examples/client/c/src/clockbound_loop_forever.c b/examples/client/c/src/clockbound_loop_forever.c index ca2e103..2b9b8ba 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_open_with(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..f1945b6 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. @@ -77,28 +91,26 @@ 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 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_open(&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 +124,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; } diff --git a/examples/client/rust/Cargo.toml b/examples/client/rust/Cargo.toml index c6025e3..372aea8 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 = { 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 ddb7835..72e8a30 100644 --- a/examples/client/rust/src/main.rs +++ b/examples/client/rust/src/main.rs @@ -1,5 +1,11 @@ -use clock_bound_client::{ - ClockBoundClient, ClockBoundError, ClockStatus, CLOCKBOUND_SHM_DEFAULT_PATH, +#![expect( + clippy::cast_possible_truncation, + clippy::cast_lossless, + clippy::uninlined_format_args, + clippy::cast_precision_loss +)] +use clock_bound::client::{ + CLOCKBOUND_SHM_CLIENT_DEFAULT_PATH, ClockBoundClient, ClockBoundError, ClockStatus, VMCLOCK_SHM_DEFAULT_PATH, }; use nix::sys::time::TimeSpec; @@ -7,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, @@ -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; @@ -81,7 +91,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-adjust-clock-test/Cargo.toml b/test/clock-bound-adjust-clock-test/Cargo.toml new file mode 100644 index 0000000..470c353 --- /dev/null +++ b/test/clock-bound-adjust-clock-test/Cargo.toml @@ -0,0 +1,27 @@ +[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 = { 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"] } + +[dev-dependencies] +rstest = "0.26" 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 0000000..d2a1b13 Binary files /dev/null and b/test/clock-bound-adjust-clock-test/sample_plot1.png differ 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 0000000..cd0fc34 Binary files /dev/null and b/test/clock-bound-adjust-clock-test/sample_plot2.png differ 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..02eba2c --- /dev/null +++ b/test/clock-bound-adjust-clock-test/src/adjust_clock_test.rs @@ -0,0 +1,513 @@ +//! 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::{ + clock_state::clock_adjust::{KAPIClockAdjuster, NtpAdjTimeError, NtpAdjTimeExt}, + time::{Duration, Instant, tsc::Skew}, +}; +use tracing::info; + +#[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`. 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 + /// 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 { + // 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. + #[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 = 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() + ); + } + } +} diff --git a/test/clock-bound-adjust-clock/Cargo.toml b/test/clock-bound-adjust-clock/Cargo.toml new file mode 100644 index 0000000..5456774 --- /dev/null +++ b/test/clock-bound-adjust-clock/Cargo.toml @@ -0,0 +1,28 @@ +[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 = "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 = { 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..176a8a9 --- /dev/null +++ b/test/clock-bound-adjust-clock/README.md @@ -0,0 +1,69 @@ +# Test program: clock-bound-adjust-clock + +This directory contains the source code for a test program `clock-bound-adjust-clock`. + +### `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` +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) + +### `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`). + +Currently only Linux is supported. + +## Building with Cargo + +Run the following command to build the example programs. + +``` +cargo build --release +``` + +## Running `adjust-clock` + +The build artifact should show up at +``` +./target/release/adjust-clock +``` + +You can run the command like below, and the output should look similar: +``` +$ ./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. +``` + +## Running `step-clock` + +The build artifact should show up at +``` +./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/adjust_clock.rs b/test/clock-bound-adjust-clock/src/adjust_clock.rs new file mode 100644 index 0000000..224662e --- /dev/null +++ b/test/clock-bound-adjust-clock/src/adjust_clock.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::clock_adjust::{KAPIClockAdjuster, NtpAdjTimeExt}, + 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 = 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(()) +} 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..dfbcbd5 --- /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::clock_adjust::{KAPIClockAdjuster, NtpAdjTimeExt}, + 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 = 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(()) +} diff --git a/test/clock-bound-client-generic/Cargo.toml b/test/clock-bound-client-generic/Cargo.toml new file mode 100644 index 0000000..9c66bc1 --- /dev/null +++ b/test/clock-bound-client-generic/Cargo.toml @@ -0,0 +1,34 @@ +[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 = { path = "../../clock-bound", features = ["client", "daemon"] } +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", +] } +thiserror = "2.0" +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..14a84e4 --- /dev/null +++ b/test/clock-bound-client-generic/src/lib.rs @@ -0,0 +1,168 @@ +//! 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 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; +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; + +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 { + 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. + fn now(&mut self) -> Result { + match self { + 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, Clone, 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/Cargo.toml b/test/clock-bound-now/Cargo.toml new file mode 100644 index 0000000..6d61cbd --- /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 = { 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..b6bbe80 --- /dev/null +++ b/test/clock-bound-now/src/main.rs @@ -0,0 +1,24 @@ +//! A program to read the ClockBound SHM segment's earliest and latest bounds. + +use clap::Parser; +use clock_bound_client_generic::{ + ClockBoundClient, ClockBoundClientClock, 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.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 new file mode 100644 index 0000000..8a5af7c --- /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-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", +] } +nix = { version = "0.26" } +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/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..0ee13fa --- /dev/null +++ b/test/clock-bound-phc-offset/src/lib.rs @@ -0,0 +1,4 @@ +mod phc; +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 new file mode 100644 index 0000000..27f28c1 --- /dev/null +++ b/test/clock-bound-phc-offset/src/main.rs @@ -0,0 +1,52 @@ +//! 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_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)] +#[command(version, about, long_about = None)] +struct Args { + #[arg(value_enum, long)] + clock: ClockToCompare, +} + +fn emit_logs(data: T) { + println!("{}", serde_json::to_string(&data).unwrap()); +} + +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"); + + match clock { + 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(phc_reader.compare_to_clock_bound_client_clock(&mut client)); + } + ClockToCompare::ClockBoundClientV3 => { + let mut client = ClockBoundClient::new(ClockBoundClientVersion::V3); + 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 new file mode 100644 index 0000000..3ee09fa --- /dev/null +++ b/test/clock-bound-phc-offset/src/phc.rs @@ -0,0 +1,230 @@ +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; +use clock_bound::daemon::time::{ClockExt, Duration, Instant, TscCount, TscDiff}; + +mod ptp; +use ptp::PtpReader; + +mod ceb; +use ceb::PhcClockErrorBoundReader; + +mod autoconfiguration; +pub use autoconfiguration::autoconfigure_phc_reader; + +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 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, + /// 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, +} + +/// 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: Result, + exchange_metadata: Option, + 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: Result, + tsc_pre: TscCount, + ref_clock: Result, + exchange_metadata: Option, + tsc_post: TscCount, + clock_post: Result, +} + +#[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 { + /// 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); + Self { + _phc_device_file: phc_device_file, + ptp_reader, + ceb_reader, + } + } + + /// Retrieves a timestamp from the PHC device. + /// + /// # Errors + /// Returns an error if the PTP system call fails. + 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, + ) -> BasicClockRefComparison { + 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 tsc_pre = TscCount::new(tsc_pre.into()); + let tsc_post = TscCount::new(tsc_post.into()); + + 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, + }) + }; + + 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, + ) -> 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 tsc_post = read_timestamp_counter_end(); + 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 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, + }) + }; + + ClockBoundClientRefComparison { + clock_pre, + tsc_pre, + ref_clock, + exchange_metadata, + tsc_post, + clock_post, + } + } +} 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..7e24fe8 --- /dev/null +++ b/test/clock-bound-phc-offset/src/phc/ceb.rs @@ -0,0 +1,35 @@ +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, +} + +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) + .map_err(|e| CebReadError::Io(e.to_string()))?; + let nanos = contents + .trim() + .parse::() + .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 new file mode 100644 index 0000000..40f87a2 --- /dev/null +++ b/test/clock-bound-phc-offset/src/phc/ptp.rs @@ -0,0 +1,79 @@ +use clock_bound::daemon::time::Instant; +use std::fs::File; + +use libc::c_uint; +use nix::{errno::Errno, 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 _ = unsafe { + ptp_sys_offset_extended2(self.phc_device_fd, &raw mut self.ptp_sys_offset_extended) + }?; + let phc = self.ptp_sys_offset_extended.ts[0][1]; + Ok(Instant::from_time(phc.sec.into(), phc.nsec)) + } +} diff --git a/test/clock-bound-vmclock-client-test/Cargo.toml b/test/clock-bound-vmclock-client-test/Cargo.toml index a734dc1..4da0465 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 = { 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 2103008..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::{ - ClockBoundClient, ClockBoundError, ClockStatus, CLOCKBOUND_SHM_DEFAULT_PATH, +use clock_bound::client::{ + 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, @@ -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 { @@ -54,7 +58,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/link-local/Cargo.toml b/test/link-local/Cargo.toml new file mode 100644 index 0000000..2222f0f --- /dev/null +++ b/test/link-local/Cargo.toml @@ -0,0 +1,24 @@ +[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 = { 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/Makefile.toml b/test/link-local/Makefile.toml new file mode 100644 index 0000000..35a64f9 --- /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 test/link-local" +''' 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 new file mode 100644 index 0000000..275549e --- /dev/null +++ b/test/link-local/src/main.rs @@ -0,0 +1,267 @@ +//! 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::event::{Ntp, Stratum, ValidStratumLevel}; +use clock_bound::daemon::io::{SourceIO, ntp::LINK_LOCAL_BURST_INTERVAL_DURATION}; +use clock_bound::daemon::selected_clock::{ClockSource, SelectedClockSource}; +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. +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(); + + 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 +async fn setup() -> (Receiver, Arc, SourceIO) { + let (sender, receiver) = async_ring_buffer::create(1); + + 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); + sourceio.create_link_local(sender).await; + sourceio.spawn_all(); + (receiver, selected_clock, 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; + sourceio.spawn_all(); +} + +/// Test normal polling +async fn test_normal_polling_rate(receiver: &Receiver) { + println!("Testing normal polling rate ..."); + + // 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; + + 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 startup polling +async fn test_startup_polling_rate(receiver: &Receiver) { + println!("Testing startup polling rate ..."); + 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(receiver: &Receiver, sourceio: &mut SourceIO) { + 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); + + setup_with_vmclock(vmclock_shm_path, sourceio).await; + + // 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; + 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_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!( + 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; + 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 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; + } else { + polling_rate += d; + count += 1; + } + } + polling_rate /= count - 1; + polling_rate +} + +/// Test polling with varied selected clock source and stratum combinations +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(); + println!( + "Generated {} selected clock source combinations", + combinations.len() + ); + + 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); + } + } + + // 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}"); + } + } + } + + 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/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, + } + } +} diff --git a/test/ntp-source/Cargo.toml b/test/ntp-source/Cargo.toml new file mode 100644 index 0000000..7fc6a3a --- /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 = { 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/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..2399455 --- /dev/null +++ b/test/ntp-source/README.md @@ -0,0 +1,40 @@ +# 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. + +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 + +## 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-source-test +``` + + +The output should look something like the following: + +```sh +$ ./ntp-source-test +NTP Source creation complete! +Lets get NTP packets! +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 new file mode 100644 index 0000000..89430d1 --- /dev/null +++ b/test/ntp-source/src/main.rs @@ -0,0 +1,181 @@ +//! 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::{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::{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 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 { + let (tx, rx) = async_ring_buffer::create(1); + receiver_vec.push(rx); + let sender_with_address: NTPSourceSender = (address, tx); + sourceio.create_ntp_source(sender_with_address).await; + } + + 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() { + #[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!("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 +} diff --git a/test/phc/Cargo.toml b/test/phc/Cargo.toml new file mode 100644 index 0000000..5c9b379 --- /dev/null +++ b/test/phc/Cargo.toml @@ -0,0 +1,25 @@ +[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 = { 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..38197c1 --- /dev/null +++ b/test/phc/README.md @@ -0,0 +1,77 @@ +# 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 PHC samples! +PHC event received. +Ok( + Phc { + tsc_pre: TscCount( + 1425415815539165, + ), + tsc_post: TscCount( + 1425415815545741, + ), + data: PhcData { + time: Instant( + 1763144203.339_967_033, + ), + clock_error_bound: Duration( + 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 new file mode 100644 index 0000000..4a03140 --- /dev/null +++ b/test/phc/src/main.rs @@ -0,0 +1,87 @@ +//! PHC test executable. +//! +//! This executable tests that the PHC runner is able to read timestamps and error bounds, +//! 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; +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 PHC samples!"); + let (phc_sender, phc_receiver) = async_ring_buffer::create(1); + + 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; + let mut start = time::Instant::now(); + + 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!( + "PHC event received.\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 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."); +} diff --git a/test/vmclock-updater/Cargo.toml b/test/vmclock-updater/Cargo.toml index 5e685ce..6ba416a 100644 --- a/test/vmclock-updater/Cargo.toml +++ b/test/vmclock-updater/Cargo.toml @@ -17,13 +17,15 @@ name = "vmclock-updater" path = "src/main.rs" [dependencies] -clock-bound-vmclock = { version = "2.0", path = "../../clock-bound-vmclock" } +clock-bound = { 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" } 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 5b70a64..4dc512c 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::{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. #[derive(Parser, Debug)] @@ -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:?}" + ); } diff --git a/test/vmclock/Cargo.toml b/test/vmclock/Cargo.toml new file mode 100644 index 0000000..7ffab71 --- /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 = "3.0.0-alpha.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..0154cab --- /dev/null +++ b/test/vmclock/src/main.rs @@ -0,0 +1,39 @@ +//! 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; + 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."); +}