Davasorus.Utility.DotNet.Services
2026.2.3.1
dotnet add package Davasorus.Utility.DotNet.Services --version 2026.2.3.1
NuGet\Install-Package Davasorus.Utility.DotNet.Services -Version 2026.2.3.1
<PackageReference Include="Davasorus.Utility.DotNet.Services" Version="2026.2.3.1" />
<PackageVersion Include="Davasorus.Utility.DotNet.Services" Version="2026.2.3.1" />
<PackageReference Include="Davasorus.Utility.DotNet.Services" />
paket add Davasorus.Utility.DotNet.Services --version 2026.2.3.1
#r "nuget: Davasorus.Utility.DotNet.Services, 2026.2.3.1"
#:package Davasorus.Utility.DotNet.Services@2026.2.3.1
#addin nuget:?package=Davasorus.Utility.DotNet.Services&version=2026.2.3.1
#tool nuget:?package=Davasorus.Utility.DotNet.Services&version=2026.2.3.1
Davasorus.Utility.DotNet.Services
Davasorus.Utility.DotNet.Services provides a set of service/client abstractions for managing Windows services (start, stop, load, manipulate) in .NET 8+ applications. It is designed for use with dependency injection (DI). End users should only interact with the service interfaces; the services handle all client interactions, error handling, and logging.
Note: Only interact with the service interfaces (e.g.,
IStartServiceService,IStopServicesService,ILoadServicesService,IManipulateServiceService) in your application code. The service classes manage all communication with their respective client classes internally.
Breaking Changes in This Release
The root namespace has been renamed from Tyler.Utility.DotNet.Services.* to Davasorus.Utility.DotNet.Services.* to align with the published NuGet package identity. Consumers upgrading from any prior 2026.2.1.x version must update their using directives:
// Before
using Tyler.Utility.DotNet.Services.Configuration;
using Tyler.Utility.DotNet.Services.Windows_Services.Start_Services.Service;
// After
using Davasorus.Utility.DotNet.Services.Configuration;
using Davasorus.Utility.DotNet.Services.Windows_Services.Start_Services.Service;
No public API signatures changed. This is purely a namespace rename.
B1 — API contract modernization (this version)
DI lifetimes changed to Singleton
All registrations in AddWindowsServices now use TryAddSingleton. Consumers depending on Scoped or Transient resolution semantics will see one instance for the lifetime of the host. Consumers wanting to override the default implementations (e.g., for testing) can pre-register their implementation before calling AddWindowsServices — TryAddSingleton honors consumer pre-registrations.
IDisposable removed from all 8 interfaces
ILoadServicesClient, ILoadServicesService, IManipulateServiceClient, IManipulateServiceService, IStartServiceClient, IStartServiceService, IStopServicesClient, IStopServicesService no longer declare void Dispose();. Consumers using using var x = service; patterns must drop the using keyword:
// Before
using var loadService = scope.ServiceProvider.GetRequiredService<ILoadServicesService>();
var list = await loadService.GetServiceList("HOST");
// After
var loadService = scope.ServiceProvider.GetRequiredService<ILoadServicesService>();
var list = await loadService.GetServiceList("HOST");
Method return types changed to ServiceResult<T> / ServiceResult
Every public method now returns a ServiceResult (non-generic) or ServiceResult<T> (generic) wrapping the operation's outcome. The streaming method GetServiceListStreaming is the one exception — it retains IAsyncEnumerable<ServiceObj> and exceptions propagate to the consumer's await foreach.
// Before
var list = await loadService.GetServiceList("HOST");
if (list != null) { ... }
// After
var result = await loadService.GetServiceList("HOST");
if (result.IsSuccess && result.Value != null)
{
var list = result.Value;
// ...
}
// or, to handle failure:
if (!result.IsSuccess)
{
logger.LogError(result.Error, "List failed; reported to SQS: {Reported}", result.WasReportedToSqs);
}
ServiceResult carries IsSuccess, Error, and WasReportedToSqs. The WasReportedToSqs flag tells callers whether the package's catch-template successfully sent the error to SQS — useful for chain composition (don't double-report).
Manipulate methods now throw on null input (previously silent return false)
SetSpecifiedServiceOnMachineToEnabled, SetSpecifiedServiceOnMachineToDisabled, SetSpecifiedServiceOnMachineToManual and their Client-tier counterparts previously returned false silently when called with null or whitespace input. They now throw ArgumentException consistently with the other tiers. Inputs are validated via ArgumentException.ThrowIfNullOrWhiteSpace(...).
ServiceOptions changes
ServiceOperationTimeoutSecondshas been split into two fields:GeneralOperationTimeoutSeconds(default 60s) for general service operationsWebOperationTimeoutSeconds(default 30s) for IIS / w3svc operations
EnableErrorLoggingnow actually does work — setting it tofalseskips SQS reporting (previously the field was read by nothing)MaxRetryAttemptsnow actually drives retry behavior via Polly (previously the field was read by nothing)
ServiceOptionsBuilder method renames
// Before
services.AddWindowsServices(svc => svc.WithOperationTimeout(TimeSpan.FromSeconds(45)));
// After
services.AddWindowsServices(svc => svc.WithGeneralOperationTimeout(TimeSpan.FromSeconds(45)));
// or
services.AddWindowsServices(svc => svc.WithWebOperationTimeout(TimeSpan.FromSeconds(20)));
Polly retry on transient exceptions
Transient WMI / SCM / Registry exceptions (IOException, TimeoutException, SocketException, ServiceProcess.TimeoutException) now retry automatically per MaxRetryAttempts (default 3 retries). Non-transient exceptions (ArgumentException, UnauthorizedAccessException, etc.) do NOT retry. Worst-case wall-clock for a fully failing operation: (MaxRetryAttempts + 1) × timeout ≈ 4 × 60s = 240s for general operations under default settings.
If retry behavior is unwanted, set MaxRetryAttempts = 0 in options.
WaitForStatus is now an async polling loop
The blocking ServiceController.WaitForStatus(...) call inside Start/Stop has been replaced with an async polling loop (Internal/WaitForStatusAsyncHelper). The new loop polls every 500ms instead of blocking a thread-pool thread for the full timeout duration. Consumers observing thread-pool exhaustion under heavy Start/Stop load will see relief.
C2a — Telemetry semantic conventions migration
Two consumer-visible wire-format changes in this release:
Cache result tag changed from cache.hit (bool) to cache.result (enum)
Activity tags emitted by GetServiceList and GetServiceDescription on cache hit/miss now use the canonical OpenTelemetry-aligned enum-valued cache.result key instead of the bool cache.hit key. Dashboards observing the old key will return zero results.
# Before (dashboard query)
{cache.hit="true"}
# After
{cache.result="hit"}
# Before
{cache.hit="false"}
# After
{cache.result="miss"}
The cache.key tag is unchanged — only the bool→enum change applies.
Tag-key prefix normalization under windows.service.*
All Windows-service-related tag keys and event names now use the windows.service.* prefix per OpenTelemetry domain-prefix conventions. Dashboards observing the old (non-prefixed) keys will return zero results.
# Before → After
registry.key.path → windows.service.registry.key_path
service.timeout_seconds → windows.service.timeout_seconds
# Event names
registry.operation.start → windows.service.registry.operation_start
registry.set_value → windows.service.registry.set_value
registry.verified → windows.service.registry.verified
service.status.change → windows.service.status_change
The existing windows.service.* keys (host, name, operation, type, success, count) are unchanged — they already used the prefix.
Migration: see the new "Observability" section below for the canonical constant locations.
C2b — Telemetry deepening
Five consumer-visible changes in this release. Most affect observability dashboards and APM topology; none affect functional behavior.
ActivityKind changed from Internal to Client on outbound operations
GetServiceList, GetServiceDescription, GetServiceListStreaming (WMI), EnableService/DisableService/SetServiceToManual (Registry), Start (SCM), Stop (SCM) now create activities with ActivityKind.Client instead of ActivityKind.Internal. Service-layer wrappers stay Internal. APM topology diagrams will start rendering Services as a "client" calling out to "Windows host" instead of as a leaf in the trace tree.
windows.service.success tag now emitted on all operations
Previously only Manipulate* operations emitted this tag. After C2b, Start, Stop, Load operations also emit it (true on happy path, false on caught exception). Dashboards can now filter on {windows.service.success="false"} across all operations uniformly.
Cache key suffix :v2 and envelope wire-format change
The Services pkg's internal cache entries now use the :v2 key suffix and a new CachedWithContext<T> envelope shape carrying both the cached value and a W3C traceparent string. Pre-C2b cache entries (key without :v2) are NOT read; this forces a one-time cache miss + WMI re-population on first read per host/operation after deploy. Operational impact: one burst of WMI queries during the deploy window. Once re-populated, the v2 entry is cached for the same 30-day-sliding/60-day-absolute TTL.
If you read this package's cache directly (you shouldn't), the stored value shape is now CachedWithContext<List<ServiceObj>> (or <string> for Description) instead of the bare collection/string.
Cache-hit activities now emit ActivityLink to the populate-call trace
When a GetServiceList or GetServiceDescription returns a cached value, the resulting activity carries one ActivityLink back to the trace context of the call that originally populated the cache. Dashboards visualizing trace relationships can now show "cache hit → original populate" connections.
If the stored traceparent fails to parse (e.g., corrupted cache value), the cache-hit emits a cache.activitylink.malformed event and continues normally — the cache-hit semantics never become a cache-miss due to link parsing failure.
Five new OpenTelemetry metrics under windows_service.*
windows_service.operations Counter<long> tags: operation, host, success
windows_service.operation.duration Histogram<double> unit: ms; tags: operation, host
windows_service.cache.hits Counter<long> tags: operation, host
windows_service.cache.misses Counter<long> tags: operation, host
windows_service.errors Counter<long> tags: operation, host, exception.type
All metrics live on a single Meter named Davasorus.Utility.Services.WindowsServices. They're auto-wired by AddDavasorusTelemetry's wildcard meter subscription (Davasorus.Utility.*). See the new "Observability" section below.
D — Caching deepening
Five consumer-visible changes in this release.
Cache TTLs reduced from 30d/60d to 30s/5min/1hr
Service-list and service-description cache entries previously stayed for up to 60 days. After D: entries refresh every 30 seconds (SWR), expire if untouched for 5 minutes (sliding), and have a 1-hour absolute ceiling. The aggressive freshness is safe because mutations now flush cache entries immediately and SWR refreshes happen in the background.
# Before
SlidingExpiration = 30 days
AbsoluteExpirationRelativeToNow = 60 days
# After
Refresh = 30 seconds (SWR refresh-after threshold)
SlidingExpiration = 5 minutes (entry expires if untouched)
AbsoluteExpirationRelativeToNow = 1 hour (hard ceiling)
Operational impact: more frequent WMI queries (every 5-min idle period now causes a miss + re-populate). For most usage patterns this is negligible; the stampede-prevention from GetOrSetAsync ensures concurrent callers don't multiply the queries.
GetServiceDescription return type changed from Task<ServiceResult<string>> to Task<ServiceResult<string?>>
Binary-breaking interface change.
Return semantics:
Success(null)— service not found on hostSuccess("")— service exists but has no descriptionSuccess("text")— service has description "text"Failure(...)— WMI threw or cache backend completely failed
Before: all three success cases collapsed to empty string, indistinguishable from cache miss. Consumer code matching on result.Value == "" for "missing" now sees a non-match. Update to result.Value is null for the missing case.
Concurrent GetServiceList(host) callers no longer cause N parallel WMI queries
Per-key stampede lock inside GetOrSetAsync ensures one factory invocation per cache key. Callers that miss the cache simultaneously now share the result of a single WMI roundtrip. Cache pkg stamps cache.lock_waited=true on the awaiting callers' activities and cache.factory_invoked=true on the leader's activity — observable in trace UIs.
Mutations (Start/Stop/Enable/Disable/Manual) now flush cache entries
After each successful mutation, BOTH the affected service's cache entry AND the host's list cache are flushed via ICacheTagInvalidation.InvalidateByTagsAsync([$"host:{host}", $"service:{host}:{name}"]). The two tags target distinct entries: host:{host} matches only the list entry (the only entry tagged with it), and service:{host}:{name} matches only the one mutated service's description (the only entry tagged with it). Other services' descriptions on the same host are NOT flushed. Subsequent GetServiceList and GetServiceDescription calls see the freshly-mutated state, not the old cached value.
Today's "30-day-stale Status field" problem after starting/stopping a service is fixed.
If the cache implementation doesn't support ICacheTagInvalidation (Cache pkg's SqlServer backend doesn't expose it), the invalidation is gracefully skipped. Entries will still refresh via the new 30s SWR + 5min sliding TTL — bounded staleness.
Cache pkg activity tags now surface on Services activities
Davasorus.Utility.DotNet.Cache's GetOrSetAsync stamps the ambient activity (i.e., the Services-pkg activity created by StartActivityWithLogging) with:
cache.factory_invoked(bool) — true if the factory ran (miss), false if cache served the value (hit).cache.lock_waited(bool) — true if the caller waited on the per-key stampede lock.cache.is_stale(bool) — true if the served value is pastRefreshage.cache.refresh_started(bool) — true if a background SWR refresh was triggered.
Dashboards can graph SWR refresh rate via {cache.refresh_started="true"} filter.
Migration: see the updated "Observability" section's new "Caching" sub-section.
E — Hot-path correctness + hygiene
Five consumer-visible changes in this release.
CancellationToken parameter added to all public APIs
Binary-breaking interface change. Source-compatible because the parameter has default as its default value, so existing call sites compile unchanged. Consumers compiled against the old package need to recompile.
// Before
Task<ServiceResult<List<ServiceObj>>> GetServiceList(string hostName);
// After
Task<ServiceResult<List<ServiceObj>>> GetServiceList(string hostName, CancellationToken cancellationToken = default);
Affects all public methods on ILoadServicesClient, ILoadServicesService, IManipulateServiceClient, IManipulateServiceService, IStartServiceClient, IStartServiceService, IStopServicesClient, IStopServicesService.
Cancellation contract: cancelled operations throw OperationCanceledException. They do NOT return ServiceResult.Failure(...). This is the one exception to B1's "operational outcomes return Failure" rule, per .NET convention.
The activity is still tagged with windows.service.success=false and exception.type=OperationCanceledException on cancellation, and windows_service.errors{exception.type=OperationCanceledException} counter increments — so cancellation is observable in dashboards.
Handle leaks closed
ServiceController, ManagementObject, RegistryKey instances are now disposed via using declarations. Consumer-invisible behavior change — operators see lower process handle counts under load.
Per-host WMI scope cache (60s TTL)
LoadServiceDescriptionFromHost now reuses a connected ManagementScope instance across calls within 60s. First call per host pays the 5-30ms scope.Connect() handshake; subsequent calls within TTL reuse the connected scope.
Cache is auto-evicted on ManagementException (stale-connection recovery). Other exception types don't trigger eviction.
"teps" debug-string removed from filter list
The hardcoded service-name filter list previously included "teps" as a testing-only entry (with XML doc comment "for testing needs to be removed before deployment"). Now removed. Services starting with or containing "teps" no longer pass the filter.
OpenRegistryKey distinguishes permission vs connectivity errors
Mutation methods' catch blocks now have a typed catch tower:
| Exception | Failure message | exception.type tag |
|---|---|---|
UnauthorizedAccessException |
"Permission denied accessing registry on '{host}'" | UnauthorizedAccessException |
SecurityException |
"Security exception accessing registry on '{host}'" | SecurityException |
IOException |
"Connectivity error accessing registry on '{host}'" | IOException |
| (other) | (existing generic message) | (actual type) |
Dashboards can filter on {exception.type="UnauthorizedAccessException"} separately from connectivity errors.
Configuration / Options (Cluster F)
Service-name filter (ServiceFilterOptions)
LoadServicesClient.GetServiceList filters the SCM service-list by name. The built-in 12-entry list (CommunicationServer, new, nwa, nwf, nws, Peripheral, nwd, Tomcat, New World, Enterprise, OpenSearch, Elastic) is the floor; consumers can extend or suppress via ServiceFilterOptions:
// Fluent (recommended)
services.AddWindowsServices(b => b
.WithRetryPolicy(5)
.WithFilter(f => f
.WithAdditionalNames("Splunk", "DataDog")
.WithSuppressedNames("OpenSearch")));
// appsettings.json shape:
// {
// "Services": {
// "MaxRetryAttempts": 5,
// "Filter": {
// "AdditionalNames": [ "Splunk", "DataDog" ],
// "SuppressedNames": [ "OpenSearch" ]
// }
// }
// }
services.AddWindowsServices(configuration.GetSection("Services"));
The effective filter set is (Defaults ∪ AdditionalNames) ∖ SuppressedNames, case-insensitive and deduplicated.
Validation + fail-fast
ServiceOptions is decorated with DataAnnotations ([Range] on the 3 numeric properties). Each AddWindowsServices overload calls .ValidateDataAnnotations().ValidateOnStart() — misconfiguration throws OptionsValidationException at host start, not at first call.
Hot reload via IOptionsMonitor
All four clients receive IOptionsMonitor<ServiceOptions> (not snapshot IOptions<>). In long-running hosts (IHostedService), operators can change MaxRetryAttempts or filter entries at runtime and Services pkg picks up the change without restart.
IResiliencePipelineProvider is a Singleton that rebuilds the Polly pipeline on options change; clients consume pipelineProvider.Current per operation.
Health Checks (G)
The package ships an optional Microsoft.Extensions.Diagnostics.HealthChecks integration. Configure remote-host WMI reachability probing in two steps:
// Step 1 — register Services and configure hosts to probe
services.AddWindowsServices(b => b
.WithHealthCheckHosts("server1.contoso.com", "server2.contoso.com"));
// Or via appsettings.json:
// {
// "Services": {
// "HealthCheck": {
// "Hosts": [ "server1.contoso.com", "server2.contoso.com" ],
// "PerHostTimeout": "00:00:05"
// }
// }
// }
services.AddWindowsServices(configuration.GetSection("Services"));
// Step 2 — activate the health check (standard ASP.NET Core pattern)
services.AddHealthChecks().AddWindowsServicesHealthCheck();
WindowsServicesHealthCheck returns:
- Healthy when all configured hosts respond to WMI within
PerHostTimeout. - Degraded (default — overridable via
failureStatusparameter) when one or more hosts fail or time out. TheHealthCheckResult.Datadictionary keys each failing host to its per-host error message. - Healthy immediately when
Hostsis empty (no probes performed).
The check leverages the shared ManagementScopeCache singleton, so cache-hit probes (within the 60-second TTL) are near-zero-cost. Sequential probing keeps connection pressure low; for very large host lists, partition into multiple AddWindowsServicesHealthCheck(name: ...) registrations.
AOT / Trim-safety
This package is not AOT-safe and not trim-safe by design. It uses System.Management (WMI), System.ServiceProcess (SCM), and Microsoft.Win32 (Registry), all of which depend on reflection.
The csproj declares <IsAotCompatible>false</IsAotCompatible> and <IsTrimmable>false</IsTrimmable>, plus class-level [RequiresUnreferencedCode] and [RequiresDynamicCode] attributes on every concrete client/service/health-check/cache class. Consumers attempting to AOT-publish or aggressively-trim an app referencing this package will receive compile-time warnings explicitly naming this package as the source of the incompatibility.
Breaking Changes (F)
IOptions<ServiceOptions>→IOptionsMonitor<ServiceOptions>in all four client and service primary constructors. Source-breaking for manualnewcallers; source-compatible for DI-resolved consumers (the container auto-derivesIOptions<>from theAddOptions<>()registration).LoadServicesClientprimary constructor gainsIServiceFilter. Source-breaking for manual constructors. DI-registered consumers get it transparently.- All clients receive
IResiliencePipelineProviderinstead ofResiliencePipeline. Source-breaking for manual constructors. - Raw
ServiceOptionsdirect injection removed. Consumers wanting current values resolveIOptionsMonitor<ServiceOptions>.CurrentValue. - Raw
ResiliencePipelinedirect injection removed. Replaced byIResiliencePipelineProvider. Davasorus.Utility.DotNet.Services.Wmi.ServiceFilterRegexdeleted. Wasinternal— no external impact. Functionality moved toIServiceFilter/RegexServiceFilter.ServiceOptionszero/negative timeouts and negative retry counts now fail validation at host start. Fix the config; do not weaken validation.
Identity Configuration (H)
Error messages reported to SQS via IServiceErrorReporter carry the consuming application's identity (UtilityName, UtilityRelease). Configure via the fluent or appsettings path:
// Fluent
services.AddWindowsServices(b => b
.WithUtilityIdentity("MyApp", "2026.5.1"));
// appsettings.json
// {
// "Services": {
// "Identity": {
// "ProductName": "MyApp",
// "ProductRelease": "2026.5.1",
// "UserName": "svc-account" // optional; defaults to Environment.UserName
// }
// }
// }
services.AddWindowsServices(configuration.GetSection("Services"));
The reporter is Scoped (matches the ISqsPublisher lifetime from Davasorus.Utility.DotNet.SQS) and is consumed by all four Services-pkg clients/services internally. Consumers must call AddSqsLogging(...) from Davasorus.Utility.DotNet.SQS to register ISqsPublisher — otherwise error-reporting will silently fail at runtime (the reporter swallows the missing-publisher exception and logs at Warning per the best-effort design). Per-call error reporting also honours the existing ServiceOptions.EnableErrorLogging flag: when false, the reporter is skipped entirely and ServiceResult.WasReportedToSqs is false.
Service Status Events (J)
Start/Stop client operations raise status transition events that consumers can subscribe to from any UI host (WPF, MAUI, console, web). Subscribe once via the DI-resolved IServiceStatusReporter:
// In your DI registration or hosted-service startup:
var reporter = services.BuildServiceProvider().GetRequiredService<IServiceStatusReporter>();
reporter.ServiceStatusChanged += (sender, args) =>
{
// args.HostName, args.ServiceName, args.Status (e.g., "Starting", "Running", "Stopping", "Stopped")
// WPF consumer: wrap UI work in Dispatcher.Invoke
Application.Current.Dispatcher.Invoke(() =>
{
var serviceObj = MyServiceCollection.FirstOrDefault(s => s.HostName == args.HostName && s.ServiceName == args.ServiceName);
if (serviceObj is not null) serviceObj.Status = args.Status;
});
};
The reporter is Singleton — a single subscription catches all status transitions across all Start/Stop client operations. Event handlers run on whatever thread raised the event; UI consumers must dispatch to their UI thread inside the handler.
Breaking Changes (J)
StartServiceClient+StopServicesClientprimary constructors addIServiceStatusReporter statusReporterparameter (binary-breaking; source-breaking for manualnewcallers; DI-resolved consumers transparent).- Services pkg no longer references
Davasorus.Utility.Dotnet.Contracts.Collections. The PackageReference and PackageVersion pin are removed. TheServiceObjstype itself remains in the Contracts.Collections pkg for other consumers; Services pkg simply stops referencing it. - Services pkg no longer mutates
ServiceObjs.Collection. Consumers who relied on Services-pkg-driven status updates to that globally-sharedObservableCollectionmust migrate to subscribing toIServiceStatusReporter.ServiceStatusChangedand updating their own collection (or any other UI state) themselves from inside the handler. See the example above.
Breaking Changes (H)
- Client/service primary constructors:
IServiceScopeFactory scopeFactory→IServiceErrorReporter errorReporteron all 8 production classes. DI consumers transparent; consumers who manuallynew LoadServicesClient(...)etc. must update. Davasorus.Utility.DotNet.Services.Helpers.ErrorMessageHelperdeleted. Wasinternal static; no external impact.GenericAppSettings.ProductName/GenericAppSettings.ProductReleaseno longer consulted by Services pkg. Replace withAddWindowsServices(b => b.WithUtilityIdentity(...))orServices:Identityappsettings.ISqsServiceno longer resolved from DI by Services pkg internals. Consumers must callAddSqsLogging(...)fromDavasorus.Utility.DotNet.SQSto registerISqsPublisher. (ISqsServicemay still be registered byAddSqsLoggingfor back-compat — Services pkg simply no longer reads from it.)IServiceErrorReporteris public. Originally spec'd asinternal, but CLR accessibility rules require any type used as a primary-ctor parameter of apublicclass to itself bepublic. The interface is now part of the package's public API surface.
Features
- Start, stop, and manipulate Windows services on remote or local hosts
- Retrieve service lists and descriptions, with streaming support
- Strongly-typed async APIs
- OpenTelemetry distributed tracing via
ActivitySourceon all operations - Robust error handling and logging (ILogger + SQS integration)
- Designed for DI: all services registered as Singleton via
TryAddSingleton - Returns
ServiceResult<T>for every public method, with explicit success/failure outcomes and SQS-report tracking - Automatic retry on transient WMI/SCM/Registry exceptions via Polly resilience pipeline
- Async-polling wait loop replaces blocking
WaitForStatus, avoiding thread-pool exhaustion under heavy load - Hybrid validation: programmer errors throw, operational outcomes return
Dependency Injection Setup
Register all services using the AddWindowsServices() extension method:
using Davasorus.Utility.DotNet.Services.Configuration;
services.AddWindowsServices();
Or with fluent configuration:
services.AddWindowsServices(svc => svc
.WithGeneralOperationTimeout(TimeSpan.FromSeconds(60))
.WithWebOperationTimeout(TimeSpan.FromSeconds(30))
.WithRetryPolicy(3)
.WithErrorLogging(true));
This registers the following services automatically:
| Interface | Implementation | Lifetime |
|---|---|---|
IStartServiceService |
StartServiceService |
Singleton |
IStartServiceClient |
StartServiceClient |
Singleton |
IStopServicesService |
StopServicesService |
Singleton |
IStopServicesClient |
StopServicesClient |
Singleton |
ILoadServicesService |
LoadServicesService |
Singleton |
ILoadServicesClient |
LoadServicesClient |
Singleton |
IManipulateServiceService |
ManipulateServiceService |
Singleton |
IManipulateServiceClient |
ManipulateServiceClient |
Singleton |
ResiliencePipeline (shared) |
(built from ServiceOptions) |
Singleton |
ServiceOptions |
(via IOptions<> pipeline) |
Singleton |
Example Usage
Start a Service
public class MyServiceStarter
{
private readonly IStartServiceService _startService;
public MyServiceStarter(IStartServiceService startService)
{
_startService = startService;
}
public async Task StartAsync(string host, string serviceName)
{
await _startService.StartService(host, serviceName);
}
}
Stop a Service
public class MyServiceStopper
{
private readonly IStopServicesService _stopService;
public MyServiceStopper(IStopServicesService stopService)
{
_stopService = stopService;
}
public async Task StopAsync(string host, string serviceName)
{
await _stopService.StopService(host, serviceName);
}
}
Load Services List
public class MyServiceLoader
{
private readonly ILoadServicesService _loadService;
public MyServiceLoader(ILoadServicesService loadService)
{
_loadService = loadService;
}
public async Task<List<ServiceObj>?> GetServicesAsync(string host)
{
return await _loadService.GetServiceList(host);
}
}
Manipulate Service State
public class MyServiceManipulator
{
private readonly IManipulateServiceService _manipulateService;
public MyServiceManipulator(IManipulateServiceService manipulateService)
{
_manipulateService = manipulateService;
}
public async Task<bool> EnableServiceAsync(string host, string serviceName)
{
return await _manipulateService.SetSpecifiedServiceOnMachineToEnabled(host, serviceName);
}
}
API Overview
IStartServiceService
Task StartService(string hostName, string serviceName, TimeSpan? timeout = null)Starts the specified Windows service on the given host. Defaults to 60 seconds if no timeout is specified.Task StartWebServices(string hostName, TimeSpan? timeout = null)Starts all web-related Windows services on the specified host. Defaults to 30 seconds if no timeout is specified.
IStopServicesService
Task StopService(string hostName, string serviceName, TimeSpan? timeout = null)Stops the specified Windows service on the given host. Defaults to 60 seconds if no timeout is specified.Task StopWebServices(string hostName, TimeSpan? timeout = null)Stops all web-related Windows services on the specified host. Defaults to 30 seconds if no timeout is specified.
ILoadServicesService
Task<List<ServiceObj>?> GetServiceList(string hostName)Retrieves a list of Windows services from the specified host. Returns a list ofServiceObjinstances representing each service, ornullif retrieval fails.Task<string> GetServiceDescription(string hostName, string serviceName)Gets the description of a specific Windows service on the given host. Returns the service description as a string.IAsyncEnumerable<ServiceObj> GetServiceListStreaming(string hostName)Streams Windows services from the specified host as they are discovered. Returns an async stream ofServiceObjinstances.
IManipulateServiceService
Task<bool> SetSpecifiedServiceOnMachineToEnabled(string hostName, string serviceName)Enables the specified Windows service on the given host, setting its startup type to "Automatic". Returnstrueif successful.Task<bool> SetSpecifiedServiceOnMachineToDisabled(string hostName, string serviceName)Disables the specified Windows service on the given host, setting its startup type to "Disabled". Returnstrueif successful.Task<bool> SetSpecifiedServiceOnMachineToManual(string hostName, string serviceName)Sets the specified Windows service on the given host to "Manual" startup type. Returnstrueif successful.
Observability
This package emits OpenTelemetry activities and metrics. Both are auto-wired into the OTel pipeline by AddDavasorusTelemetry's default wildcard subscription (Davasorus.Utility.*). No additional registration extension is required.
OpenTelemetry semantic-convention version
This package's Activity tag keys and event names follow OpenTelemetry semantic conventions v1.28, pinned via the Davasorus.Utility.DotNet.Telemetry dependency. Tag key examples: windows.service.name, windows.service.host, cache.result (post-Cluster-C2a).
When OpenTelemetry upgrades to v1.29+ in ways that rename tag keys (e.g., the v1.27→v1.28 transition renamed several keys), expect a coordinated Davasorus.Utility.DotNet.Telemetry version bump that flows through to this package. Tag-key renames will be documented in the corresponding Breaking Changes section.
Activities
| Operation | Activity name | Kind |
|---|---|---|
LoadServicesClient.GetServiceList |
Services.Client.Load.List |
Client |
LoadServicesClient.GetServiceDescription |
Services.Client.Load.Describe |
Client |
LoadServicesClient.GetServiceListStreaming |
Services.Client.Load.Stream |
Client |
LoadServicesService.GetServiceList |
Services.Service.Load.List |
Internal |
LoadServicesService.GetServiceDescription |
Services.Service.Load.Describe |
Internal |
ManipulateServiceClient.EnableService |
Services.Client.Manipulate.Enable |
Client |
ManipulateServiceClient.DisableService |
Services.Client.Manipulate.Disable |
Client |
ManipulateServiceClient.SetServiceToManual |
Services.Client.Manipulate.Manual |
Client |
| (Service wrappers) | Services.Service.Manipulate.* |
Internal |
StartServiceClient.Start |
Services.Client.Start |
Client |
StopServicesClient.Stop |
Services.Client.Stop |
Client |
Tag keys come from Davasorus.Utility.DotNet.Telemetry's SemanticConventions.WindowsService group (and SemanticConventions.Cache for cache tags). Reference them in dashboard query / processing code:
using Davasorus.Utility.DotNet.Telemetry;
// Tag keys
var hostKey = SemanticConventions.WindowsService.Host; // "windows.service.host"
var operationKey = SemanticConventions.WindowsService.Operation; // "windows.service.operation"
var successKey = SemanticConventions.WindowsService.Success; // "windows.service.success"
var cacheKey = SemanticConventions.Cache.Key; // "cache.key"
var cacheResult = SemanticConventions.Cache.Result; // "cache.result" — values: "hit" | "miss"
Metrics
Single Meter: Davasorus.Utility.Services.WindowsServices.
| Metric | Type | Unit | Tags |
|---|---|---|---|
windows_service.operations |
Counter<long> |
(count) | windows.service.operation, .host, .success |
windows_service.operation.duration |
Histogram<double> |
ms |
windows.service.operation, .host |
windows_service.cache.hits |
Counter<long> |
(count) | windows.service.operation, .host |
windows_service.cache.misses |
Counter<long> |
(count) | windows.service.operation, .host |
windows_service.errors |
Counter<long> |
(count) | windows.service.operation, .host, exception.type |
Caching
This package uses Davasorus.Utility.DotNet.Cache's GetOrSetAsync for atomic load-or-populate, ICacheTagInvalidation.InvalidateByTagsAsync for mutation-driven invalidation, and CacheOptions.Refresh for stale-while-revalidate.
Cache key schema
ServiceList:{hostName}:v2
ServiceDescription:{hostName}:{serviceName}:v2
Cache tag schema
Every cache entry carries exactly one tag for selective invalidation:
List entries: ["host:{host}"]
Description entries: ["service:{host}:{name}"]
Each tag uniquely identifies one entry class: host:{host} is carried only by list entries; service:{host}:{name} is carried only by the description for that one service. On successful Start/Stop/Enable/Disable/Manual operations, both tags are passed to InvalidateByTagsAsync([$"host:{host}", $"service:{host}:{name}"]). Because InvalidateByTagsAsync performs a logical-OR match, this flushes exactly the list entry for the host and the one mutated service's description — other services' descriptions on the same host are NOT evicted.
TTL configuration
| Setting | Value | Meaning |
|---|---|---|
Refresh |
30 seconds | SWR refresh-after threshold (stale-but-served + background refresh fires) |
SlidingExpiration |
5 minutes | Entry expires if untouched for this duration |
AbsoluteExpirationRelativeToNow |
1 hour | Hard ceiling regardless of access pattern |
Activity tags emitted by Cache pkg on Services activities
using Davasorus.Utility.DotNet.Telemetry;
var factoryInvokedKey = SemanticConventions.Cache.FactoryInvoked; // "cache.factory_invoked"
var lockWaitedKey = SemanticConventions.Cache.LockWaited; // "cache.lock_waited"
var isStaleKey = SemanticConventions.Cache.IsStale; // "cache.is_stale"
var refreshStartedKey = SemanticConventions.Cache.RefreshStarted; // "cache.refresh_started"
Cache pkg's GetOrSetAsync automatically stamps these on Activity.Current (which is the Services-pkg activity at the time of the call). Dashboards can filter on {cache.refresh_started="true"} to graph SWR refresh rate, on {cache.lock_waited="true"} to graph stampede-lock contention, etc.
Example dashboard queries (caching-specific)
# SWR refresh rate
rate(windows_service_operations_total{cache_refresh_started="true"}[5m])
# Stampede-lock wait rate
rate(windows_service_operations_total{cache_lock_waited="true"}[5m])
# Cache invalidation flush count distribution
histogram_quantile(0.5,
rate(windows_service_operations_total{cache_invalidated_count!=""}[5m]))
Threading + Cancellation
All public methods accept CancellationToken cancellationToken = default (Group E). Cancellation honored cooperatively:
- WMI / SCM / Registry sync calls are wrapped in
Task.Run(_, cancellationToken)— no thread-pool thread pinned for 60s during a service-start wait. sc.WaitForStatus(target, timeout)replaced with a cooperative poll loop usingTask.Delay(_, cancellationToken)between SCM status checks (250ms poll interval).- Cache operations forward the token to
ICacheOperations.GetOrSetAsync(_, ct)andICacheTagInvalidation.InvalidateByTagsAsync(_, ct).
Cancellation contract: cancelled operations throw OperationCanceledException (per .NET convention), they do NOT return ServiceResult.Failure(...). The activity records the cancellation:
var key = SemanticConventions.Exception.Type; // "exception.type"
// On cancellation: tag value is "OperationCanceledException"
Dashboard query example:
# Cancellation rate per host
rate(windows_service_errors_total{exception_type="OperationCanceledException"}[5m])
The cooperative poll loop in Start/Stop honors cancellation at three points:
ThrowIfCancellationRequested()between pollsTask.Delay(_, cancellationToken)(throwsOperationCanceledExceptionon cancel)Task.Run(_, cancellationToken)around the SCM mutation call (sc.Start/sc.Stop)
ActivityLink: cache-hit → populate trace
GetServiceList and GetServiceDescription cache-hit activities carry one ActivityLink back to the trace context of the call that originally populated the cache value (recorded in the cached envelope as a W3C traceparent). Visualize cache-hit relationships in your tracing UI.
If the stored traceparent is malformed (corrupted serialization, unexpected wire format), the cache-hit activity emits a cache.activitylink.malformed event and continues with the cached value. Malformed traceparents never downgrade a cache-hit to a cache-miss.
Example dashboard queries (PromQL-style)
# Per-host operation rate
rate(windows_service_operations_total{}[5m])
# Per-operation p99 latency (ms)
histogram_quantile(0.99, rate(windows_service_operation_duration_bucket{}[5m]))
# Cache hit ratio
rate(windows_service_cache_hits_total{}[5m]) / (rate(windows_service_cache_hits_total{}[5m]) + rate(windows_service_cache_misses_total{}[5m]))
# Error rate by exception type
rate(windows_service_errors_total{}[5m])
# All failed operations
rate(windows_service_operations_total{windows_service_success="false"}[5m])
See the Davasorus.Utility.DotNet.Telemetry documentation for the full semantic conventions catalog.
Error Handling
All errors are logged via ILogger and reported to SQS. Service methods return null, false, or the exception message as appropriate, and log details for diagnostics. All operations are instrumented with OpenTelemetry ActivitySource spans for distributed tracing.
Requirements
- .NET 8.0 or .NET 10.0
License
MIT License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Davasorus.Utility.DotNet.Cache (>= 2026.2.2.29)
- Davasorus.Utility.DotNet.Contracts.Types (>= 2026.2.2.3)
- Davasorus.Utility.DotNet.SQS (>= 2026.2.2.9)
- Davasorus.Utility.DotNet.Telemetry (>= 2026.2.2.12)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.8)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.8)
- Microsoft.Extensions.Http.Resilience (>= 10.6.0)
- Microsoft.Extensions.Logging (>= 10.0.8)
- System.Management (>= 10.0.8)
- System.ServiceProcess.ServiceController (>= 10.0.8)
-
net8.0
- Davasorus.Utility.DotNet.Cache (>= 2026.2.2.29)
- Davasorus.Utility.DotNet.Contracts.Types (>= 2026.2.2.3)
- Davasorus.Utility.DotNet.SQS (>= 2026.2.2.9)
- Davasorus.Utility.DotNet.Telemetry (>= 2026.2.2.12)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.8)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.8)
- Microsoft.Extensions.Http.Resilience (>= 10.6.0)
- Microsoft.Extensions.Logging (>= 10.0.8)
- System.Management (>= 10.0.8)
- System.ServiceProcess.ServiceController (>= 10.0.8)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2026.2.3.1 | 0 | 6/1/2026 |
| 2026.2.2.14 | 0 | 5/31/2026 |
| 2026.2.2.13 | 90 | 5/23/2026 |
| 2026.2.2.12 | 96 | 5/19/2026 |
| 2026.2.2.11 | 93 | 5/18/2026 |
| 2026.2.2.10 | 97 | 5/18/2026 |
| 2026.2.2.9 | 87 | 5/18/2026 |
| 2026.2.2.8 | 90 | 5/17/2026 |
| 2026.2.2.7 | 94 | 5/17/2026 |
| 2026.2.2.6 | 99 | 5/17/2026 |
| 2026.2.2.5 | 88 | 5/17/2026 |
| 2026.2.2.4 | 84 | 5/16/2026 |
| 2026.2.2.3 | 88 | 5/16/2026 |
| 2026.2.2.2 | 95 | 5/16/2026 |
| 2026.2.2.1 | 94 | 5/15/2026 |
| 2026.2.1.3 | 149 | 4/16/2026 |
| 2026.2.1.2 | 2,147 | 4/9/2026 |
| 2026.2.1.1 | 344 | 4/1/2026 |
| 2026.1.3.5 | 153 | 3/29/2026 |
| 2026.1.3.4 | 186 | 3/29/2026 |