From fa7b61cea19a033666828919f739c46717cc4892 Mon Sep 17 00:00:00 2001 From: uniblab Date: Tue, 25 Aug 2026 19:54:01 -0400 Subject: [PATCH 1/3] initial add --- .editorconfig | 51 ++++++++++++++++ LICENSE | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++ icon.png | Bin 0 -> 6641 bytes 3 files changed, 216 insertions(+) create mode 100644 .editorconfig create mode 100644 LICENSE create mode 100644 icon.png diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b37572f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,51 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 4 +quote_type = double +max_line_length = 120 + +[*.{cs,csproj,props,targets,xml}] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = tab +indent_size = 4 +tab_width = 4 + +[*.cs] +# C# style preferences +csharp_new_line_before_open_brace = none:warning +csharp_new_line_before_else = false:warning +csharp_new_line_before_catch = false:warning +csharp_new_line_before_finally = false:warning +csharp_prefer_braces = true:warning +csharp_style_expression_bodied_methods = when_possible:suggestion +csharp_style_expression_bodied_properties = when_possible:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_var_elsewhere = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +dotnet_analyzer_diagnostic.category-Style.severity = none +dotnet_diagnostic.CA1016.severity = none +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_property = false:suggestion + +# Naming conventions +dotnet_naming_rule.types_should_be_pascal_case.severity = warning +dotnet_naming_rule.types_should_be_pascal_case.symbols = all_types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.all_types.applicable_kinds = class, struct, interface, enum, delegate + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +[**/tests/**] +max_line_length = off diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser 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 +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6258ecf9ef057d70e50e89644256a4c0f404b73e GIT binary patch literal 6641 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 Date: Tue, 25 Aug 2026 21:20:28 -0400 Subject: [PATCH 2/3] initial add --- .gitattributes | 1 + .github/workflows/pr-build-and-test.yaml | 21 + .github/workflows/push-main.yaml | 77 ++ CONTRIBUTING.md | 111 ++ Icod.Host.csproj | 89 ++ Icod.Host.sln | 51 + README.md | 167 +++ build.cmd | 71 ++ build.sh | 62 + .../Icod.Host.Sample/Icod.Host.Sample.csproj | 17 + samples/Icod.Host.Sample/Program.cs | 63 + src/HostIdentifier.cs | 102 ++ src/HostResourceParsers.cs | 169 +++ src/HostResourceValue.cs | 156 +++ src/IHostResourceProvider.cs | 34 + src/ProcessorResourceModels.cs | 349 +++++ src/README.md | 38 + src/SystemHostResourceProvider.cs | 1122 +++++++++++++++++ tests/Host.Tests/Icod.Host.Tests.csproj | 52 + tests/Host.Tests/src/HostIdentifierTests.cs | 53 + .../Host.Tests/src/HostResourceParserTests.cs | 89 ++ .../Host.Tests/src/HostResourceValueTests.cs | 30 + .../src/SystemHostResourceProviderTests.cs | 112 ++ 23 files changed, 3036 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/pr-build-and-test.yaml create mode 100644 .github/workflows/push-main.yaml create mode 100644 CONTRIBUTING.md create mode 100644 Icod.Host.csproj create mode 100644 Icod.Host.sln create mode 100644 README.md create mode 100644 build.cmd create mode 100644 build.sh create mode 100644 samples/Icod.Host.Sample/Icod.Host.Sample.csproj create mode 100644 samples/Icod.Host.Sample/Program.cs create mode 100644 src/HostIdentifier.cs create mode 100644 src/HostResourceParsers.cs create mode 100644 src/HostResourceValue.cs create mode 100644 src/IHostResourceProvider.cs create mode 100644 src/ProcessorResourceModels.cs create mode 100644 src/README.md create mode 100644 src/SystemHostResourceProvider.cs create mode 100644 tests/Host.Tests/Icod.Host.Tests.csproj create mode 100644 tests/Host.Tests/src/HostIdentifierTests.cs create mode 100644 tests/Host.Tests/src/HostResourceParserTests.cs create mode 100644 tests/Host.Tests/src/HostResourceValueTests.cs create mode 100644 tests/Host.Tests/src/SystemHostResourceProviderTests.cs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/pr-build-and-test.yaml b/.github/workflows/pr-build-and-test.yaml new file mode 100644 index 0000000..b85e792 --- /dev/null +++ b/.github/workflows/pr-build-and-test.yaml @@ -0,0 +1,21 @@ +name: pr-build-and-test + +on: + pull_request: + +jobs: + build-and-test: + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - run: dotnet clean Icod.Host.sln -c Staging + - run: dotnet restore Icod.Host.sln + - run: dotnet build Icod.Host.sln -c Staging --no-restore + - run: dotnet test Icod.Host.sln -c Staging --no-build --logger trx diff --git a/.github/workflows/push-main.yaml b/.github/workflows/push-main.yaml new file mode 100644 index 0000000..746b8db --- /dev/null +++ b/.github/workflows/push-main.yaml @@ -0,0 +1,77 @@ +name: build and publish + +on: + push: + branches: + - main + +permissions: + id-token: write + contents: read + packages: write + +jobs: + build-and-test: + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - run: dotnet clean Icod.Host.sln -c Release + - run: dotnet restore Icod.Host.sln + - run: dotnet build Icod.Host.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true + - run: dotnet test Icod.Host.sln -c Release --no-build --logger trx + + - name: Pack NuGet Package + if: matrix.os == 'windows-latest' + run: dotnet pack Icod.Host.csproj -c Release --no-build -o ./artifacts + + - name: Upload Artifact + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: ./artifacts/*nupkg + + deploy: + needs: build-and-test + runs-on: windows-latest + environment: Release + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + name: nuget-package + path: ./artifacts + + - name: NuGet login (OIDC → temp API key) + uses: NuGet/login@v1 + id: login + with: + user: ${{ secrets.NUGET_USER }} + + - name: NuGet push + shell: pwsh + run: | + Get-ChildItem "./artifacts/Icod.Host.*.nupkg" | ForEach-Object { + dotnet nuget push $_.FullName --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + } + + - name: Push to GitHub Packages + shell: pwsh + run: | + Get-ChildItem "./artifacts/Icod.Host.*.nupkg" | ForEach-Object { + dotnet nuget push $_.FullName --api-key "${{ secrets.GITHUB_TOKEN }}" --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --skip-duplicate + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9075c4a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,111 @@ +# Contributing to Icod.Host + +Thank you for contributing to `Icod.Host`. The library provides neutral, +cross-platform host identity and processor-resource observations. Changes should +preserve factual semantics, explicit platform limitations, and provenance. + +## Supported toolchain + +- Target framework: `net10.0`. +- Language version: C# 13. +- Nullable reference types and implicit global usings remain enabled. +- Supported CI runners are `windows-latest`, `ubuntu-latest`, and + `macos-latest`. +- Repository text files use UTF-8 with LF line endings. +- Public, protected, and internal types and members should have substantive XML + documentation; use `` where appropriate. + +Do not change the target framework, language version, configuration policy, or +repository line-ending convention as part of an unrelated contribution. + +## Architecture + +`Icod.Host` owns neutral factual observations such as: + +- normalized host identity; +- processor counts; +- process affinity or processor-set selection; +- hard CPU quotas; +- processor and NUMA topology; +- observation availability and provenance; and +- narrow platform adapters needed to obtain those facts. + +Do not add GNU `hostid`/`nproc` command policy, process control, ProcPs-specific +metrics or `/proc` models, command-line parsing, or command-hosting +infrastructure. + +`ObservationFidelity` is also outside this package. Semantic-fidelity policy +belongs to consumers that map factual host observations into higher-level +cross-platform models. + +The package should remain free of dependencies on `Icod.CommandFramework`, +`Icod.CoreUtils`, and `Icod.ProcPs`. + +## C# style + +Follow `.editorconfig` and the surrounding source. In particular: + +- use tabs for C# indentation; +- use 1TBS braces and always brace conditional and loop bodies; +- use PascalCase for types and members and camelCase for locals and parameters; +- validate public, protected, and internal method parameters at entry; +- keep nullable flow explicit rather than suppressing warnings casually; +- propagate `CancellationToken` through asynchronous work; and +- avoid unrelated formatting churn. + +Unsupported platform behavior must remain explicit. Do not fabricate Unix +capabilities or substitute unrelated measurements merely to return a value. + +## Tests + +Add or update tests for changed behavior. Important cases include: + +- deterministic host-ID normalization; +- availability/provenance behavior; +- Linux CPU-list parsing; +- cgroup v1 and v2 quota parsing; +- affinity descriptor validation; +- processor quota validation; +- provider injection and cancellation; and +- controlled system-provider behavior on Windows, Linux, and macOS. + +Tests must not write to standard output or standard error unless explicitly +communicating with another process. Keep any temporary resources uniquely named +and delete only resources owned by the test. + +## Build and validation + +From the repository root: + +```text +dotnet clean Icod.Host.sln -c Debug +dotnet restore Icod.Host.sln +dotnet build Icod.Host.sln -c Debug --no-restore +dotnet test Icod.Host.sln -c Debug --no-build +``` + +Before merge or release, also validate Release: + +```text +dotnet clean Icod.Host.sln -c Release +dotnet restore Icod.Host.sln +dotnet build Icod.Host.sln -c Release --no-restore +dotnet test Icod.Host.sln -c Release --no-build +``` + +`build.cmd` and `build.sh` may be used for the standard local sequence. Pull +requests run Staging across all three CI operating systems; pushes to `main` +run Release and publish only after the complete Release matrix succeeds. + +## Pull requests and commits + +Keep changes focused. A pull request should identify: + +- the factual host-resource contract being changed; +- important platform-specific behavior; +- added or changed tests; +- build/test commands and platforms used; and +- intentionally unsupported or deferred behavior. + +Use concise imperative commit subjects. Discuss cross-package ownership changes +before introducing a new shared abstraction. diff --git a/Icod.Host.csproj b/Icod.Host.csproj new file mode 100644 index 0000000..838b23e --- /dev/null +++ b/Icod.Host.csproj @@ -0,0 +1,89 @@ + + + + Copyright (c) 2026 Timothy J. Bruce + 13.0 + Library + net10.0 + enable + enable + true + false + Icod.Host + Icod.Host + Debug;Release;Staging + 1.0.0 + + + AnyCPU + + + prompt + 2 + true + full + false + DEBUG;TRACE + false + false + + + prompt + 3 + true + full + false + TRACE + false + false + + + prompt + 4 + portable + true + false + true + CS1591 + + + 1.0.0 + Initial standalone host identity and processor-resource observation release. + Timothy J. Bruce + Cross-platform .NET host resource observations for normalized host identity, processor counts, affinity, CPU quotas, topology, NUMA, and provenance. + README.md + LGPL-3.0-or-later + True + true + True + snupkg + true + true + host;host-resources;system-information;processor;cpu;cpu-affinity;cpu-quota;processor-topology;numa;host-id;machine-id;cgroup;job-object;sysctl;hardware;resource-observation;cross-platform;dotnet;csharp;system-resources + https://github.com/uniblab/Icod.Host + https://github.com/uniblab/Icod.Host + git + icon.png + False + + + + + + + + + + PreserveNewest + $(AssemblyName).README.md + true + \ + + + PreserveNewest + $(AssemblyName).LICENSE.txt + true + \ + + + diff --git a/Icod.Host.sln b/Icod.Host.sln new file mode 100644 index 0000000..77f628a --- /dev/null +++ b/Icod.Host.sln @@ -0,0 +1,51 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.37411.7 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.Host", "Icod.Host.csproj", "{8D07C4AB-94E0-49E8-9160-F71FA79266B0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{6D3334D0-F8D2-49F7-A07B-5BA42609D78A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.Host.Tests", "tests\Host.Tests\Icod.Host.Tests.csproj", "{5E2CA2D7-172A-4DCE-A2FE-B38A54C93801}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{B3479FCE-558D-402B-92D3-E42218843A55}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.Host.Sample", "samples\Icod.Host.Sample\Icod.Host.Sample.csproj", "{10D44A87-CF88-49D8-9E4B-83D81D57645F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + Staging|Any CPU = Staging|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8D07C4AB-94E0-49E8-9160-F71FA79266B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8D07C4AB-94E0-49E8-9160-F71FA79266B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8D07C4AB-94E0-49E8-9160-F71FA79266B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8D07C4AB-94E0-49E8-9160-F71FA79266B0}.Release|Any CPU.Build.0 = Release|Any CPU + {8D07C4AB-94E0-49E8-9160-F71FA79266B0}.Staging|Any CPU.ActiveCfg = Staging|Any CPU + {8D07C4AB-94E0-49E8-9160-F71FA79266B0}.Staging|Any CPU.Build.0 = Staging|Any CPU + {5E2CA2D7-172A-4DCE-A2FE-B38A54C93801}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E2CA2D7-172A-4DCE-A2FE-B38A54C93801}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E2CA2D7-172A-4DCE-A2FE-B38A54C93801}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E2CA2D7-172A-4DCE-A2FE-B38A54C93801}.Release|Any CPU.Build.0 = Release|Any CPU + {5E2CA2D7-172A-4DCE-A2FE-B38A54C93801}.Staging|Any CPU.ActiveCfg = Staging|Any CPU + {5E2CA2D7-172A-4DCE-A2FE-B38A54C93801}.Staging|Any CPU.Build.0 = Staging|Any CPU + {10D44A87-CF88-49D8-9E4B-83D81D57645F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10D44A87-CF88-49D8-9E4B-83D81D57645F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10D44A87-CF88-49D8-9E4B-83D81D57645F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10D44A87-CF88-49D8-9E4B-83D81D57645F}.Release|Any CPU.Build.0 = Release|Any CPU + {10D44A87-CF88-49D8-9E4B-83D81D57645F}.Staging|Any CPU.ActiveCfg = Staging|Any CPU + {10D44A87-CF88-49D8-9E4B-83D81D57645F}.Staging|Any CPU.Build.0 = Staging|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {5E2CA2D7-172A-4DCE-A2FE-B38A54C93801} = {6D3334D0-F8D2-49F7-A07B-5BA42609D78A} + {10D44A87-CF88-49D8-9E4B-83D81D57645F} = {B3479FCE-558D-402B-92D3-E42218843A55} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6095CBAE-D85C-452D-9A41-3F9FA2D53CE2} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..30edf8e --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# Icod.Host + +`Icod.Host` is a cross-platform .NET library for factual host identity and +processor-resource observation. It provides neutral system facts without tying +callers to a command framework or to a command suite such as CoreUtils or +ProcPs. + +The library is the standalone successor to the factual provider layer that was +originally incubated under `Icod.CommandFramework.Host`. + +## Features + +- normalized 32-bit host identifiers with source descriptions; +- explicit resource availability and provenance; +- configured, installed/present, online, and process-available processor counts; +- current-process affinity or processor-set observations; +- container, cgroup, and Windows job-object hard CPU quota observations; +- processor package, physical-core, logical-processor, and NUMA topology; +- capability reports derived from the individual observations; +- deterministic Linux CPU-list and cgroup-quota parsers; and +- injectable host and processor-resource provider contracts. + +`Icod.Host` deliberately reports `Unavailable`, `Unsupported`, and +`NotApplicable` distinctly. Consumers should not replace an unsupported +topology, affinity, or quota observation with a plausible-looking zero. + +## Requirements + +The initial `1.0.0` release targets .NET 10.0 and uses C# 13. + +The package has no runtime package dependencies. + +## Installation + +```text +Install-Package Icod.Host -Version 1.0.0 +``` + +or: + +```text +dotnet add package Icod.Host --version 1.0.0 +``` + +## Example + +```csharp +using Icod.Host; + +HostResourceSnapshot snapshot = + await SystemHostResourceProvider.Instance.ObserveAsync(); + +if ( snapshot.HostIdentifier.IsAvailable ) { + Console.WriteLine( + snapshot.HostIdentifier.GetRequiredValue().Hexadecimal + ); +} + +Console.WriteLine( + snapshot.Processors.ProcessAvailableProcessorCount.GetRequiredValue() +); +``` + +A larger runnable example is available under `samples/Icod.Host.Sample`. + +## Platform profile + +| Fact | Windows | Linux | macOS | Other/BSD fallback | +| --- | --- | --- | --- | --- | +| Host identifier | Stable MachineGuid folded to 32 bits | Native `gethostid`, then machine-id/host-name fallback | Native `gethostid`, then host-name fallback | Native `gethostid` where available, then stable-text fallback | +| Configured processors | Maximum processor-group capacity | `sysconf(_SC_NPROCESSORS_CONF)` | `hw.logicalcpu_max` / `hw.ncpu` | Unsupported | +| Installed processors | Active processors across groups | sysfs `present`, then configured count | Configured logical processors | Unsupported | +| Online processors | Active processors across groups | sysfs `online`, then `sysconf` | `hw.logicalcpu` / `hw.ncpu` | Unsupported | +| Process-available processors | `Environment.ProcessorCount` | `Environment.ProcessorCount` | `Environment.ProcessorCount` | `Environment.ProcessorCount` | +| Affinity / processor set | Default CPU sets, then process-group mask | `sched_getaffinity` | Unsupported | Unsupported | +| Hard CPU quota | Job-object hard or maximum rate | cgroup v2 `cpu.max` or cgroup v1 CFS quota | Unsupported | Unsupported | +| Topology / NUMA | Group-aware logical processor information | sysfs package/core/node directories | package/core/logical sysctls; NUMA unsupported | Process-available logical count only | + +Windows CPU-set values are labeled as opaque CPU-set identifiers rather than +logical processor indices. A legacy affinity mask that covers only the current +Windows processor group is marked incomplete. Relative Windows job weights are +not misrepresented as hard quotas. + +On Linux, the affinity observation reflects the effective scheduler mask, +including cpuset restrictions. Cgroup membership paths are rooted and checked +for containment before controller files are read. + +## Host identifier normalization + +Native signed host identifiers are normalized to their low unsigned 32 bits. + +Stable textual machine identifiers are trimmed and, when hexadecimal, decoded +to bytes before deterministic FNV-1a folding. Other text is normalized to +lowercase invariant UTF-8 before folding. Raw Windows MachineGuid and Linux +machine-id values are not exposed by the public snapshot. + +## Design boundary + +`Icod.Host` owns factual host identity and processor-resource observations. + +It does not own: + +- GNU `hostid` or `nproc` command policy; +- process enumeration or process control; +- ProcPs `/proc` models, process metrics, memory maps, slab data, or command + presentation; +- command-line parsing or diagnostics; or +- wall-clock/date parsing and formatting. + +`ObservationFidelity` is intentionally not part of `Icod.Host`. Semantic +fidelity describes how a consumer maps platform-specific observations onto a +higher-level model; it is separate from this package's factual availability and +provenance contracts. + +## Migrating from Icod.CommandFramework.Host + +Consumers of the factual Host layer can replace: + +```csharp +using Icod.CommandFramework.Host; +``` + +with: + +```csharp +using Icod.Host; +``` + +and reference: + +```xml + +``` + +`ObservationFidelity` is not migrated by this package. + +## Building + +On Windows: + +```text +build.cmd +``` + +On Unix-like hosts: + +```text +./build.sh +``` + +Both scripts support `clean`, `restore`, `build`, `test`, and `pack`. With no +argument they run the complete local sequence. + +Pull requests build and test the Staging configuration on Windows, Ubuntu, and +macOS. Pushes to `main` build and test Release on all three platforms and +publish only after the Release matrix succeeds. + +## Author + +Timothy J. Bruce + +Copyright (c) 2026 Timothy J. Bruce. + +## License + +Licensed under the GNU Lesser General Public License v3.0 or later +(`LGPL-3.0-or-later`). See `LICENSE` for the complete license text. diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..dcf72ad --- /dev/null +++ b/build.cmd @@ -0,0 +1,71 @@ +@echo off +setlocal + +if "%~1"=="" goto all +if /I "%~1"=="clean" goto clean +if /I "%~1"=="restore" goto restore +if /I "%~1"=="build" goto build +if /I "%~1"=="test" goto test +if /I "%~1"=="pack" goto pack + +echo Invalid section: %~1 +echo Usage: %~nx0 [clean^|restore^|build^|test^|pack] +exit /b 1 + +:all +call :do_clean || exit /b 1 +call :do_restore || exit /b 1 +call :do_build || exit /b 1 +call :do_test || exit /b 1 +call :do_pack || exit /b 1 +exit /b 0 + +:clean +call :do_clean +exit /b %errorlevel% + +:restore +call :do_restore +exit /b %errorlevel% + +:build +call :do_build +exit /b %errorlevel% + +:test +call :do_test +exit /b %errorlevel% + +:pack +call :do_pack +exit /b %errorlevel% + +:do_clean +echo. +echo === Clean === +dotnet clean Icod.Host.sln -c Debug +exit /b %errorlevel% + +:do_restore +echo. +echo === Restore === +dotnet restore Icod.Host.sln +exit /b %errorlevel% + +:do_build +echo. +echo === Build === +dotnet build Icod.Host.sln -c Debug --no-restore +exit /b %errorlevel% + +:do_test +echo. +echo === Test === +dotnet test Icod.Host.sln -c Debug --no-build +exit /b %errorlevel% + +:do_pack +echo. +echo === Pack === +dotnet pack Icod.Host.csproj -c Debug --include-source --include-symbols --no-build +exit /b %errorlevel% diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..380bafb --- /dev/null +++ b/build.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env sh +set -eu + +clean() +{ + printf '\n=== Clean ===\n' + dotnet clean Icod.Host.sln -c Debug +} + +restore() +{ + printf '\n=== Restore ===\n' + dotnet restore Icod.Host.sln +} + +build() +{ + printf '\n=== Build ===\n' + dotnet build Icod.Host.sln -c Debug --no-restore +} + +test() +{ + printf '\n=== Test ===\n' + dotnet test Icod.Host.sln -c Debug --no-build +} + +pack() +{ + printf '\n=== Pack ===\n' + dotnet pack Icod.Host.csproj -c Debug --include-source --include-symbols --no-build +} + +case "${1-}" in + "") + clean + restore + build + test + pack + ;; + clean) + clean + ;; + restore) + restore + ;; + build) + build + ;; + test) + test + ;; + pack) + pack + ;; + *) + printf 'Invalid section: %s\n' "$1" >&2 + printf 'Usage: %s [clean|restore|build|test|pack]\n' "$0" >&2 + exit 1 + ;; +esac diff --git a/samples/Icod.Host.Sample/Icod.Host.Sample.csproj b/samples/Icod.Host.Sample/Icod.Host.Sample.csproj new file mode 100644 index 0000000..78b2f47 --- /dev/null +++ b/samples/Icod.Host.Sample/Icod.Host.Sample.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + 13.0 + enable + enable + false + Icod.Host.Sample + Icod.Host.Sample + Debug;Release;Staging + + + + + diff --git a/samples/Icod.Host.Sample/Program.cs b/samples/Icod.Host.Sample/Program.cs new file mode 100644 index 0000000..6f351dc --- /dev/null +++ b/samples/Icod.Host.Sample/Program.cs @@ -0,0 +1,63 @@ +namespace Icod.Host.Sample; + +using Icod.Host; + +/// Demonstrates the standalone host-resource provider. +public static class Program { + /// Observes and prints selected host and processor facts. + public static async Task Main() { + HostResourceSnapshot snapshot = await SystemHostResourceProvider.Instance.ObserveAsync(); + + Console.WriteLine( + snapshot.HostIdentifier.IsAvailable + ? $"Host ID: {snapshot.HostIdentifier.GetRequiredValue().Hexadecimal}" + : $"Host ID: {snapshot.HostIdentifier.Availability}" + ); + + ProcessorResourceSnapshot processors = snapshot.Processors; + Console.WriteLine( + $"Process-available processors: {processors.ProcessAvailableProcessorCount.GetRequiredValue()}" + ); + WriteObservation( + "Configured processors", + processors.ConfiguredProcessorCount + ); + WriteObservation( + "Online processors", + processors.OnlineProcessorCount + ); + + if ( processors.Affinity.IsAvailable ) { + ProcessorAffinityDescriptor affinity = processors.Affinity.GetRequiredValue(); + Console.WriteLine( + $"Affinity selection: {affinity.Count} processor(s); complete={affinity.IsComplete}" + ); + } else { + Console.WriteLine( + $"Affinity selection: {processors.Affinity.Availability}" + ); + } + + if ( processors.Quota.IsAvailable ) { + Console.WriteLine( + $"CPU quota: {processors.Quota.GetRequiredValue().ProcessorLimit:0.###} processor(s)" + ); + } else { + Console.WriteLine( + $"CPU quota: {processors.Quota.Availability}" + ); + } + } + + private static void WriteObservation( + string label, + HostResourceValue observation + ) { + ArgumentException.ThrowIfNullOrWhiteSpace( label ); + Console.WriteLine( + observation.IsAvailable + ? $"{label}: {observation.GetRequiredValue()} ({observation.Provenance})" + : $"{label}: {observation.Availability} ({observation.Provenance})" + ); + } +} diff --git a/src/HostIdentifier.cs b/src/HostIdentifier.cs new file mode 100644 index 0000000..9f3d2f2 --- /dev/null +++ b/src/HostIdentifier.cs @@ -0,0 +1,102 @@ +namespace Icod.Host; + +using System.Globalization; +using System.Text; + +/// +/// Represents one normalized 32-bit host identifier and the kind of source from +/// which it was obtained. +/// +public sealed record HostIdentifier { + /// + /// Initializes a normalized host identifier. + /// + /// The normalized unsigned 32-bit value. + /// A non-secret description of the source. + /// is null. + public HostIdentifier( + uint value, + string sourceDescription + ) { + Value = value; + SourceDescription = sourceDescription ?? throw new ArgumentNullException( nameof( sourceDescription ) ); + } + + /// Gets the normalized unsigned 32-bit identifier. + public uint Value { get; } + + /// Gets the lowercase eight-digit hexadecimal representation. + public string Hexadecimal => HostIdentifierNormalizer.Format( Value ); + + /// Gets a non-secret description of the identifier source. + public string SourceDescription { get; } +} + +/// +/// Supplies deterministic normalization for native and textual host identifiers. +/// +public static class HostIdentifierNormalizer { + private const uint FnvOffsetBasis = 2166136261; + private const uint FnvPrime = 16777619; + + /// + /// Normalizes the native signed gethostid result to the low unsigned + /// 32 bits used by GNU-compatible presentation. + /// + /// The native signed value. + /// The normalized unsigned value. + public static uint NormalizeNative( long nativeValue ) { + return unchecked((uint)nativeValue); + } + + /// + /// Deterministically folds a stable textual machine identifier to 32 bits. + /// Hexadecimal identifiers are decoded before hashing; other identifiers are + /// normalized to trimmed lowercase invariant text and encoded as UTF-8. + /// + /// The stable textual identifier. + /// The normalized unsigned value. + /// is null. + /// The identifier is empty. + public static uint NormalizeStableText( string identifier ) { + ArgumentNullException.ThrowIfNull( identifier ); + var normalized = identifier.Trim(); + if ( normalized.Length == 0 ) { + throw new ArgumentException( "A host identifier cannot be empty.", nameof( identifier ) ); + } + + var compactHex = new string( + normalized + .Where( + static character => character is not '-' + && character is not '{' + && character is not '}' + && !char.IsWhiteSpace( character ) + ) + .ToArray() + ); + byte[] bytes; + if ( + compactHex.Length >= 8 + && compactHex.Length % 2 == 0 + && compactHex.All( static character => Uri.IsHexDigit( character ) ) + ) { + bytes = Convert.FromHexString( compactHex ); + } else { + bytes = Encoding.UTF8.GetBytes( normalized.ToLowerInvariant() ); + } + + var hash = FnvOffsetBasis; + foreach ( var value in bytes ) { + hash = unchecked((hash ^ value) * FnvPrime); + } + return hash; + } + + /// Formats a normalized identifier as eight lowercase hexadecimal digits. + /// The normalized identifier. + /// The hexadecimal representation. + public static string Format( uint value ) { + return value.ToString( "x8", CultureInfo.InvariantCulture ); + } +} diff --git a/src/HostResourceParsers.cs b/src/HostResourceParsers.cs new file mode 100644 index 0000000..89c4ba4 --- /dev/null +++ b/src/HostResourceParsers.cs @@ -0,0 +1,169 @@ +namespace Icod.Host; + +using System.Globalization; +using System.Numerics; + +/// +/// Provides deterministic parsers used by platform host-resource providers and +/// their tests. +/// +public static class HostResourceParsers { + private const int MaximumLogicalProcessorId = 1_048_575; + + /// + /// Parses a Linux CPU-list expression such as 0-3,8,10-11. + /// + /// The CPU-list text. + /// The sorted, distinct logical processor identifiers. + /// The expression is malformed. + public static IReadOnlyList ParseProcessorList( string text ) { + ArgumentNullException.ThrowIfNull( text ); + var result = new SortedSet(); + foreach ( var untrimmedSegment in text.Split( ',', StringSplitOptions.RemoveEmptyEntries ) ) { + var segment = untrimmedSegment.Trim(); + if ( segment.Length == 0 ) { + continue; + } + var dash = segment.IndexOf( '-' ); + if ( dash < 0 ) { + result.Add( ParseProcessorId( segment ) ); + continue; + } + if ( dash == 0 || dash == segment.Length - 1 || segment.IndexOf( '-', dash + 1 ) >= 0 ) { + throw new FormatException( "The processor-list range is malformed." ); + } + var start = ParseProcessorId( segment[..dash] ); + var end = ParseProcessorId( segment[(dash + 1)..] ); + if ( end < start ) { + throw new FormatException( "The processor-list range ends before it begins." ); + } + for ( var processor = start; processor <= end; processor++ ) { + result.Add( processor ); + } + } + if ( result.Count == 0 ) { + throw new FormatException( "The processor list does not contain a processor identifier." ); + } + return result.ToArray(); + } + + /// Counts selected bits in a native affinity mask. + /// The affinity-mask bytes. + /// The number of selected bits. + public static int CountSetBits( ReadOnlySpan mask ) { + var count = 0; + foreach ( var value in mask ) { + count += BitOperations.PopCount( value ); + } + return count; + } + + /// Returns logical processor identifiers selected by a native affinity mask. + /// The affinity-mask bytes, ordered least-significant byte first. + /// The selected logical processor identifiers. + public static IReadOnlyList GetSetBitIndices( ReadOnlySpan mask ) { + var result = new List(); + for ( var byteIndex = 0; byteIndex < mask.Length; byteIndex++ ) { + for ( var bit = 0; bit < 8; bit++ ) { + if ( 0 != (mask[byteIndex] & (1 << bit)) ) { + result.Add( checked((byteIndex * 8) + bit) ); + } + } + } + return result; + } + + /// Parses the cgroup v2 cpu.max format. + /// The file contents. + /// An available hard quota or a not-applicable unlimited result. + public static HostResourceValue ParseControlGroupV2CpuMax( string text ) { + ArgumentNullException.ThrowIfNull( text ); + var fields = text.Split( (char[]?)null, StringSplitOptions.RemoveEmptyEntries ); + if ( fields.Length != 2 ) { + return HostResourceValue.Unavailable( + "The cgroup v2 cpu.max value is malformed.", + HostResourceProvenance.LinuxControlGroupV2 + ); + } + if ( fields[0].Equals( "max", StringComparison.Ordinal ) ) { + return HostResourceValue.NotApplicable( + "The cgroup v2 CPU controller does not impose a hard quota.", + HostResourceProvenance.LinuxControlGroupV2 + ); + } + if ( + !long.TryParse( fields[0], NumberStyles.None, CultureInfo.InvariantCulture, out var quota ) + || !long.TryParse( fields[1], NumberStyles.None, CultureInfo.InvariantCulture, out var period ) + || quota <= 0 + || period <= 0 + ) { + return HostResourceValue.Unavailable( + "The cgroup v2 CPU quota or period is invalid.", + HostResourceProvenance.LinuxControlGroupV2 + ); + } + return HostResourceValue.Available( + new ProcessorQuotaDescriptor( + (double)quota / period, + quota, + period, + "cgroup v2" + ), + HostResourceProvenance.LinuxControlGroupV2 + ); + } + + /// Parses the cgroup v1 CPU quota and period files. + /// The cpu.cfs_quota_us contents. + /// The cpu.cfs_period_us contents. + /// An available hard quota or a not-applicable unlimited result. + public static HostResourceValue ParseControlGroupV1CpuQuota( + string quotaText, + string periodText + ) { + ArgumentNullException.ThrowIfNull( quotaText ); + ArgumentNullException.ThrowIfNull( periodText ); + if ( + !long.TryParse( quotaText.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var quota ) + || !long.TryParse( periodText.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var period ) + || period <= 0 + ) { + return HostResourceValue.Unavailable( + "The cgroup v1 CPU quota or period is invalid.", + HostResourceProvenance.LinuxControlGroupV1 + ); + } + if ( quota < 0 ) { + return HostResourceValue.NotApplicable( + "The cgroup v1 CPU controller does not impose a hard quota.", + HostResourceProvenance.LinuxControlGroupV1 + ); + } + if ( quota == 0 ) { + return HostResourceValue.Unavailable( + "The cgroup v1 CPU quota is zero.", + HostResourceProvenance.LinuxControlGroupV1 + ); + } + return HostResourceValue.Available( + new ProcessorQuotaDescriptor( + (double)quota / period, + quota, + period, + "cgroup v1" + ), + HostResourceProvenance.LinuxControlGroupV1 + ); + } + + private static int ParseProcessorId( string text ) { + if ( + !int.TryParse( text.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var value ) + || value < 0 + || value > MaximumLogicalProcessorId + ) { + throw new FormatException( "The processor identifier is invalid." ); + } + return value; + } +} diff --git a/src/HostResourceValue.cs b/src/HostResourceValue.cs new file mode 100644 index 0000000..9715b84 --- /dev/null +++ b/src/HostResourceValue.cs @@ -0,0 +1,156 @@ +namespace Icod.Host; + +/// +/// Identifies whether one host-resource value is available and, when it is not, +/// why the provider could not supply it. +/// +public enum HostResourceAvailability { + /// The provider could not obtain the value for this observation. + Unavailable = 0, + /// The value is available. + Available = 1, + /// The current platform does not expose the value. + Unsupported = 2, + /// The value does not apply to the current host or process. + NotApplicable = 3 +} + +/// +/// Identifies the source from which a host-resource value was obtained. +/// +public enum HostResourceProvenance { + /// The provider did not identify a source. + Unknown = 0, + /// The managed runtime supplied the value. + ManagedRuntime = 1, + /// A native operating-system API supplied the value. + NativeOperatingSystem = 2, + /// A Linux procfs file supplied the value. + LinuxProcFileSystem = 3, + /// A Linux sysfs file supplied the value. + LinuxSysFileSystem = 4, + /// A Linux cgroup v2 controller supplied the value. + LinuxControlGroupV2 = 5, + /// A Linux cgroup v1 controller supplied the value. + LinuxControlGroupV1 = 6, + /// The Windows registry supplied the value. + WindowsRegistry = 7, + /// A Windows processor-group API supplied the value. + WindowsProcessorGroup = 8, + /// A Windows CPU-set API supplied the value. + WindowsProcessorSet = 9, + /// A Windows job object supplied the value. + WindowsJobObject = 10, + /// A macOS sysctl supplied the value. + MacOsSysctl = 11, + /// The value was deterministically derived from other host facts. + Derived = 12 +} + +/// +/// Carries one host-resource value together with explicit availability, +/// provenance, and diagnostic information. +/// +/// The observed value type. +public readonly record struct HostResourceValue { + private HostResourceValue( + HostResourceAvailability availability, + HostResourceProvenance provenance, + T? value, + string? message + ) { + Availability = availability; + Provenance = provenance; + Value = value; + Message = message; + } + + /// Gets the availability state. + public HostResourceAvailability Availability { get; } + + /// Gets the source of the observation. + public HostResourceProvenance Provenance { get; } + + /// Gets the observed value when it is available. + public T? Value { get; } + + /// Gets an optional provider explanation. + public string? Message { get; } + + /// Gets whether the value is available. + public bool IsAvailable => Availability == HostResourceAvailability.Available; + + /// + /// Gets the available value or throws when the value is not available. + /// + /// The available value. + /// The value is not available. + public T GetRequiredValue() { + if ( !IsAvailable ) { + throw new InvalidOperationException( Message ?? "The host-resource value is not available." ); + } + return Value!; + } + + /// Creates an available observation. + /// The observed value. + /// The source of the value. + /// An optional explanatory message. + /// The available observation. + /// is null. + public static HostResourceValue Available( + T value, + HostResourceProvenance provenance, + string? message = null + ) { + ArgumentNullException.ThrowIfNull( value ); + return new HostResourceValue( + HostResourceAvailability.Available, + provenance, + value, + message + ); + } + + /// Creates an unavailable observation. + /// An optional explanation. + /// The source that could not supply the value. + /// The unavailable observation. + public static HostResourceValue Unavailable( + string? message = null, + HostResourceProvenance provenance = HostResourceProvenance.Unknown + ) => new( + HostResourceAvailability.Unavailable, + provenance, + default, + message + ); + + /// Creates an unsupported observation. + /// An optional explanation. + /// The provider boundary reporting the limitation. + /// The unsupported observation. + public static HostResourceValue Unsupported( + string? message = null, + HostResourceProvenance provenance = HostResourceProvenance.Unknown + ) => new( + HostResourceAvailability.Unsupported, + provenance, + default, + message + ); + + /// Creates a not-applicable observation. + /// An optional explanation. + /// The provider boundary reporting the state. + /// The not-applicable observation. + public static HostResourceValue NotApplicable( + string? message = null, + HostResourceProvenance provenance = HostResourceProvenance.Unknown + ) => new( + HostResourceAvailability.NotApplicable, + provenance, + default, + message + ); +} diff --git a/src/IHostResourceProvider.cs b/src/IHostResourceProvider.cs new file mode 100644 index 0000000..1e3813d --- /dev/null +++ b/src/IHostResourceProvider.cs @@ -0,0 +1,34 @@ +namespace Icod.Host; + +/// Supplies normalized host-identifier observations. +public interface IHostIdentifierProvider { + /// Gets the current host identifier. + /// A token used to cancel the observation. + /// The host-identifier observation. + ValueTask> GetHostIdentifierAsync( + CancellationToken cancellationToken = default + ); +} + +/// Supplies processor-resource observations. +public interface IProcessorResourceProvider { + /// Gets processor-resource facts for the host and current process. + /// A token used to cancel the observation. + /// The processor-resource snapshot. + ValueTask GetProcessorResourcesAsync( + CancellationToken cancellationToken = default + ); +} + +/// +/// Supplies the combined host and processor-resource foundation for +/// cross-suite and application consumers. +/// +public interface IHostResourceProvider : IHostIdentifierProvider, IProcessorResourceProvider { + /// Gets a combined host-resource snapshot. + /// A token used to cancel the observation. + /// The combined snapshot. + ValueTask ObserveAsync( + CancellationToken cancellationToken = default + ); +} diff --git a/src/ProcessorResourceModels.cs b/src/ProcessorResourceModels.cs new file mode 100644 index 0000000..73a39cd --- /dev/null +++ b/src/ProcessorResourceModels.cs @@ -0,0 +1,349 @@ +namespace Icod.Host; + +/// +/// Identifies a host or process capability exposed by the processor provider. +/// +public enum HostResourceCapabilityKind { + /// Native or stable host identifier retrieval. + HostIdentifier = 0, + /// Configured processor count. + ConfiguredProcessorCount = 1, + /// Installed or present processor count. + InstalledProcessorCount = 2, + /// Online processor count. + OnlineProcessorCount = 3, + /// Processors available to the current process. + ProcessAvailableProcessorCount = 4, + /// Current-process affinity or processor-set inspection. + ProcessAffinity = 5, + /// Container, cgroup, or job-object quota inspection. + ProcessorQuota = 6, + /// Processor package and core topology. + ProcessorTopology = 7, + /// NUMA topology. + NumaTopology = 8 +} + +/// +/// Summarizes one capability and the provider state observed for it. +/// +public sealed record HostResourceCapability { + /// Initializes a capability report. + /// The capability kind. + /// The observed availability. + /// The observation source. + /// An optional explanation. + public HostResourceCapability( + HostResourceCapabilityKind kind, + HostResourceAvailability availability, + HostResourceProvenance provenance, + string? message = null + ) { + Kind = kind; + Availability = availability; + Provenance = provenance; + Message = message; + } + + /// Gets the capability kind. + public HostResourceCapabilityKind Kind { get; } + + /// Gets the capability availability. + public HostResourceAvailability Availability { get; } + + /// Gets the source of the capability observation. + public HostResourceProvenance Provenance { get; } + + /// Gets an optional explanation. + public string? Message { get; } +} + +/// +/// Identifies the namespace used by processor-selection identifiers. +/// +public enum ProcessorSelectionIdentifierKind { + /// The identifiers are zero-based logical-processor indices. + LogicalProcessorIndex = 0, + /// The identifiers are opaque Windows CPU-set identifiers. + WindowsCpuSetId = 1 +} + +/// +/// Describes the processors selected for the current process. +/// +public sealed record ProcessorAffinityDescriptor { + /// Initializes an affinity descriptor. + /// The selected processor identifiers. + /// Whether the list covers every processor group or equivalent host domain. + /// The namespace used by the identifiers. + /// is null. + /// No processor identifier was supplied. + /// A processor identifier is negative. + public ProcessorAffinityDescriptor( + IEnumerable processorIdentifiers, + bool isComplete, + ProcessorSelectionIdentifierKind identifierKind = ProcessorSelectionIdentifierKind.LogicalProcessorIndex + ) { + ArgumentNullException.ThrowIfNull( processorIdentifiers ); + var identifiers = processorIdentifiers.Distinct().Order().ToArray(); + if ( identifiers.Length == 0 ) { + throw new ArgumentException( + "An affinity descriptor requires at least one processor identifier.", + nameof( processorIdentifiers ) + ); + } + if ( identifiers[0] < 0 ) { + throw new ArgumentOutOfRangeException( + nameof( processorIdentifiers ), + "Processor identifiers cannot be negative." + ); + } + ProcessorIdentifiers = Array.AsReadOnly( identifiers ); + IsComplete = isComplete; + IdentifierKind = identifierKind; + } + + /// Gets the selected processor identifiers. + public IReadOnlyList ProcessorIdentifiers { get; } + + /// Gets the namespace used by the processor identifiers. + public ProcessorSelectionIdentifierKind IdentifierKind { get; } + + /// Gets the number of selected processors. + public int Count => ProcessorIdentifiers.Count; + + /// Gets whether the list covers every processor group or equivalent host domain. + public bool IsComplete { get; } +} + +/// +/// Describes a hard processor-time quota as a fractional processor capacity. +/// +public sealed record ProcessorQuotaDescriptor { + /// Initializes a processor quota. + /// The fractional processor capacity made available. + /// The optional quota interval in microseconds. + /// The optional accounting period in microseconds. + /// A short description of the quota scope. + /// The processor limit, quota, or period is not positive. + /// + /// Only one of quota and period is present, or is empty. + /// + public ProcessorQuotaDescriptor( + double processorLimit, + long? quotaMicroseconds, + long? periodMicroseconds, + string scope + ) { + if ( !double.IsFinite( processorLimit ) || processorLimit <= 0 ) { + throw new ArgumentOutOfRangeException( nameof( processorLimit ) ); + } + if ( quotaMicroseconds.HasValue != periodMicroseconds.HasValue ) { + throw new ArgumentException( + "Quota and period values must either both be present or both be absent.", + nameof( quotaMicroseconds ) + ); + } + if ( + quotaMicroseconds.HasValue + && (quotaMicroseconds.Value <= 0 || periodMicroseconds.GetValueOrDefault() <= 0) + ) { + throw new ArgumentOutOfRangeException( + nameof( quotaMicroseconds ), + "Quota and period values must be positive." + ); + } + if ( string.IsNullOrWhiteSpace( scope ) ) { + throw new ArgumentException( "A processor quota requires a nonempty scope.", nameof( scope ) ); + } + ProcessorLimit = processorLimit; + QuotaMicroseconds = quotaMicroseconds; + PeriodMicroseconds = periodMicroseconds; + Scope = scope; + } + + /// Gets the fractional processor capacity. + public double ProcessorLimit { get; } + + /// Gets the optional quota interval in microseconds. + public long? QuotaMicroseconds { get; } + + /// Gets the optional accounting period in microseconds. + public long? PeriodMicroseconds { get; } + + /// Gets a short description of the quota scope. + public string Scope { get; } +} + +/// +/// Describes optional processor package, core, logical-processor, and NUMA facts. +/// +public sealed record ProcessorTopologyDescriptor { + /// Initializes a topology descriptor. + /// The processor-package count. + /// The physical-core count. + /// The logical-processor count. + /// The NUMA-node count. + public ProcessorTopologyDescriptor( + HostResourceValue packages, + HostResourceValue physicalCores, + HostResourceValue logicalProcessors, + HostResourceValue numaNodes + ) { + Packages = packages; + PhysicalCores = physicalCores; + LogicalProcessors = logicalProcessors; + NumaNodes = numaNodes; + } + + /// Gets the processor-package count. + public HostResourceValue Packages { get; } + + /// Gets the physical-core count. + public HostResourceValue PhysicalCores { get; } + + /// Gets the logical-processor count. + public HostResourceValue LogicalProcessors { get; } + + /// Gets the NUMA-node count. + public HostResourceValue NumaNodes { get; } +} + +/// +/// Collects processor-resource facts for the host and current process. +/// +public sealed record ProcessorResourceSnapshot { + /// Initializes a processor-resource snapshot. + /// The configured processor count. + /// The installed or present processor count. + /// The online processor count. + /// The runtime-observed process-available count. + /// The current-process affinity or processor-set observation. + /// The current container, cgroup, or job-object quota. + /// The optional package, core, logical, and NUMA topology. + public ProcessorResourceSnapshot( + HostResourceValue configuredProcessorCount, + HostResourceValue installedProcessorCount, + HostResourceValue onlineProcessorCount, + HostResourceValue processAvailableProcessorCount, + HostResourceValue affinity, + HostResourceValue quota, + HostResourceValue topology + ) { + ConfiguredProcessorCount = configuredProcessorCount; + InstalledProcessorCount = installedProcessorCount; + OnlineProcessorCount = onlineProcessorCount; + ProcessAvailableProcessorCount = processAvailableProcessorCount; + Affinity = affinity; + Quota = quota; + Topology = topology; + Capabilities = BuildCapabilities(); + } + + /// Gets the configured processor count. + public HostResourceValue ConfiguredProcessorCount { get; } + + /// Gets the installed or present processor count. + public HostResourceValue InstalledProcessorCount { get; } + + /// Gets the online processor count. + public HostResourceValue OnlineProcessorCount { get; } + + /// Gets the processors available to the current process according to the runtime. + public HostResourceValue ProcessAvailableProcessorCount { get; } + + /// Gets the current-process affinity or processor-set observation. + public HostResourceValue Affinity { get; } + + /// Gets the current container, cgroup, or job-object processor quota. + public HostResourceValue Quota { get; } + + /// Gets the optional processor topology. + public HostResourceValue Topology { get; } + + /// Gets a capability report derived from the individual observations. + public IReadOnlyList Capabilities { get; } + + private IReadOnlyList BuildCapabilities() { + var topologyAvailability = Topology.Availability; + var numaAvailability = topologyAvailability; + var numaProvenance = Topology.Provenance; + var numaMessage = Topology.Message; + if ( Topology.IsAvailable ) { + var descriptor = Topology.GetRequiredValue(); + numaAvailability = descriptor.NumaNodes.Availability; + numaProvenance = descriptor.NumaNodes.Provenance; + numaMessage = descriptor.NumaNodes.Message; + } + + return [ + CreateCapability( HostResourceCapabilityKind.ConfiguredProcessorCount, ConfiguredProcessorCount ), + CreateCapability( HostResourceCapabilityKind.InstalledProcessorCount, InstalledProcessorCount ), + CreateCapability( HostResourceCapabilityKind.OnlineProcessorCount, OnlineProcessorCount ), + CreateCapability( + HostResourceCapabilityKind.ProcessAvailableProcessorCount, + ProcessAvailableProcessorCount + ), + CreateCapability( HostResourceCapabilityKind.ProcessAffinity, Affinity ), + CreateCapability( HostResourceCapabilityKind.ProcessorQuota, Quota ), + CreateCapability( HostResourceCapabilityKind.ProcessorTopology, Topology ), + new HostResourceCapability( + HostResourceCapabilityKind.NumaTopology, + numaAvailability, + numaProvenance, + numaMessage + ) + ]; + } + + private static HostResourceCapability CreateCapability( + HostResourceCapabilityKind kind, + HostResourceValue value + ) => new( + kind, + value.Availability, + value.Provenance, + value.Message + ); +} + +/// +/// Collects the host identifier and processor-resource observations made at one +/// point in time. +/// +public sealed record HostResourceSnapshot { + /// Initializes a combined host-resource snapshot. + /// The host-identifier observation. + /// The processor-resource observations. + /// The observation timestamp. + /// is null. + public HostResourceSnapshot( + HostResourceValue hostIdentifier, + ProcessorResourceSnapshot processors, + DateTimeOffset observedAtUtc + ) { + HostIdentifier = hostIdentifier; + Processors = processors ?? throw new ArgumentNullException( nameof( processors ) ); + ObservedAtUtc = observedAtUtc; + } + + /// Gets the host-identifier observation. + public HostResourceValue HostIdentifier { get; } + + /// Gets the processor-resource observations. + public ProcessorResourceSnapshot Processors { get; } + + /// Gets the UTC observation timestamp. + public DateTimeOffset ObservedAtUtc { get; } + + /// Gets the complete capability report, including host-identifier support. + public IReadOnlyList Capabilities => [ + new HostResourceCapability( + HostResourceCapabilityKind.HostIdentifier, + HostIdentifier.Availability, + HostIdentifier.Provenance, + HostIdentifier.Message + ), + .. Processors.Capabilities + ]; +} diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..7287e17 --- /dev/null +++ b/src/README.md @@ -0,0 +1,38 @@ +# Icod.Host source + +The `Icod.Host` source directory contains the neutral factual host-resource +layer extracted from `Icod.CommandFramework.Host`. + +## Responsibilities + +The production surface covers: + +- `HostIdentifier` and deterministic host-identifier normalization; +- `HostResourceValue`, availability, provenance, and capability reporting; +- `IHostIdentifierProvider`, `IProcessorResourceProvider`, and + `IHostResourceProvider`; +- processor count, affinity, quota, topology, and NUMA models; +- deterministic Linux CPU-list and cgroup quota parsers; and +- `SystemHostResourceProvider`, which selects narrow native/BCL observations for + Windows, Linux, macOS, and portable fallback hosts. + +The system provider reports facts only. Command policy belongs to consumers. + +## Availability and provenance + +Every optional fact uses `HostResourceValue` so consumers can distinguish an +available value from a temporarily unavailable value, a platform-unsupported +concept, or a concept that does not apply to the current process. + +`HostResourceProvenance` identifies where a value came from. Unsupported facts +must remain explicit rather than being replaced by zero or another unrelated +measurement. + +## Boundary + +`ObservationFidelity` is intentionally excluded. Availability/provenance are +properties of factual observations; semantic fidelity belongs to consumers that +map those observations into suite-specific cross-platform models. + +Likewise, process control, ProcPs-specific observations, GNU command policy, and +command-hosting infrastructure do not belong in this library. diff --git a/src/SystemHostResourceProvider.cs b/src/SystemHostResourceProvider.cs new file mode 100644 index 0000000..0ebd541 --- /dev/null +++ b/src/SystemHostResourceProvider.cs @@ -0,0 +1,1122 @@ +namespace Icod.Host; + +using Microsoft.Win32; +using System.Globalization; +using System.Net; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +/// +/// Reads host identifiers and processor-resource facts from managed and narrow +/// native operating-system providers. +/// +public sealed class SystemHostResourceProvider : IHostResourceProvider { + private const int LinuxConfiguredProcessors = 83; + private const int LinuxOnlineProcessors = 84; + private const ushort AllProcessorGroups = ushort.MaxValue; + private const uint JobObjectCpuRateControlEnable = 0x1; + private const uint JobObjectCpuRateControlWeightBased = 0x2; + private const uint JobObjectCpuRateControlHardCap = 0x4; + private const uint JobObjectCpuRateControlMinMaxRate = 0x10; + private const int JobObjectCpuRateControlInformation = 15; + + /// Gets the process-wide system provider. + public static SystemHostResourceProvider Instance { get; } = new(); + + private SystemHostResourceProvider() { } + + /// + public ValueTask> GetHostIdentifierAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult( ObserveHostIdentifier() ); + } + + /// + public ValueTask GetProcessorResourcesAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult( ObserveProcessorResources() ); + } + + /// + public ValueTask ObserveAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + var hostIdentifier = ObserveHostIdentifier(); + cancellationToken.ThrowIfCancellationRequested(); + var processors = ObserveProcessorResources(); + return ValueTask.FromResult( + new HostResourceSnapshot( + hostIdentifier, + processors, + DateTimeOffset.UtcNow + ) + ); + } + + private static HostResourceValue ObserveHostIdentifier() { + if ( OperatingSystem.IsWindows() ) { + var registryIdentifier = TryReadWindowsMachineGuid(); + if ( registryIdentifier.IsAvailable ) { + return registryIdentifier; + } + } else { + try { + var nativeValue = NativeMethods.GetHostId(); + return HostResourceValue.Available( + new HostIdentifier( + HostIdentifierNormalizer.NormalizeNative( nativeValue.ToInt64() ), + "native gethostid" + ), + HostResourceProvenance.NativeOperatingSystem + ); + } catch ( DllNotFoundException ) { + // Continue to stable textual identifiers. + } catch ( EntryPointNotFoundException ) { + // Continue to stable textual identifiers. + } catch ( PlatformNotSupportedException ) { + // Continue to stable textual identifiers. + } + } + + foreach ( var path in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" } ) { + if ( TryReadNonEmptyText( path, out var machineIdentifier ) ) { + return HostResourceValue.Available( + new HostIdentifier( + HostIdentifierNormalizer.NormalizeStableText( machineIdentifier ), + "stable machine identifier" + ), + HostResourceProvenance.Derived, + "A stable textual machine identifier was deterministically folded to 32 bits." + ); + } + } + + try { + var hostName = Dns.GetHostName(); + if ( hostName.Length > 0 ) { + return HostResourceValue.Available( + new HostIdentifier( + HostIdentifierNormalizer.NormalizeStableText( hostName ), + "host name fallback" + ), + HostResourceProvenance.Derived, + "No native or stable machine identifier was available; the normalized host name was used." + ); + } + } catch ( Exception ex ) { + return HostResourceValue.Unavailable( + ex.Message, + HostResourceProvenance.Derived + ); + } + return HostResourceValue.Unavailable( + "The host did not expose a native, stable-machine, or host-name identifier." + ); + } + + [SupportedOSPlatform( "windows" )] + private static HostResourceValue TryReadWindowsMachineGuid() { + try { + using var key = Registry.LocalMachine.OpenSubKey( @"SOFTWARE\Microsoft\Cryptography", writable: false ); + var value = key?.GetValue( "MachineGuid" ) as string; + if ( string.IsNullOrWhiteSpace( value ) ) { + return HostResourceValue.Unavailable( + "The Windows MachineGuid registry value is unavailable.", + HostResourceProvenance.WindowsRegistry + ); + } + return HostResourceValue.Available( + new HostIdentifier( + HostIdentifierNormalizer.NormalizeStableText( value ), + "Windows MachineGuid" + ), + HostResourceProvenance.WindowsRegistry, + "The stable MachineGuid was deterministically folded to 32 bits." + ); + } catch ( Exception ex ) { + return HostResourceValue.Unavailable( + ex.Message, + HostResourceProvenance.WindowsRegistry + ); + } + } + + private static ProcessorResourceSnapshot ObserveProcessorResources() { + var processAvailable = HostResourceValue.Available( + Math.Max( 1, Environment.ProcessorCount ), + HostResourceProvenance.ManagedRuntime, + "The managed runtime applies host affinity and container restrictions where supported." + ); + + if ( OperatingSystem.IsLinux() ) { + return ObserveLinuxProcessors( processAvailable ); + } + if ( OperatingSystem.IsWindows() ) { + return ObserveWindowsProcessors( processAvailable ); + } + if ( OperatingSystem.IsMacOS() ) { + return ObserveMacOsProcessors( processAvailable ); + } + return ObservePortableProcessors( processAvailable ); + } + + private static ProcessorResourceSnapshot ObserveLinuxProcessors( + HostResourceValue processAvailable + ) { + var configured = TryReadLinuxSysconfCount( + LinuxConfiguredProcessors, + "configured processor count" + ); + var installed = TryReadLinuxProcessorListCount( + "/sys/devices/system/cpu/present", + "installed processor count" + ); + if ( !installed.IsAvailable && configured.IsAvailable ) { + installed = HostResourceValue.Available( + configured.GetRequiredValue(), + configured.Provenance, + "The configured processor count was used because sysfs did not expose the present set." + ); + } + var online = TryReadLinuxProcessorListCount( + "/sys/devices/system/cpu/online", + "online processor count" + ); + if ( !online.IsAvailable ) { + online = TryReadLinuxSysconfCount( + LinuxOnlineProcessors, + "online processor count" + ); + } + var affinity = TryReadLinuxAffinity(); + var quota = TryReadLinuxControlGroupQuota(); + var topology = TryReadLinuxTopology( installed, online ); + return new ProcessorResourceSnapshot( + configured, + installed, + online, + processAvailable, + affinity, + quota, + topology + ); + } + + private static ProcessorResourceSnapshot ObserveWindowsProcessors( + HostResourceValue processAvailable + ) { + HostResourceValue configured; + HostResourceValue installed; + HostResourceValue online; + try { + var maximum = NativeMethods.GetMaximumProcessorCount( AllProcessorGroups ); + var active = NativeMethods.GetActiveProcessorCount( AllProcessorGroups ); + configured = maximum > 0 + ? HostResourceValue.Available( + checked((int)maximum), + HostResourceProvenance.WindowsProcessorGroup + ) + : HostResourceValue.Unavailable( + "GetMaximumProcessorCount returned zero.", + HostResourceProvenance.WindowsProcessorGroup + ); + installed = active > 0 + ? HostResourceValue.Available( + checked((int)active), + HostResourceProvenance.WindowsProcessorGroup + ) + : HostResourceValue.Unavailable( + "GetActiveProcessorCount returned zero.", + HostResourceProvenance.WindowsProcessorGroup + ); + online = installed; + } catch ( Exception ex ) when ( + ex is DllNotFoundException + or EntryPointNotFoundException + or PlatformNotSupportedException + ) { + configured = HostResourceValue.Unavailable( ex.Message, HostResourceProvenance.WindowsProcessorGroup ); + installed = configured; + online = configured; + } + var affinity = TryReadWindowsAffinity(); + var quota = TryReadWindowsJobQuota( online ); + var topology = TryReadWindowsTopology( online ); + return new ProcessorResourceSnapshot( + configured, + installed, + online, + processAvailable, + affinity, + quota, + topology + ); + } + + private static ProcessorResourceSnapshot ObserveMacOsProcessors( + HostResourceValue processAvailable + ) { + var configured = TryReadMacOsSysctlInt( "hw.logicalcpu_max" ); + if ( !configured.IsAvailable ) { + configured = TryReadMacOsSysctlInt( "hw.ncpu" ); + } + var installed = configured; + var online = TryReadMacOsSysctlInt( "hw.logicalcpu" ); + if ( !online.IsAvailable ) { + online = TryReadMacOsSysctlInt( "hw.ncpu" ); + } + var affinity = HostResourceValue.Unsupported( + "macOS does not expose a stable process-affinity mask through the supported provider boundary.", + HostResourceProvenance.NativeOperatingSystem + ); + var quota = HostResourceValue.Unsupported( + "The macOS provider does not expose a container or job-object CPU quota.", + HostResourceProvenance.NativeOperatingSystem + ); + var topology = TryReadMacOsTopology( online ); + return new ProcessorResourceSnapshot( + configured, + installed, + online, + processAvailable, + affinity, + quota, + topology + ); + } + + private static ProcessorResourceSnapshot ObservePortableProcessors( + HostResourceValue processAvailable + ) { + var hostCount = HostResourceValue.Unsupported( + "This platform adapter exposes only the process-available processor count.", + HostResourceProvenance.ManagedRuntime + ); + var affinity = HostResourceValue.Unsupported( + "Process affinity is not implemented for this platform." + ); + var quota = HostResourceValue.Unsupported( + "Processor quota inspection is not implemented for this platform." + ); + var topology = HostResourceValue.Available( + new ProcessorTopologyDescriptor( + HostResourceValue.Unsupported( "Processor packages are unavailable." ), + HostResourceValue.Unsupported( "Physical cores are unavailable." ), + processAvailable, + HostResourceValue.Unsupported( "NUMA topology is unavailable." ) + ), + HostResourceProvenance.ManagedRuntime, + "Only the process-available logical processor count is available." + ); + return new ProcessorResourceSnapshot( + hostCount, + hostCount, + hostCount, + processAvailable, + affinity, + quota, + topology + ); + } + + private static HostResourceValue TryReadLinuxSysconfCount( + int name, + string description + ) { + try { + var value = NativeMethods.Sysconf( name ).ToInt64(); + if ( value <= 0 || value > int.MaxValue ) { + return HostResourceValue.Unavailable( + string.Concat( "sysconf did not return a valid ", description, "." ), + HostResourceProvenance.NativeOperatingSystem + ); + } + return HostResourceValue.Available( + checked((int)value), + HostResourceProvenance.NativeOperatingSystem + ); + } catch ( Exception ex ) when ( + ex is DllNotFoundException + or EntryPointNotFoundException + or PlatformNotSupportedException + ) { + return HostResourceValue.Unavailable( + ex.Message, + HostResourceProvenance.NativeOperatingSystem + ); + } + } + + private static HostResourceValue TryReadLinuxProcessorListCount( + string path, + string description + ) { + try { + if ( !File.Exists( path ) ) { + return HostResourceValue.Unavailable( + string.Concat( "sysfs does not expose the ", description, "." ), + HostResourceProvenance.LinuxSysFileSystem + ); + } + var processors = HostResourceParsers.ParseProcessorList( File.ReadAllText( path ) ); + return HostResourceValue.Available( + processors.Count, + HostResourceProvenance.LinuxSysFileSystem + ); + } catch ( Exception ex ) { + return HostResourceValue.Unavailable( + ex.Message, + HostResourceProvenance.LinuxSysFileSystem + ); + } + } + + private static HostResourceValue TryReadLinuxAffinity() { + try { + const int invalidArgument = 22; + for ( var byteCount = 128; byteCount <= 131072; byteCount *= 2 ) { + var mask = new byte[byteCount]; + if ( 0 == NativeMethods.SchedGetAffinity( 0, checked((nuint)mask.Length), mask ) ) { + var ids = HostResourceParsers.GetSetBitIndices( mask ); + if ( ids.Count == 0 ) { + return HostResourceValue.Unavailable( + "sched_getaffinity returned an empty mask.", + HostResourceProvenance.NativeOperatingSystem + ); + } + return HostResourceValue.Available( + new ProcessorAffinityDescriptor( ids.Select( static value => (long)value ), isComplete: true ), + HostResourceProvenance.NativeOperatingSystem, + "The effective kernel mask includes scheduler affinity and cgroup cpuset restrictions." + ); + } + + var error = Marshal.GetLastPInvokeError(); + if ( error != invalidArgument ) { + return HostResourceValue.Unavailable( + string.Concat( + "sched_getaffinity failed with native error ", + error.ToString( CultureInfo.InvariantCulture ), + "." + ), + HostResourceProvenance.NativeOperatingSystem + ); + } + } + return HostResourceValue.Unavailable( + "The scheduler affinity mask exceeded the provider's 131,072-byte safety limit.", + HostResourceProvenance.NativeOperatingSystem + ); + } catch ( Exception ex ) when ( + ex is DllNotFoundException + or EntryPointNotFoundException + or PlatformNotSupportedException + ) { + return HostResourceValue.Unsupported( + ex.Message, + HostResourceProvenance.NativeOperatingSystem + ); + } + } + + private static HostResourceValue TryReadWindowsAffinity() { + try { + var process = NativeMethods.GetCurrentProcess(); + try { + _ = NativeMethods.GetProcessDefaultCpuSets( + process, + null, + 0, + out var requiredCount + ); + if ( requiredCount > 0 ) { + var cpuSetIds = new uint[checked((int)requiredCount)]; + if ( + NativeMethods.GetProcessDefaultCpuSets( + process, + cpuSetIds, + checked((uint)cpuSetIds.Length), + out requiredCount + ) + && requiredCount > 0 + ) { + return HostResourceValue.Available( + new ProcessorAffinityDescriptor( + cpuSetIds + .Take( checked((int)Math.Min( requiredCount, checked((uint)cpuSetIds.Length) )) ) + .Select( static value => (long)value ), + isComplete: true, + identifierKind: ProcessorSelectionIdentifierKind.WindowsCpuSetId + ), + HostResourceProvenance.WindowsProcessorSet + ); + } + } + } catch ( EntryPointNotFoundException ) { + // Older Windows hosts fall back to the process affinity mask. + } + + if ( !NativeMethods.GetProcessAffinityMask( process, out var processMask, out _ ) ) { + return HostResourceValue.Unavailable( + string.Concat( + "GetProcessAffinityMask failed with native error ", + Marshal.GetLastPInvokeError().ToString( CultureInfo.InvariantCulture ), + "." + ), + HostResourceProvenance.WindowsProcessorGroup + ); + } + var rawMask = processMask.ToUInt64(); + var bytes = BitConverter.GetBytes( rawMask ); + var ids = HostResourceParsers.GetSetBitIndices( bytes ); + if ( ids.Count == 0 ) { + return HostResourceValue.Unavailable( + "GetProcessAffinityMask returned an empty process mask.", + HostResourceProvenance.WindowsProcessorGroup + ); + } + var groupCount = NativeMethods.GetActiveProcessorGroupCount(); + return HostResourceValue.Available( + new ProcessorAffinityDescriptor( + ids.Select( static value => (long)value ), + isComplete: groupCount <= 1 + ), + HostResourceProvenance.WindowsProcessorGroup, + groupCount <= 1 + ? null + : "The legacy affinity mask describes only the current processor group." + ); + } catch ( Exception ex ) when ( + ex is DllNotFoundException + or EntryPointNotFoundException + or PlatformNotSupportedException + ) { + return HostResourceValue.Unsupported( + ex.Message, + HostResourceProvenance.WindowsProcessorGroup + ); + } + } + + private static HostResourceValue TryReadLinuxControlGroupQuota() { + try { + if ( !TryReadNonEmptyText( "/proc/self/cgroup", out var membership ) ) { + return HostResourceValue.Unavailable( + "The process cgroup membership file is unavailable.", + HostResourceProvenance.LinuxProcFileSystem + ); + } + foreach ( var line in membership.Split( '\n', StringSplitOptions.RemoveEmptyEntries ) ) { + var fields = line.Trim().Split( ':', 3 ); + if ( fields.Length != 3 ) { + continue; + } + if ( fields[0] == "0" && fields[1].Length == 0 ) { + var path = BuildControlGroupPath( "/sys/fs/cgroup", fields[2], "cpu.max" ); + if ( TryReadNonEmptyText( path, out var cpuMax ) ) { + return HostResourceParsers.ParseControlGroupV2CpuMax( cpuMax ); + } + } + var controllers = fields[1].Split( ',' ); + if ( controllers.Contains( "cpu", StringComparer.Ordinal ) ) { + foreach ( var root in new[] { "/sys/fs/cgroup/cpu", "/sys/fs/cgroup/cpu,cpuacct" } ) { + var quotaPath = BuildControlGroupPath( root, fields[2], "cpu.cfs_quota_us" ); + var periodPath = BuildControlGroupPath( root, fields[2], "cpu.cfs_period_us" ); + if ( + TryReadNonEmptyText( quotaPath, out var quota ) + && TryReadNonEmptyText( periodPath, out var period ) + ) { + return HostResourceParsers.ParseControlGroupV1CpuQuota( quota, period ); + } + } + } + } + return HostResourceValue.NotApplicable( + "No cgroup CPU hard quota applies to the current process.", + HostResourceProvenance.LinuxProcFileSystem + ); + } catch ( Exception ex ) { + return HostResourceValue.Unavailable( + ex.Message, + HostResourceProvenance.LinuxProcFileSystem + ); + } + } + + private static HostResourceValue TryReadWindowsJobQuota( + HostResourceValue online + ) { + try { + var process = NativeMethods.GetCurrentProcess(); + if ( !NativeMethods.IsProcessInJob( process, IntPtr.Zero, out var inJob ) ) { + return HostResourceValue.Unavailable( + string.Concat( + "IsProcessInJob failed with native error ", + Marshal.GetLastPInvokeError().ToString( CultureInfo.InvariantCulture ), + "." + ), + HostResourceProvenance.WindowsJobObject + ); + } + if ( !inJob ) { + return HostResourceValue.NotApplicable( + "The current process is not assigned to a Windows job object.", + HostResourceProvenance.WindowsJobObject + ); + } + if ( + !NativeMethods.QueryInformationJobObject( + IntPtr.Zero, + JobObjectCpuRateControlInformation, + out var information, + checked((uint)Marshal.SizeOf()), + IntPtr.Zero + ) + ) { + return HostResourceValue.Unavailable( + string.Concat( + "QueryInformationJobObject failed with native error ", + Marshal.GetLastPInvokeError().ToString( CultureInfo.InvariantCulture ), + "." + ), + HostResourceProvenance.WindowsJobObject + ); + } + if ( 0 == (information.ControlFlags & JobObjectCpuRateControlEnable) ) { + return HostResourceValue.NotApplicable( + "The current job object does not enable CPU rate control.", + HostResourceProvenance.WindowsJobObject + ); + } + if ( 0 != (information.ControlFlags & JobObjectCpuRateControlWeightBased) ) { + return HostResourceValue.Unavailable( + "The current job object uses relative CPU weights rather than a hard processor quota.", + HostResourceProvenance.WindowsJobObject + ); + } + var onlineCount = online.IsAvailable + ? online.GetRequiredValue() + : Math.Max( 1, Environment.ProcessorCount ); + double rate; + string scope; + if ( 0 != (information.ControlFlags & JobObjectCpuRateControlHardCap) ) { + rate = information.CpuRate; + scope = "Windows job object hard cap"; + } else if ( 0 != (information.ControlFlags & JobObjectCpuRateControlMinMaxRate) ) { + rate = information.CpuRate >> 16; + scope = "Windows job object maximum rate"; + } else { + return HostResourceValue.NotApplicable( + "The current job object does not impose a hard or maximum CPU-rate cap.", + HostResourceProvenance.WindowsJobObject + ); + } + if ( rate <= 0 ) { + return HostResourceValue.Unavailable( + "The current job object reported an invalid zero CPU-rate cap.", + HostResourceProvenance.WindowsJobObject + ); + } + var processorLimit = onlineCount * (rate / 10000d); + return HostResourceValue.Available( + new ProcessorQuotaDescriptor( + processorLimit, + null, + null, + scope + ), + HostResourceProvenance.WindowsJobObject, + "The job CPU rate was converted from ten-thousandths of total active processor capacity." + ); + } catch ( Exception ex ) when ( + ex is DllNotFoundException + or EntryPointNotFoundException + or PlatformNotSupportedException + ) { + return HostResourceValue.Unsupported( + ex.Message, + HostResourceProvenance.WindowsJobObject + ); + } + } + + private static HostResourceValue TryReadLinuxTopology( + HostResourceValue installed, + HostResourceValue online + ) { + try { + var packages = new HashSet(); + var cores = new HashSet<(int Package, int Core)>(); + var logicalCount = 0; + const string cpuRoot = "/sys/devices/system/cpu"; + if ( Directory.Exists( cpuRoot ) ) { + foreach ( var directory in Directory.EnumerateDirectories( cpuRoot, "cpu*" ) ) { + var name = System.IO.Path.GetFileName( directory ); + if ( + name.Length <= 3 + || !int.TryParse( + name[3..], + NumberStyles.None, + CultureInfo.InvariantCulture, + out _ + ) + ) { + continue; + } + logicalCount++; + if ( + TryReadInt32( + System.IO.Path.Combine( directory, "topology", "physical_package_id" ), + out var package + ) + && TryReadInt32( System.IO.Path.Combine( directory, "topology", "core_id" ), out var core ) + ) { + packages.Add( package ); + cores.Add( (package, core) ); + } + } + } + var numaCount = CountNumberedDirectories( "/sys/devices/system/node", "node" ); + var logical = logicalCount > 0 + ? HostResourceValue.Available( logicalCount, HostResourceProvenance.LinuxSysFileSystem ) + : installed.IsAvailable ? installed : online; + var descriptor = new ProcessorTopologyDescriptor( + packages.Count > 0 + ? HostResourceValue.Available( packages.Count, HostResourceProvenance.LinuxSysFileSystem ) + : HostResourceValue.Unavailable( + "sysfs did not expose processor package identifiers.", + HostResourceProvenance.LinuxSysFileSystem + ), + cores.Count > 0 + ? HostResourceValue.Available( cores.Count, HostResourceProvenance.LinuxSysFileSystem ) + : HostResourceValue.Unavailable( + "sysfs did not expose physical core identifiers.", + HostResourceProvenance.LinuxSysFileSystem + ), + logical, + numaCount > 0 + ? HostResourceValue.Available( numaCount, HostResourceProvenance.LinuxSysFileSystem ) + : HostResourceValue.Unavailable( + "sysfs did not expose NUMA node directories.", + HostResourceProvenance.LinuxSysFileSystem + ) + ); + return HostResourceValue.Available( + descriptor, + HostResourceProvenance.LinuxSysFileSystem + ); + } catch ( Exception ex ) { + return HostResourceValue.Unavailable( + ex.Message, + HostResourceProvenance.LinuxSysFileSystem + ); + } + } + + private static HostResourceValue TryReadWindowsTopology( + HostResourceValue online + ) { + try { + uint length = 0; + _ = NativeMethods.GetLogicalProcessorInformationEx( + NativeMethods.LogicalProcessorRelationship.All, + IntPtr.Zero, + ref length + ); + if ( length == 0 ) { + return HostResourceValue.Unavailable( + "GetLogicalProcessorInformationEx did not report a buffer size.", + HostResourceProvenance.NativeOperatingSystem + ); + } + var buffer = Marshal.AllocHGlobal( checked((int)length) ); + try { + if ( + !NativeMethods.GetLogicalProcessorInformationEx( + NativeMethods.LogicalProcessorRelationship.All, + buffer, + ref length + ) + ) { + return HostResourceValue.Unavailable( + string.Concat( + "GetLogicalProcessorInformationEx failed with native error ", + Marshal.GetLastPInvokeError().ToString( CultureInfo.InvariantCulture ), + "." + ), + HostResourceProvenance.NativeOperatingSystem + ); + } + var packages = 0; + var cores = 0; + var numaNodes = 0; + uint offset = 0; + while ( offset < length ) { + var relationship = (NativeMethods.LogicalProcessorRelationship)Marshal.ReadInt32( + buffer, + checked((int)offset) + ); + var itemLength = Marshal.ReadInt32( buffer, checked((int)offset + sizeof( int )) ); + if ( itemLength < 8 || offset + checked((uint)itemLength) > length ) { + return HostResourceValue.Unavailable( + "GetLogicalProcessorInformationEx returned a malformed record.", + HostResourceProvenance.NativeOperatingSystem + ); + } + switch ( relationship ) { + case NativeMethods.LogicalProcessorRelationship.ProcessorCore: + cores++; + break; + case NativeMethods.LogicalProcessorRelationship.NumaNode: + case NativeMethods.LogicalProcessorRelationship.NumaNodeEx: + numaNodes++; + break; + case NativeMethods.LogicalProcessorRelationship.ProcessorPackage: + packages++; + break; + } + offset += checked((uint)itemLength); + } + var descriptor = new ProcessorTopologyDescriptor( + packages > 0 + ? HostResourceValue.Available( packages, HostResourceProvenance.NativeOperatingSystem ) + : HostResourceValue.Unavailable( + "Processor packages were not reported.", + HostResourceProvenance.NativeOperatingSystem + ), + cores > 0 + ? HostResourceValue.Available( cores, HostResourceProvenance.NativeOperatingSystem ) + : HostResourceValue.Unavailable( + "Physical cores were not reported.", + HostResourceProvenance.NativeOperatingSystem + ), + online, + numaNodes > 0 + ? HostResourceValue.Available( numaNodes, HostResourceProvenance.NativeOperatingSystem ) + : HostResourceValue.Unavailable( + "NUMA nodes were not reported.", + HostResourceProvenance.NativeOperatingSystem + ) + ); + return HostResourceValue.Available( + descriptor, + HostResourceProvenance.NativeOperatingSystem + ); + } finally { + Marshal.FreeHGlobal( buffer ); + } + } catch ( Exception ex ) when ( + ex is DllNotFoundException + or EntryPointNotFoundException + or PlatformNotSupportedException + ) { + return HostResourceValue.Unsupported( + ex.Message, + HostResourceProvenance.NativeOperatingSystem + ); + } + } + + private static HostResourceValue TryReadMacOsTopology( + HostResourceValue online + ) { + var packages = TryReadMacOsSysctlInt( "hw.packages" ); + var cores = TryReadMacOsSysctlInt( "hw.physicalcpu" ); + var logical = TryReadMacOsSysctlInt( "hw.logicalcpu" ); + if ( !logical.IsAvailable ) { + logical = online; + } + var descriptor = new ProcessorTopologyDescriptor( + packages, + cores, + logical, + HostResourceValue.Unsupported( + "macOS does not expose a stable NUMA-node inventory through the supported provider boundary.", + HostResourceProvenance.MacOsSysctl + ) + ); + return HostResourceValue.Available( + descriptor, + HostResourceProvenance.MacOsSysctl + ); + } + + private static HostResourceValue TryReadMacOsSysctlInt( string name ) { + try { + var value = 0; + nuint length = checked((nuint)sizeof( int )); + if ( 0 != NativeMethods.SysctlByName( name, ref value, ref length, IntPtr.Zero, 0 ) || value <= 0 ) { + return HostResourceValue.Unavailable( + string.Concat( "The macOS sysctl ", name, " is unavailable." ), + HostResourceProvenance.MacOsSysctl + ); + } + return HostResourceValue.Available( + value, + HostResourceProvenance.MacOsSysctl + ); + } catch ( Exception ex ) when ( + ex is DllNotFoundException + or EntryPointNotFoundException + or PlatformNotSupportedException + ) { + return HostResourceValue.Unavailable( + ex.Message, + HostResourceProvenance.MacOsSysctl + ); + } + } + + private static string BuildControlGroupPath( + string root, + string membershipPath, + string fileName + ) { + var rootPath = System.IO.Path.GetFullPath( root ); + var relative = membershipPath.Trim().TrimStart( '/', '\\' ); + var candidate = System.IO.Path.GetFullPath( System.IO.Path.Combine( rootPath, relative, fileName ) ); + var rootPrefix = string.Concat( + rootPath.TrimEnd( System.IO.Path.DirectorySeparatorChar ), + System.IO.Path.DirectorySeparatorChar + ); + if ( !candidate.StartsWith( rootPrefix, StringComparison.Ordinal ) ) { + throw new InvalidDataException( "The cgroup membership path escapes its controller root." ); + } + return candidate; + } + + private static int CountNumberedDirectories( + string root, + string prefix + ) { + if ( !Directory.Exists( root ) ) { + return 0; + } + var count = 0; + foreach ( var directory in Directory.EnumerateDirectories( root, string.Concat( prefix, "*" ) ) ) { + var name = System.IO.Path.GetFileName( directory ); + if ( + name.Length > prefix.Length + && int.TryParse( name[prefix.Length..], NumberStyles.None, CultureInfo.InvariantCulture, out _ ) + ) { + count++; + } + } + return count; + } + + private static bool TryReadInt32( + string path, + out int value + ) { + value = 0; + return TryReadNonEmptyText( path, out var text ) + && int.TryParse( text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value ); + } + + private static bool TryReadNonEmptyText( + string path, + out string value + ) { + value = string.Empty; + try { + if ( !File.Exists( path ) ) { + return false; + } + value = File.ReadAllText( path ).Trim(); + return value.Length > 0; + } catch { + return false; + } + } + +#pragma warning disable CS0649 // Native output structures are populated by P/Invoke. +// Keep native host-resource adapters self-contained and compatible with the existing project settings. +#pragma warning disable SYSLIB1054 + private static class NativeMethods { + /// Gets the native Unix host identifier. + /// The native host identifier. + [DllImport( "libc", EntryPoint = "gethostid", SetLastError = false )] + internal static extern nint GetHostId(); + + /// Reads one native Unix system-configuration value. + /// The platform configuration selector. + /// The configuration value, or a negative result on failure. + [DllImport( "libc", EntryPoint = "sysconf", SetLastError = true )] + internal static extern nint Sysconf( int name ); + + /// Reads the Linux scheduler affinity mask for a process. + /// The process identifier, or zero for the current process. + /// The mask-buffer size. + /// The destination mask. + /// Zero on success; otherwise, a native error result. + [DllImport( "libc", EntryPoint = "sched_getaffinity", SetLastError = true )] + internal static extern int SchedGetAffinity( + int processId, + nuint cpuSetSize, + [Out] byte[] mask + ); + + /// Reads one integer-valued macOS sysctl. + /// The sysctl name. + /// The destination value. + /// The destination size. + /// The unused replacement pointer. + /// The unused replacement size. + /// Zero on success; otherwise, a native error result. + [DllImport( "libSystem.B.dylib", EntryPoint = "sysctlbyname", SetLastError = true )] + internal static extern int SysctlByName( + [MarshalAs( UnmanagedType.LPUTF8Str )] string name, + ref int oldValue, + ref nuint oldLength, + IntPtr newValue, + nuint newLength + ); + + /// Gets the pseudo-handle for the current Windows process. + /// The process pseudo-handle. + [DllImport( "kernel32.dll", EntryPoint = "GetCurrentProcess", ExactSpelling = true )] + internal static extern IntPtr GetCurrentProcess(); + + /// Gets the maximum processor count for a Windows processor group or all groups. + /// The group number or the all-groups sentinel. + /// The maximum processor count. + [DllImport( "kernel32.dll", EntryPoint = "GetMaximumProcessorCount", ExactSpelling = true )] + internal static extern uint GetMaximumProcessorCount( ushort groupNumber ); + + /// Gets the active processor count for a Windows processor group or all groups. + /// The group number or the all-groups sentinel. + /// The active processor count. + [DllImport( "kernel32.dll", EntryPoint = "GetActiveProcessorCount", ExactSpelling = true )] + internal static extern uint GetActiveProcessorCount( ushort groupNumber ); + + /// Gets the number of active Windows processor groups. + /// The active processor-group count. + [DllImport( "kernel32.dll", EntryPoint = "GetActiveProcessorGroupCount", ExactSpelling = true )] + internal static extern ushort GetActiveProcessorGroupCount(); + + /// Reads the current process and system affinity masks. + /// The process handle. + /// The process mask. + /// The system mask for the current group. + /// on success. + [DllImport( "kernel32.dll", EntryPoint = "GetProcessAffinityMask", ExactSpelling = true, SetLastError = true )] + [return: MarshalAs( UnmanagedType.Bool )] + internal static extern bool GetProcessAffinityMask( + IntPtr process, + out UIntPtr processAffinityMask, + out UIntPtr systemAffinityMask + ); + + /// Reads the current process default Windows CPU-set identifiers. + /// The process handle. + /// The destination CPU-set identifiers. + /// The destination capacity. + /// The required or returned identifier count. + /// on success. + [DllImport( + "kernel32.dll", + EntryPoint = "GetProcessDefaultCpuSets", + ExactSpelling = true, + SetLastError = true + )] + [return: MarshalAs( UnmanagedType.Bool )] + internal static extern bool GetProcessDefaultCpuSets( + IntPtr process, + [Out] uint[]? cpuSetIds, + uint cpuSetIdCount, + out uint requiredIdCount + ); + + /// Determines whether a Windows process belongs to a job object. + /// The process handle. + /// The job handle or a null handle for any job. + /// Whether the process belongs to the requested job. + /// on success. + [DllImport( "kernel32.dll", EntryPoint = "IsProcessInJob", ExactSpelling = true, SetLastError = true )] + [return: MarshalAs( UnmanagedType.Bool )] + internal static extern bool IsProcessInJob( + IntPtr process, + IntPtr job, + [MarshalAs( UnmanagedType.Bool )] out bool result + ); + + /// Reads Windows job-object CPU-rate information. + /// The job handle or a null handle for the current job. + /// The job information class. + /// The returned CPU-rate information. + /// The destination structure size. + /// The optional returned-size pointer. + /// on success. + [DllImport( + "kernel32.dll", + EntryPoint = "QueryInformationJobObject", + ExactSpelling = true, + SetLastError = true + )] + [return: MarshalAs( UnmanagedType.Bool )] + internal static extern bool QueryInformationJobObject( + IntPtr job, + int informationClass, + out JobObjectCpuRateControlInformation information, + uint informationLength, + IntPtr returnLength + ); + + /// Reads group-aware Windows logical-processor topology records. + /// The requested relationship class. + /// The destination buffer or a null pointer for sizing. + /// The required or returned byte count. + /// on success. + [DllImport( + "kernel32.dll", + EntryPoint = "GetLogicalProcessorInformationEx", + ExactSpelling = true, + SetLastError = true + )] + [return: MarshalAs( UnmanagedType.Bool )] + internal static extern bool GetLogicalProcessorInformationEx( + LogicalProcessorRelationship relationship, + IntPtr buffer, + ref uint returnedLength + ); + + /// Matches the Windows job-object CPU-rate control structure. + [StructLayout( LayoutKind.Sequential )] + internal struct JobObjectCpuRateControlInformation { + /// Gets the native CPU-rate control flags. + internal uint ControlFlags; + /// Gets the native rate, weight, or packed minimum/maximum values. + internal uint CpuRate; + } + + /// Identifies Windows logical-processor topology relationships. + internal enum LogicalProcessorRelationship { + /// A physical processor core. + ProcessorCore = 0, + /// A legacy NUMA-node relationship. + NumaNode = 1, + /// A processor cache. + Cache = 2, + /// A physical processor package. + ProcessorPackage = 3, + /// A processor group. + Group = 4, + /// A processor die. + ProcessorDie = 5, + /// An extended NUMA-node relationship. + NumaNodeEx = 6, + /// A processor module. + ProcessorModule = 7, + /// All supported relationship types. + All = 0xffff + } + } +#pragma warning restore SYSLIB1054 +#pragma warning restore CS0649 +} diff --git a/tests/Host.Tests/Icod.Host.Tests.csproj b/tests/Host.Tests/Icod.Host.Tests.csproj new file mode 100644 index 0000000..95cc6bc --- /dev/null +++ b/tests/Host.Tests/Icod.Host.Tests.csproj @@ -0,0 +1,52 @@ + + + + net10.0 + 13.0 + enable + enable + false + true + true + Icod.Host.Tests + Icod.Host.Tests + Debug;Release;Staging + + + AnyCPU + + + 2 + true + full + false + DEBUG;TRACE + false + + + 3 + true + full + false + TRACE + false + + + 4 + portable + true + true + CS1591 + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/tests/Host.Tests/src/HostIdentifierTests.cs b/tests/Host.Tests/src/HostIdentifierTests.cs new file mode 100644 index 0000000..e897e27 --- /dev/null +++ b/tests/Host.Tests/src/HostIdentifierTests.cs @@ -0,0 +1,53 @@ +namespace Icod.Host.Tests; + +using Icod.Host; +using Xunit; + +/// Tests deterministic host-identifier normalization. +public sealed class HostIdentifierTests { + /// Verifies that signed native values retain their low 32 bits. + [Theory] + [InlineData( 0L, 0U )] + [InlineData( 1L, 1U )] + [InlineData( -1L, uint.MaxValue )] + [InlineData( 0x123456789L, 0x23456789U )] + public void NativeNormalizationUsesLowUnsignedBits( + long nativeValue, + uint expected + ) { + Assert.Equal( expected, HostIdentifierNormalizer.NormalizeNative( nativeValue ) ); + } + + /// Verifies equivalent hexadecimal textual forms normalize identically. + [Fact] + public void HexadecimalTextIgnoresCommonSeparators() { + var compact = HostIdentifierNormalizer.NormalizeStableText( "00112233445566778899aabbccddeeff" ); + var separated = HostIdentifierNormalizer.NormalizeStableText( "{00112233-4455-6677-8899-aabbccddeeff}" ); + + Assert.Equal( compact, separated ); + } + + /// Verifies stable hexadecimal input has a fixed cross-platform result. + [Fact] + public void HexadecimalTextUsesStableFnvResult() { + Assert.Equal( + 0xff138f15U, + HostIdentifierNormalizer.NormalizeStableText( "00112233445566778899aabbccddeeff" ) + ); + } + + /// Verifies nonhexadecimal text is case-insensitive and trimmed. + [Fact] + public void TextFallbackIsTrimmedAndCaseInsensitive() { + Assert.Equal( + HostIdentifierNormalizer.NormalizeStableText( "example-host" ), + HostIdentifierNormalizer.NormalizeStableText( " EXAMPLE-HOST " ) + ); + } + + /// Verifies GNU-style fixed-width hexadecimal formatting. + [Fact] + public void FormattingUsesEightLowercaseDigits() { + Assert.Equal( "0000002a", HostIdentifierNormalizer.Format( 42 ) ); + } +} diff --git a/tests/Host.Tests/src/HostResourceParserTests.cs b/tests/Host.Tests/src/HostResourceParserTests.cs new file mode 100644 index 0000000..6f64dab --- /dev/null +++ b/tests/Host.Tests/src/HostResourceParserTests.cs @@ -0,0 +1,89 @@ +namespace Icod.Host.Tests; + +using Icod.Host; +using Xunit; + +/// Tests deterministic processor-list, affinity, and quota parsers. +public sealed class HostResourceParserTests { + /// Verifies Linux CPU-list ranges are expanded and deduplicated. + [Fact] + public void ProcessorListExpandsRanges() { + Assert.Equal( + new[] { 0, 1, 2, 3, 8, 10, 11 }, + HostResourceParsers.ParseProcessorList( "0-3,8,10-11,3" ) + ); + } + + /// Verifies malformed descending ranges are rejected. + [Fact] + public void ProcessorListRejectsDescendingRange() { + Assert.Throws( + () => HostResourceParsers.ParseProcessorList( "4-2" ) + ); + } + + /// Verifies affinity mask bit counting and index extraction. + [Fact] + public void AffinityMaskReportsSelectedProcessors() { + var mask = new byte[] { 0b1000_0101, 0b0000_0010 }; + + Assert.Equal( 4, HostResourceParsers.CountSetBits( mask ) ); + Assert.Equal( new[] { 0, 2, 7, 9 }, HostResourceParsers.GetSetBitIndices( mask ) ); + } + + /// Verifies affinity descriptors retain identifier namespaces and normalize values. + [Fact] + public void AffinityDescriptorNormalizesIdentifiers() { + var descriptor = new ProcessorAffinityDescriptor( + new long[] { 9, 2, 9 }, + isComplete: true, + identifierKind: ProcessorSelectionIdentifierKind.WindowsCpuSetId + ); + + Assert.Equal( new long[] { 2, 9 }, descriptor.ProcessorIdentifiers ); + Assert.Equal( ProcessorSelectionIdentifierKind.WindowsCpuSetId, descriptor.IdentifierKind ); + Assert.Equal( 2, descriptor.Count ); + } + + /// Verifies empty affinity selections are rejected instead of reported as available. + [Fact] + public void AffinityDescriptorRejectsEmptySelection() { + Assert.Throws( + () => new ProcessorAffinityDescriptor( Array.Empty(), isComplete: true ) + ); + } + + /// Verifies quota intervals must be supplied as a complete pair. + [Fact] + public void QuotaDescriptorRejectsPartialInterval() { + Assert.Throws( + () => new ProcessorQuotaDescriptor( 1, 100000, null, "test" ) + ); + } + + /// Verifies cgroup v2 quota conversion preserves fractional capacity. + [Fact] + public void ControlGroupV2QuotaIsFractionalProcessorCapacity() { + var result = HostResourceParsers.ParseControlGroupV2CpuMax( "150000 100000" ); + + Assert.True( result.IsAvailable ); + Assert.Equal( 1.5, result.GetRequiredValue().ProcessorLimit, 8 ); + Assert.Equal( HostResourceProvenance.LinuxControlGroupV2, result.Provenance ); + } + + /// Verifies an unlimited cgroup v2 controller is not a zero quota. + [Fact] + public void ControlGroupV2UnlimitedIsNotApplicable() { + var result = HostResourceParsers.ParseControlGroupV2CpuMax( "max 100000" ); + + Assert.Equal( HostResourceAvailability.NotApplicable, result.Availability ); + } + + /// Verifies cgroup v1 negative quota denotes no hard limit. + [Fact] + public void ControlGroupV1NegativeQuotaIsNotApplicable() { + var result = HostResourceParsers.ParseControlGroupV1CpuQuota( "-1", "100000" ); + + Assert.Equal( HostResourceAvailability.NotApplicable, result.Availability ); + } +} diff --git a/tests/Host.Tests/src/HostResourceValueTests.cs b/tests/Host.Tests/src/HostResourceValueTests.cs new file mode 100644 index 0000000..4794453 --- /dev/null +++ b/tests/Host.Tests/src/HostResourceValueTests.cs @@ -0,0 +1,30 @@ +namespace Icod.Host.Tests; + +using Icod.Host; +using Xunit; + +/// Tests explicit host-resource availability and provenance. +public sealed class HostResourceValueTests { + /// Verifies that available values retain their source. + [Fact] + public void AvailableValueRetainsProvenance() { + var value = HostResourceValue.Available( + 8, + HostResourceProvenance.ManagedRuntime + ); + + Assert.True( value.IsAvailable ); + Assert.Equal( 8, value.GetRequiredValue() ); + Assert.Equal( HostResourceProvenance.ManagedRuntime, value.Provenance ); + } + + /// Verifies that unavailable values cannot masquerade as zero. + [Fact] + public void UnavailableValueRejectsRequiredAccess() { + var value = HostResourceValue.Unsupported( "not supported" ); + + Assert.False( value.IsAvailable ); + Assert.Equal( HostResourceAvailability.Unsupported, value.Availability ); + Assert.Throws( () => { _ = value.GetRequiredValue(); } ); + } +} diff --git a/tests/Host.Tests/src/SystemHostResourceProviderTests.cs b/tests/Host.Tests/src/SystemHostResourceProviderTests.cs new file mode 100644 index 0000000..6dd3a48 --- /dev/null +++ b/tests/Host.Tests/src/SystemHostResourceProviderTests.cs @@ -0,0 +1,112 @@ +namespace Icod.Host.Tests; + +using Icod.Host; +using Xunit; + +/// Tests the injectable and system host-resource provider boundaries. +public sealed class SystemHostResourceProviderTests { + /// Verifies an injected provider can supply deterministic snapshots. + [Fact] + public async Task ProviderContractIsInjectable() { + IHostResourceProvider provider = new FixedProvider(); + + var snapshot = await provider.ObserveAsync(); + + Assert.Equal( "01020304", snapshot.HostIdentifier.GetRequiredValue().Hexadecimal ); + Assert.Equal( 12, snapshot.Processors.ConfiguredProcessorCount.GetRequiredValue() ); + Assert.Equal( 4, snapshot.Processors.ProcessAvailableProcessorCount.GetRequiredValue() ); + Assert.Contains( + snapshot.Capabilities, + static capability => capability.Kind == HostResourceCapabilityKind.ProcessAffinity + ); + } + + /// Verifies the system provider returns controlled, internally consistent observations. + [Fact] + public async Task SystemProviderReturnsControlledSnapshot() { + var snapshot = await SystemHostResourceProvider.Instance.ObserveAsync(); + + Assert.True( snapshot.Processors.ProcessAvailableProcessorCount.IsAvailable ); + Assert.True( snapshot.Processors.ProcessAvailableProcessorCount.GetRequiredValue() >= 1 ); + Assert.Equal( 9, snapshot.Capabilities.Count ); + Assert.All( + snapshot.Capabilities, + static capability => Assert.True( Enum.IsDefined( capability.Availability ) ) + ); + if ( snapshot.Processors.OnlineProcessorCount.IsAvailable ) { + Assert.True( snapshot.Processors.OnlineProcessorCount.GetRequiredValue() >= 1 ); + } + if ( snapshot.Processors.Affinity.IsAvailable ) { + Assert.True( snapshot.Processors.Affinity.GetRequiredValue().Count >= 1 ); + } + } + + private sealed class FixedProvider : IHostResourceProvider { + /// + public ValueTask> GetHostIdentifierAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult( + HostResourceValue.Available( + new HostIdentifier( 0x01020304, "test" ), + HostResourceProvenance.Derived + ) + ); + } + + /// + public ValueTask GetProcessorResourcesAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult( CreateProcessors() ); + } + + /// + public async ValueTask ObserveAsync( + CancellationToken cancellationToken = default + ) { + var host = await GetHostIdentifierAsync( cancellationToken ); + var processors = await GetProcessorResourcesAsync( cancellationToken ); + return new HostResourceSnapshot( + host, + processors, + DateTimeOffset.UnixEpoch + ); + } + + private static ProcessorResourceSnapshot CreateProcessors() { + var configured = HostResourceValue.Available( 12, HostResourceProvenance.Derived ); + var installed = HostResourceValue.Available( 8, HostResourceProvenance.Derived ); + var online = HostResourceValue.Available( 6, HostResourceProvenance.Derived ); + var available = HostResourceValue.Available( 4, HostResourceProvenance.Derived ); + var affinity = HostResourceValue.Available( + new ProcessorAffinityDescriptor( new long[] { 0, 1, 2, 3 }, true ), + HostResourceProvenance.Derived + ); + var quota = HostResourceValue.Available( + new ProcessorQuotaDescriptor( 2.5, 250000, 100000, "test" ), + HostResourceProvenance.Derived + ); + var topology = HostResourceValue.Available( + new ProcessorTopologyDescriptor( + HostResourceValue.Available( 1, HostResourceProvenance.Derived ), + HostResourceValue.Available( 4, HostResourceProvenance.Derived ), + installed, + HostResourceValue.Available( 1, HostResourceProvenance.Derived ) + ), + HostResourceProvenance.Derived + ); + return new ProcessorResourceSnapshot( + configured, + installed, + online, + available, + affinity, + quota, + topology + ); + } + } +} From 682c9b22838d44ff68e2118861a2436e83b19c9a Mon Sep 17 00:00:00 2001 From: uniblab Date: Tue, 25 Aug 2026 21:27:12 -0400 Subject: [PATCH 3/3] exec --- build.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 build.sh diff --git a/build.sh b/build.sh old mode 100644 new mode 100755