SIGN IN SIGN UP

feat(service): run API mode as a Windows service (#311) (#320)

## Summary

Implements the Windows Service Control Manager backend of `all-smi service`, so a Windows monitoring node exports metrics from boot with no logged-in user. This replaces the "no Windows backend yet" arm that #309 left in `service_cmd::backend()`; the framework, the CLI surface, and the exit-code contract are unchanged.

It also extends `shutdown_signal` (moved out of `src/api/server.rs` into `src/api/shutdown.rs`) with an externally triggerable source, because the SCM delivers Stop on a control-handler thread with no signal to observe. Without it the handler would have to call `std::process::exit`, stranding the energy WAL flush and breaking Prometheus counter monotonicity across a service restart. That refactor is cross-platform and is tested on this host.

## What changed

**Service Control Manager backend**

- `src/service_cmd/scm.rs` (+ `scm_tests.rs`): the pure half. Service identity, raw `SERVICE_STATUS` / `QUERY_SERVICE_CONFIG` mapping onto the shared `ServiceStatus`, Win32 error translation, and command-line parsing for the idempotency check. Compiled under `cfg(any(windows, test))` so all of it is unit tested on macOS and Linux, mirroring how #309 keeps the systemd unit renderer covered off Linux.
- `src/service_cmd/scm_backend.rs` (+ `scm_backend_tests.rs`): the `ServiceBackend` impl over the `windows-service` crate. `sc.exe` is never shelled out to, so failures arrive as Win32 codes to translate rather than localized text to scrape.
- `src/service_cmd/scm_host.rs` (+ `scm_host_tests.rs`): the `all-smi service run` host. Dispatcher, control handler, `START_PENDING` to `RUNNING` to `STOP_PENDING` to `STOPPED` reporting, and the Tokio runtime.
- `src/service_cmd/scm_log.rs`: rolling file logging under `%PROGRAMDATA%\all-smi\logs`, daily rotation, last 14 files retained, since stdout is void under the SCM.
- `src/service_cmd/mod.rs`: the Windows `backend()` arm now returns `ScmBackend`; `service run` is dispatched ahead of backend selection because it is the process side of the service, not a management action.
- `src/cli_service.rs`: adds the hidden `ServiceAction::Run` variant.

**Graceful shutdown plumbing (cross-platform)**

- `src/api/latch.rs` (+ `latch_tests.rs`): a one-way boolean latch over a `watch` channel. `send_replace` rather than `send`, so a trigger that lands before any listener subscribes is not lost, and every waiter wakes rather than just the first.
- `src/api/shutdown.rs` (+ `shutdown_tests.rs`): `shutdown_signal` moved out of `server.rs` and now selects on that latch in addition to Ctrl+C and SIGTERM. `request_shutdown()` and `wait_until_serving()` expose it. Split into its own module because `server.rs` was already past the file-size soft limit before this branch, and because this is a self-contained concern: it is the only place that decides what counts as a reason to stop serving.
- `src/api/server.rs`: each listener raises a readiness latch after a successful bind, so the service reports `SERVICE_RUNNING` only once the port is actually accepting connections. `run_api_mode` switched from `init()` to `try_init()` on the tracing subscriber, because the service host installs the file subscriber first and `init()` panics on a second registration.
- `src/cli.rs`: `service_subcommand_is_registered` pins the exact set of `service` actions as a cross-platform contract, so the hidden `run` entry point is added to it. It also now asserts that `run` is the *only* hidden action; the old assertion would have accepted a future change that hid `install`.

**Config discovery**

- `src/common/paths.rs`: `%PROGRAMDATA%\all-smi\config.toml` is added to Tier 2 of `candidate_config_paths()`, ordered after the `%APPDATA%` candidate. LocalSystem's `%APPDATA%` resolves into `C:\Windows\System32\config\systemprofile\AppData\Roaming`, which no operator will ever edit. `all-smi config path` and the `--help` block pick it up automatically, since both already read from `candidate_config_paths()`.

**Dependencies** (both under `[target.'cfg(windows)'.dependencies]`, so no other target pulls them)

- `windows-service = "0.8.1"` — current release, published 2026-05-08, MIT OR Apache-2.0. Its `rust-version` is 1.71.0 against this project's 1.96, so the MSRV is satisfied with room to spare.
- `tracing-appender = "0.2.5"` — current release, MIT, `rust-version` 1.63.0.

**Docs and CI**

- README gains a "Windows (Service Control Manager)" subsection appended into the additive slot below the Linux one: install, elevation, the LocalSystem rationale, the `--user` refusal and its Task Scheduler alternative, idempotency, `%PROGRAMDATA%` config, logs, and the `netsh advfirewall` commands. The firewall is never touched automatically.
- `docs/man/all-smi.1` gains the matching `.SS` subsection, the `%PROGRAMDATA%` config candidate, and two `FILES` entries. Renders cleanly under `man`.
- `.github/workflows/ci.yml` gains a gated `windows-service` smoke test. See "Not verified in this environment" below.

## Design notes

**How it slots into the #309 contract.** Only the `#[cfg(target_os = "windows")]` arm of `backend()` changed; the macOS arm pointing at #310 is untouched. No new `ServiceError` variant was added: the "a service of this name exists but runs a different binary" case reuses `Conflict`, which is the same variant the Linux backend raises for a unit file it did not write, and `--force` lifts it through the existing `uninstall_forced`. The SCM offers nowhere to stamp a managed-by marker, so the registered binary path is the identity check instead.

**Elevation.** This deviates from the issue text, deliberately. Rather than probing the process token elevation flag, each action opens exactly the SCM handles it needs and translates `ERROR_ACCESS_DENIED` into `NeedsElevation` with a "Run as administrator" message. That tests the capability actually required instead of a proxy for it, keeps `status` working unelevated because it never asks for a right it does not need, and avoids adding an unsafe FFI probe that could not be executed even once from this development host.

**`--user`.** A hard `NotSupported` naming Task Scheduler, per the issue. The SCM has no per-user scope, so this is a platform limit rather than a gap.

**Dead-code detection.** `service_cmd/mod.rs` blanket-allows `dead_code` off Linux, which predates this backend and would have hidden unused items across the whole Windows tree. Each new module re-enables the lint for itself with an inner `#![warn(dead_code)]`.

## Test plan

Run on macOS / Apple Silicon:

- [x] `cargo fmt --check`
- [x] `cargo clippy --lib --tests -- -D warnings`
- [x] `cargo check --all-targets`
- [x] `cargo test --lib` (1260 passed, 3 ignored)

Unit coverage added: `SERVICE_STATUS` state and start-type mapping including the pending states and the stale-pid case, launch-argument mapping, Win32 error translation including the elevation refusal and the `--user` refusal, SCM command-line parsing (quoted, unquoted, unterminated, and the `\\?\` verbatim prefix) and the case-insensitive path comparison behind the idempotency guard, `%PROGRAMDATA%` path composition, and the new shutdown source (resolves on a prior trigger, on a later trigger, and for every waiter, and critically does *not* resolve while no source has fired).

**Windows-target compilation and lint.** `cargo check --target x86_64-pc-windows-msvc` on the real crate cannot work from macOS: it dies in `zstd-sys`, whose build script invokes `cc` with `--target=x86_64-pc-windows-msvc` and no Windows headers (`fatal error: 'string.h' file not found`). This is the same wall #309 hit for Linux. An isolated probe crate was used instead. It `#[path]`-includes the real `service_cmd`, `common`, `cli`, `cli_service`, and `utils::command` sources and stubs only `crate::api` and `crate::device`, the two modules that drag in the device readers. It is built with both a library and a binary target, because this crate compiles its module tree twice and a `pub` item that is always live in a library target can be dead behind a binary's private module root.

```
cargo clippy --target x86_64-pc-windows-msvc --lib --bins --tests -- -D warnings   # clean
```

Coverage of the probe was verified rather than assumed: a deliberate type error was injected into each of `scm_backend.rs`, `scm_host.rs`, and `scm_log.rs` in turn, and each produced a compile error through the probe. The technique is written down in the module-declaration comment in `service_cmd/mod.rs`, because no CI job compiles those three files and the next person to touch them will otherwise have no way to check their work. The probe found two real defects that would otherwise have shipped uncompiled: `raw_code` and `describe` were private but used across module boundaries, and `build_service_info` took `&PathBuf` where clippy's `ptr_arg` demands `&Path`.

So: every line of the Windows backend has been type-checked and linted for `x86_64-pc-windows-msvc`. None of it has been *run*, and the probe does not link (there is no MSVC linker on this host), so link-time problems remain possible.

## Not verified in this environment

This was developed on macOS / Apple Silicon with no access to a Windows machine, a Service Control Manager, or the self-hosted runner. Every runtime acceptance criterion below is therefore **unverified**, and its checkbox in the issue is deliberately left unticked:

- **Install, start, and reboot persistence.** `service install --now` registering and starting, `/metrics` answering, `service status` reporting a pid, and the service surviving a reboot with no login.
- **SCM stop semantics.** `service stop` and a `services.msc` stop both reaching `SERVICE_STOPPED` within the wait hint, the energy WAL flush line appearing in the log, and no orphan process.
- **Failure-action recovery** after `taskkill /F`.
- **The non-elevated refusal** exiting 1 with the elevation message. The mapping from `ERROR_ACCESS_DENIED` is unit tested; that the SCM actually returns that code to an unelevated caller for each of install, uninstall, start, and stop is not.
- **`service run` from a console** failing gracefully. The message for `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` is unit tested; that `StartServiceCtrlDispatcher` returns that code is not.
- **Log files** appearing under `%PROGRAMDATA%\all-smi\logs` and rotating.
- **The `%PROGRAMDATA%` config being honoured end to end**: changing `api.port`, restarting, and the listener moving.
- **`cargo test` on Windows**, and therefore the `#[cfg(windows)]` test modules (`scm_backend_tests.rs`, `scm_host_tests.rs`), which are compiled here but never executed. Those include the assertions that the raw Win32 constants in `scm.rs` still agree with the `windows-service` enums.
- **The `Environment` `REG_MULTI_SZ` registry value** for `RUST_LOG`. This is documented but implemented by Windows itself, not by this change.

**The CI job has never executed.** `.github/workflows/ci.yml` gains a `windows-service` job for the self-hosted `windows-on-macmini02-x64` runner, gated on the repository variable `ENABLE_WINDOWS_SERVICE_SMOKE == 'true'`. While that variable is unset the job is skipped everywhere, including on forks; no repository settings were changed by this PR, so enabling it is a deliberate follow-up action. It asserts the exit-code contract, the registered configuration (`AUTO_START`, `LocalSystem`, the `service run` arguments), `%PROGRAMDATA%` config discovery, the metrics endpoint, log creation, `taskkill /F` recovery, and the full lifecycle. On failure it dumps `sc.exe query`, `sc.exe qc`, `sc.exe qfailure`, `service status --json`, `config path`, the effective config, and the tail of every file under `%PROGRAMDATA%\all-smi\logs`. Cleanup runs `if: always()` and stops, deletes, and removes the config and logs, so a failed run cannot leave a stray registered service on a persistent runner. Treat its first real run as part of the work, not as a regression signal.

Closes #311
J
Jeongkyu Shin committed
464bfdda99b3bb288a1ebdf07014b7abd8175f68
Parent: 8c822aa
Committed by GitHub <noreply@github.com> on 8/5/2026, 12:20:20 PM