Skip to content

adds39939/AffinitySentinel

Repository files navigation

Affinity Sentinel

Reactive CPU core-group isolation for heterogeneous processors.

While a game is running, Affinity Sentinel gives it uncontended use of your CPU's preferred core group (the AMD 3D V‑Cache CCD today; Intel P‑cores in a planned future release) by evicting every other reassignable process onto the remaining cores. When the game exits, normal scheduling resumes — every process is restored to the full CPU mask.

This is the reactive exclusivity that Process Lasso can't express on its own. Process Lasso (or you) sets the per‑game pin to the preferred group; Affinity Sentinel enforces the eviction of everything else and releases it on exit. It complements an existing Process Lasso setup — it does not replace it.

Developed and validated on an AMD Ryzen 9 9950X3D (dual‑CCD X3D). The engine, topology detection and config model are deliberately CPU‑architecture‑agnostic: AMD dual‑CCD is the first concrete implementation behind a clean interface, and an Intel P+E (hybrid) provider drops in behind the same interface with no engine changes.


How it works

  1. Topology detection (runtime, never assumed). At startup the service detects the machine's core groups and produces a generic model: a preferred mask, an eviction mask, and the full mask (plus the matching CPU Set IDs and architecture‑appropriate labels). The engine only ever sees this generic model.

    • AMD: the preferred group is the CCD whose cores report the largest L3 cache (the stacked 3D V‑Cache die), read via GetLogicalProcessorInformationEx.
    • Intel (future): the preferred group is the cores at the highest EfficiencyClass (the P‑cores) — exposed by the same Win32 call.

    Why detection is mandatory: on the test machine the BIOS option "preferred CCD = Frequency" changes how Windows enumerates logical processors, so the V‑Cache cores are not reliably CPUs 0–15. Affinity Sentinel never hardcodes core IDs — it finds the V‑Cache CCD by cache size wherever it actually lands. (CPU Set IDs are likewise not processor numbers — on the 9950X3D they start at 256 — so they're resolved from GetSystemCpuSetInformation.)

  2. Trigger detection (checks BOTH affinity and CPU Set). A process counts as a present "game" when it is pinned exclusively to the preferred group — either its ProcessorAffinity equals the preferred mask or its default CPU Set equals the preferred group's CPU Set. Process Lasso can pin via either primitive, so checking only one would miss the other. The trigger strategy is pluggable (a process‑name allowlist could be added later).

  3. Eviction + restoration. With ≥1 trigger present, every other reassignable process is confined to the eviction cores. The engine records how each process was placed so restoration reverses the correct primitive. When no trigger remains, everything is restored to the full mask.

  4. Safety. Protected/system processes that reject placement (Access Denied) are logged and skipped. The service restores all placements on graceful stop, and runs a startup safety net that frees anything left stuck on the eviction cores after an unclean shutdown.


Solution layout

A single solution in the new XML .slnx format (AffinitySentinel.slnx), all targeting .NET 10:

Project Type Role
src/AffinitySentinel.Core class library Topology detection (ICpuTopologyProvider + AMD/Intel impls), the architecture‑agnostic monitoring engine, placement strategies, trigger detection, config, update checker, realtime (SignalR) contracts, file logger.
src/AffinitySentinel.Service .NET 10 Worker Service Hosts the engine as a BackgroundService (LocalSystem) plus a loopback SignalR hub, watches config, restore‑on‑shutdown + startup safety net. --dump-topology prints detected groups; runs identically when launched manually for debugging.
src/AffinitySentinel.App WPF + Blazor Hybrid Dashboard, service control, config editor, log viewer, updates — plus a system‑tray icon. Runs un‑elevated; works even when the service is stopped.
src/AffinitySentinel.Installer WiX 7 MSI (.wixproj) Service install + recovery, ProgramData + ACLs, Event Log source, Start Menu shortcut, optional tray autostart, MajorUpgrade, graceful uninstall.
tests/AffinitySentinel.IntegrationTests xUnit Validates real placement/restoration against actual spawned processes (not mocks).

Key design decisions

  • Architecture‑agnostic engine. The engine references only CpuTopology (preferred/eviction/full masks). All AMD/Intel knowledge lives behind ICpuTopologyProvider, selected at runtime by TopologyProviderFactory.
  • Mechanism‑aware restoration. Each confined process carries a PlacementRecord (which primitive, original value). Restoration is driven by the record, not the current mechanism — so it stays correct across fallback‑mode mixed state and live mechanism changes.
  • Realtime channel = SignalR over loopback. The service self‑hosts a SignalR hub on Kestrel bound to 127.0.0.1 only (port from config HubPort, default 48710). State is pushed to the GUI live (no polling), with automatic reconnection. Durable settings still flow through the service‑watched config.json. The GUI degrades gracefully when the hub is unreachable. Loopback works across the LocalSystem‑service ↔ user‑GUI boundary without pipe ACLs and is exempt from the Windows Firewall, so a manually launched service (e.g. dotnet run for debugging) is recognised the moment it appears — "is the service running?" is simply "is the hub connected?", independent of Service Control Manager registration. Trade‑off vs the previous named pipe: any local process can reach the loopback hub (no per‑user ACL), and the elevated service now carries the ASP.NET Core surface — accepted deliberately for a robust, push‑based, debuggable channel. The exposed commands are limited to enable/disable and restore.
  • GUI un‑elevated. It runs asInvoker so you keep full all‑core headroom; service start/stop is elevated on demand via a single UAC prompt.

Enforcement mechanisms (configurable)

Affinity Sentinel uses Windows' native placement APIs directly — the same primitives Process Lasso uses — and does not route through Process Lasso (which has no reliable programmatic API for live per‑process control). Choose the mechanism in the config editor:

Mechanism API Trade‑off
Affinity SetProcessAffinityMask Strongest isolation (process truly cannot run on preferred cores). Some protected/system processes reject it.
CPU Sets SetProcessDefaultCpuSets Best compatibility (almost nothing rejects it). A scheduler hint the OS strongly honours but may override under load — softer guarantee.
Affinity + CPU Set fallback (recommended default) both Hard affinity first; if a process refuses it, fall back to a CPU Set for that process so it's still steered off the preferred cores rather than skipped.

The dashboard shows, per confined process, which primitive is actually in effect — so you can see when the fallback path engaged.


Configuration

Stored at %ProgramData%\AffinitySentinel\config.json (created with defaults on first run; the service watches it and applies changes live). Edit it in the GUI's Configuration view or by hand.

{
  "Enabled": true,
  "Mechanism": "AffinityWithCpuSetFallback",   // Affinity | CpuSets | AffinityWithCpuSetFallback
  "PollIntervalSeconds": 3,
  "Exclusions": [],                              // process names (no .exe) the engine must never touch
  "ProactiveGlobalConfine": false,              // also confine NEW processes when no game is present (default OFF)
  "LogLevel": "Information",
  "GitHubOwner": "adds39939",
  "GitHubRepo": "AffinitySentinel",
  "AutoCheckUpdates": true,                      // passive check (GUI launch + daily in service)
  "AutoInstallUpdates": false                   // opt-in; the service never silently installs
}

ProactiveGlobalConfine is OFF by default on purpose. When you're not gaming you want the full 32‑thread machine for all‑core workloads; a persistent global mask would cripple that. Leave it off unless you specifically want background processes kept off the preferred cores at all times.

The service also always protects: itself, the GUI, system‑critical processes (System/Idle/Registry/ csrss/wininit/winlogon/services/lsass/…), and any triggering game.


Build

Prerequisites

  • .NET 10 SDK (this repo was built with 10.0.100; global.json pins it with latestFeature roll‑forward).
  • A toolchain recent enough to understand the .slnx solution format — the .NET 10 SDK CLI does, as does a current Visual Studio 2022. Check current requirements rather than pinning a version.
  • WiX 7 for the MSI — it ships as the WixToolset.Sdk NuGet package (SDK‑style .wixproj), so a plain dotnet restore pulls the toolset in. No separate WiX install is required.

WiX Open Source Maintenance Fee (OSMF). WiX v7 carries an Open Source Maintenance Fee: organisations generating more than US$10,000/year from projects that use WiX must sponsor the WiX project, and WiX v7 enforces EULA acceptance at build time. Builds therefore pass -p:AcceptEula=wix7. Review the terms at https://wixtoolset.org/osmf/ and ensure you're entitled to accept before building/redistributing.

Build the apps + run unit tests

dotnet build AffinitySentinel.slnx -c Release

The .slnx builds the four .NET projects. The MSI is built separately (the .wixproj is x64‑only and isn't part of the AnyCPU solution build):

dotnet build src/AffinitySentinel.Installer/AffinitySentinel.Installer.wixproj -c Release -p:AcceptEula=wix7
# -> src/AffinitySentinel.Installer/bin/x64/Release/AffinitySentinel.msi

The installer project automatically dotnet publishes the Service and GUI into a staging folder and harvests it, so building the .wixproj is all you need.

Inspect detected topology (no install required)

dotnet run --project src/AffinitySentinel.Service -c Release -- --dump-topology

On the 9950X3D this reports the V‑Cache CCD, the Frequency CCD, their masks and CPU Set IDs.

Tests

The integration tests make real SetProcessAffinityMask / CPU Set calls against processes they spawn and own, then read them back via the same P/Invoke layer the app uses. They are tagged [Trait("Category","Integration")].

# Unit tests only (what CI runs) — integration excluded:
dotnet test AffinitySentinel.slnx -c Release --filter "Category!=Integration"

# Integration tests — RUN ON THE TARGET HARDWARE (real CCD/hybrid topology), Windows, with privilege:
dotnet test tests/AffinitySentinel.IntegrationTests -c Release --filter "Category=Integration"

They only ever touch their own spawned helper processes (the engine is driven with a scoped process enumerator), and clean them up even on failure — they do not perturb your real running processes.


Install & run

Run AffinitySentinel.msi (elevation requested). It:

  • deploys to %ProgramFiles%\AffinitySentinel\;
  • registers the AffinitySentinel service (LocalSystem, auto‑start, restart‑on‑crash) and starts it;
  • creates %ProgramData%\AffinitySentinel\{logs} with ACLs so the elevated service can read/write and the un‑elevated GUI can read/write config + read logs;
  • ships a default config.json that is never overwritten on upgrade;
  • registers an Application Event Log source (removed on uninstall);
  • adds a Start Menu shortcut and an optional "start tray app at logon" feature.

Uninstall/upgrade are graceful: the MSI stops the service and waits, so the service's own shutdown restoration runs (everything back on the full mask) before files are touched. No managed custom action is involved — the service owns restoration. MajorUpgrade preserves config.json and restarts the service.

Working with Process Lasso

Keep using Process Lasso to apply your per‑game pin to the preferred group (via affinity or CPU Set). That pin is the signal Affinity Sentinel watches for. Affinity Sentinel then performs the reactive eviction of everything else and releases it when the game exits. The two are complementary.


Updates (GitHub Releases)

  • The checker (in Core) queries …/releases/latest, compares the vX.Y.Z tag to the running assembly version, and reports availability + notes + the MSI asset URL. It respects the unauthenticated rate limit (cached; GUI‑launch + daily in service) and fails quietly when offline/rate‑limited. No token is embedded or needed for public‑repo reads.
  • The GUI shows an "update available" banner with release notes and Update now. The service only logs availability — it never silently installs unless you opt in.
  • Applying an update: the app downloads the MSI to temp, verifies it (size + the published .sha256 checksum), launches msiexec /i, and immediately exits so the upgrade can replace its own binaries (the classic self‑replace pitfall — handled). The MSI's MajorUpgrade owns the transaction and restarts the service.

Release process

The git tag is the single source of truth for the version — nothing is bumped manually:

  1. Push a SemVer tag: git tag v1.2.3 && git push origin v1.2.3.
  2. release.yaml derives 1.2.3 from the tag, reuses build_test.yaml (on windows-latest) to build
    • test + produce the MSI with -p:Version=1.2.3 stamped into the assemblies and the MSI ProductVersion, then a separate ubuntu-latest job downloads the MSI, generates its .sha256, and publishes a GitHub Release with both attached.
  3. Deployed apps discover the release via the update checker.

Because the version is stamped from the tag, assembly version ≡ MSI ProductVersion ≡ release tag by construction.

CI

  • build_test.yaml — push to main / PRs to main, and workflow_call. On windows-latest: restore, build the .slnx, run unit tests (integration excluded — they need the target hardware), build the MSI, upload it as an artifact. Fails on build/test failure.
  • release.yaml — on v* tags. Reuses build_test.yaml, then publishes the Release from ubuntu-latest (contents: write, built‑in GITHUB_TOKEN).

Assumptions & notes

  • Single processor group (≤ 64 logical CPUs). True for the 9950X3D (32 threads) and essentially all consumer desktop CPUs; multi‑group servers (>64 logical processors) are out of scope, matching the limits of ProcessorAffinity and single‑group KAFFINITY masks.
  • AMD detection keys on asymmetric L3 (two CCDs with different L3 sizes = X3D). A symmetric dual‑CCD part has no "preferred" group and the engine stays idle.
  • Intel hybrid provider is implemented against the same interface but has not been validated on real hybrid silicon (the project's hardware is AMD). It only activates on a GenuineIntel machine reporting ≥2 efficiency classes.
  • ASP.NET Core runtime required on the target. Because the service self‑hosts a SignalR hub, it is framework‑dependent on ASP.NET Core 10 (in addition to the Windows Desktop runtime needed by the GUI). Dev machines with the .NET 10 SDK already have it. For end‑user installs, either ensure the ASP.NET Core 10 runtime is present or publish the service self‑contained.
  • WiX builds require -p:AcceptEula=wix7 (see the OSMF note above).
  • The .slnx AnyCPU build skips the x64‑only .wixproj; build the MSI with the explicit command shown.

About

Reactive CPU core-group isolation for heterogeneous CPUs (AMD X3D dual-CCD / Intel P+E). Gives a running game uncontended use of the preferred core group and evicts other processes onto the rest, releasing on exit.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors