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/.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/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/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 100755
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/icon.png b/icon.png
new file mode 100644
index 0000000..6258ecf
Binary files /dev/null and b/icon.png differ
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
+ );
+ }
+ }
+}