Everything here is either a .NET 10 / D-Bus / systemd gotcha or a deliberate choice that isn't visible from the API surface.
SystemdClient is a DI singleton holding a single session-bus connection. It connects
lazily on the first request (EnsureConnectedAsync, lock-guarded, connect-once).
Reason: the app can start and serve /swagger + /openapi/v1.json even if the session
bus is not up yet (e.g. container without logind); the first real call surfaces the
actual error instead of failing at boot.
Manager.Subscribe() is a per-connection flag. Until it is called, systemd delivers
no signals at all (no JobRemoved, no PropertiesChanged) and fails silently.
EnsureSubscribedAsync() runs idempotently inside every code path that installs a
watcher — this is the #1 classic bug in this stack.
For every start/stop the JobRemoved watcher is installed before the StartUnit/
StopUnit call. A fast job can emit JobRemoved immediately after the method reply;
if the watcher is installed after, the completion is lost and the request hangs until
timeout. The pending job path is a lock-guarded cell written after the reply — D-Bus
message ordering + the lock close the race (same pattern as the verified reference CLI).
A job finishing with result: "done" does not mean the service is healthy:
for Type=simple units the start job completes at exec(), and a binary that
exits non-zero afterwards leaves the unit in failed state while the job was done
(verified live). Conversely, real job-level failures come as result: "failed",
"dependency", "skipped", "timeout" — or as a D-Bus error reply (e.g.
org.freedesktop.systemd1.NoSuchUnit when a Requires= dependency doesn't exist).
The API treats anything not done as failure, but "done" ≠ "running".
GetUnit returns not-found for units that have a unit file but are not loaded yet.
GetUnitStatusAsync falls back to LoadUnit on a D-Bus error reply, so a never-started
service still yields a real status instead of a 404.
monitor-start / monitor-stop are POST endpoints that answer
Content-Type: text/event-stream. This is non-standard (the browser EventSource API
only does GET), but it keeps the start/stop verbs semantically POST while streaming
progress. Clients: curl -N -X POST ... or any HTTP client with streaming bodies.
Swagger UI shows the first chunk; for full streaming use curl or a real SSE client.
Frames: job-enqueued, state-changed (0..n), then exactly one of job-completed /
error, after which the stream closes. Note: systemd can emit the first
PropertiesChanged (job assignment) before it sends the StartUnit method reply,
so a state-changed frame may precede job-enqueued. job-completed/error is
always the last frame.
Waiting endpoints distinguish "our wait timeout fired" (→ 504) from "the client went
away" (→ abort, no response) by checking HttpContext.RequestAborted in the
OperationCanceledException filter. Same inside the SSE producer: on timeout it writes
an error frame; on client disconnect it just closes.
Start/stop enqueues with mode replace (systemctl default): a new job replaces a
conflicting queued one instead of failing. If you want orchestrator-grade strictness,
switch SystemdClient.DefaultJobMode to fail.
Only services listed in ServiceManager:Services are addressable; anything else is 404
ServiceNotWhitelisted. A whitelisted service whose unit file doesn't exist is also
404 (UnitNotFound). Names are normalized client-side (.service appended when no
dot is present) and compared case-sensitively.
Strictly sequential, each step waits for its job, fail-fast: the first non-done
result (or D-Bus error) returns 500 with {failedService, result, completed[]}.
They respond only on completion (no SSE) — agreed during planning.
WithOpenApi(op => ...)is deprecated (ASPDEPR002) in .NET 10. Per-route OpenAPI customization now goes throughAddOpenApiOperationTransformer((op, ctx, ct) => ...)(endpoint-specific operation transformers, new in .NET 10).OptionsBuilder.Validateno longer accepts a rich result type. TheFunc<T, ValidationResult>overload is gone; use anIValidateOptions<T>implementation returningValidateOptionsResult(new type, .NET 9+) plus.ValidateOnStart().Results.Streamhas noIAsyncEnumerableoverload. It takesFunc<Stream, Task>/PipeReader/Stream. The SSE producer is therefore pumped through an unboundedChannel<byte[]>and written with explicitFlushAsyncper frame (flushing is what makes SSE real-time).Results.StatusCode(int, object)does not exist — arbitrary status + JSON body isResults.Json(body, statusCode: N).Swashbuckle.AspNetCore.SwaggerUI10.x: the extension isUseSwaggerUI(capital UI) and the option isoptions.SwaggerEndpoint("/openapi/v1.json", "v1")— the NSwag-styleDocumentPathdoes not exist. The OpenAPI document itself comes from the built-inMicrosoft.AspNetCore.OpenApi(app.MapOpenApi()), not from Swashbuckle.Microsoft.OpenApi2.x (bundled with .NET 10): model types moved fromMicrosoft.OpenApi.Modelsto the rootMicrosoft.OpenApinamespace.Tmds.DBus.Generatoremits proxies fromdbus-xml/*.xmlviaAdditionalFiles;CompilerGeneratedFilesOutputPathmust stay under$(BaseIntermediateOutputPath)or the emitted .cs gets compiled twice (CS0101 storm).IValidateOptions<T>must be registered as the interface, not the concrete type. A bareAddSingleton<MyValidator>()is invisible to the options factory, soValidateOnStart()silently does nothing and the app boots with a broken config. UseAddSingleton<IValidateOptions<TOptions>, MyValidator>(). (TheValidate<T>()builder overload that registers the type for you only exists in .NET 11+, not 10.)
| D-Bus error name | HTTP |
|---|---|
org.freedesktop.systemd1.UnitNotFound |
404 |
org.freedesktop.systemd1.UnitMasked |
409 |
org.freedesktop.systemd1.UnitNotActive |
409 |
org.freedesktop.systemd1.NoSuchUnit (broken dependency) |
502 |
org.freedesktop.DBus.Error.AccessDenied / polkit |
403 (shouldn't happen on the session bus) |
org.freedesktop.DBus.Error.NoReply |
504 |
| job wait timeout (our side) | 504 |
| anything else | 500 |
GET /api/services,GET /api/services/{name}— correct states, 404 for non-whitelisted.POST .../start/.../stop— jobdone, states transition verified via the API.POST .../monitor-stop— full SSE trace observed:active/running → deactivating/stop-sigterm → inactive/dead → job-completed(done).POST /start-allfail-fast — a unit withRequires=does-not-exist-xyz.servicebroke the sequence:500 {failedService, result: NoSuchUnit, completed: [...]}./swaggerUI and/openapi/v1.json(8 paths) both 200 in Production environment.
A demo unit example-sleep.service (sleep 300) exists in ~/.config/systemd/user/ —
remove it once you configure your real services in appsettings.json.
Run the app with dotnet run in ServiceManagerBackend/ (or dotnet run --urls http://127.0.0.1:5199); SwaggerUI is at http://127.0.0.1:5199/swagger.