feat(sidecar): start as a real Windows service #29

Merged
whitlocktech merged 1 commits from feat/windows-service into main 2026-08-07 18:53:16 +00:00
Member

What & why

The sidecar could never start as a Windows service. Every install failed at sc start with 1053:

Service Control Manager: A timeout was reached (30000 milliseconds) while waiting
for the Runic Gateway uo-link sidecar service to connect.

sc query RunicGatewayLink
  STATE             : 1  STOPPED
  WIN32_EXIT_CODE   : 0 (0x0)
  SERVICE_EXIT_CODE : 0 (0x0)

Nothing had crashed — exit code 0, and the same binary with the same config ran perfectly in the foreground. The sidecar was a plain console program, and the Windows SCM only supervises a process that calls StartServiceCtrlDispatcher and identifies itself within ~30 seconds.

The installer's design (docs/installer/PLAN.md §8) had recorded that sc create against the plain console binary worked and needed no change here. That was a false symmetry with systemd: systemd supervises any foreground process, the SCM does not. There is no third option where sc.exe adopts an arbitrary console executable — it is a service-aware binary or a shim, and a shim was already rejected as a third binary to keep current.

What changed

The entry point splits so the platform owns only starting and stopping:

systemd ──▶ main ──▶ unix::run ──────────────────┐
                                                 ├──▶ app::run
SCM ─────▶ main ──▶ windows::run ──▶ ServiceMain ─┘
                                 └─▶ console fallback
  • src/app.rs is the whole sidecar, moved across unchanged. No #[cfg] anywhere on the data path — config, shard link, store, web server and the event loop are shared code.
  • src/windows.rs speaks the SCM handshake. The dispatcher is tried first and failing is expected: ERROR_FAILED_SERVICE_CONTROLLER_CONNECT (1063) means "not started by the SCM" and falls through to a normal foreground run. One binary does both, with no --service flag for an operator to forget. Any other dispatcher error is a real failure.
  • Running is reported only once the shard port is bound and the store is open, so a bad config fails the start rather than flapping Running → Stopped, and a failed run leaves a nonzero SERVICE_EXIT_CODE instead of the misleading 0 above.
  • A service has no stdout, so service mode logs to uo-link-sidecar.<date>.log beside its config (daily, seven kept). Foreground runs still log to stdout unchanged.
  • src/unix.rs additionally handles SIGTERM — what systemctl stop sends, and which previously took the default disposition mid-write.

windows-service and tracing-appender are declared under [target.'cfg(windows)'.dependencies], so Cargo neither resolves nor builds them for Linux.

No protocol change, so PROTOCOL_VERSION stays at 3.

Release note

This needs to release as v1.2.0, and a new bundle must carry it. Until then the installer keeps pulling v1.1.0 and hitting 1053. installer#<fix/service-start-diagnosis> names v1.2.0 as the floor in its 1053 diagnosis, so if this lands under a different number that constant needs updating to match.

How it was tested

Verified on Windows against a real SCM service — registered the way the installer does (virtual service account NT SERVICE\RunicGatewayLink, --config in binPath, failure actions), 13/13 checks:

sc create                               PASS   exit 0
binPath kept its quotes                 PASS
sc start succeeds                       PASS   exit 0 after 1s      (was 1053 after 30s)
start was not a 30s handshake timeout   PASS   1s
service reports RUNNING                 PASS   RUNNING
serves /health while running            PASS   {"protocol":3,...}
writes a service log file               PASS   uo-link-sidecar.2026-08-07.log
service reports STOPPED                 PASS   STOPPED
stopped with exit code 0                PASS

The service's own log, showing the shared path running under ServiceMain and a graceful stop via the SCM's Stop control rather than a kill:

uo_link_sidecar::app:   uo-link sidecar starting
uo_link_sidecar::shard: shard link listening addr=127.0.0.1:17788
uo_link_sidecar::store: store ready
uo_link_sidecar::web:   web server listening addr=127.0.0.1:18080
uo_link_sidecar::app:   shutting down

Also:

  • Console fallback: ran the release binary in the foreground — dispatcher declined with 1063, it fell through, bound both ports and served /health. --version and --print-config unchanged.
  • Linux build: cargo build in rust:1-slim-bookworm succeeds, and grepping that build log for windows-service / tracing-appender returns 0 hits — neither crate is even resolved for Linux. (cargo check --target x86_64-unknown-linux-gnu is not usable here; sqlx's C needs a cross toolchain.)
  • cargo fmt, cargo clippy --all-targets clean, cargo test 26 passed (4 new, covering the service log directory fallbacks and the service name matching the installer's).

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why The sidecar could never start as a Windows service. Every install failed at `sc start` with **1053**: ``` Service Control Manager: A timeout was reached (30000 milliseconds) while waiting for the Runic Gateway uo-link sidecar service to connect. sc query RunicGatewayLink STATE : 1 STOPPED WIN32_EXIT_CODE : 0 (0x0) SERVICE_EXIT_CODE : 0 (0x0) ``` Nothing had crashed — exit code 0, and the same binary with the same config ran perfectly in the foreground. The sidecar was a plain console program, and the Windows SCM only supervises a process that calls `StartServiceCtrlDispatcher` and identifies itself within ~30 seconds. The installer's design (`docs/installer/PLAN.md` §8) had recorded that `sc create` against the plain console binary worked and needed no change here. That was a false symmetry with systemd: systemd supervises *any* foreground process, the SCM does not. There is no third option where `sc.exe` adopts an arbitrary console executable — it is a service-aware binary or a shim, and a shim was already rejected as a third binary to keep current. ## What changed The entry point splits so the platform owns **only starting and stopping**: ``` systemd ──▶ main ──▶ unix::run ──────────────────┐ ├──▶ app::run SCM ─────▶ main ──▶ windows::run ──▶ ServiceMain ─┘ └─▶ console fallback ``` - **`src/app.rs` is the whole sidecar**, moved across unchanged. No `#[cfg]` anywhere on the data path — config, shard link, store, web server and the event loop are shared code. - **`src/windows.rs`** speaks the SCM handshake. The dispatcher is tried first and *failing is expected*: `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` (1063) means "not started by the SCM" and falls through to a normal foreground run. One binary does both, with **no `--service` flag** for an operator to forget. Any other dispatcher error is a real failure. - **`Running` is reported only once the shard port is bound and the store is open**, so a bad config fails the *start* rather than flapping Running → Stopped, and a failed run leaves a nonzero `SERVICE_EXIT_CODE` instead of the misleading `0` above. - **A service has no stdout**, so service mode logs to `uo-link-sidecar.<date>.log` beside its config (daily, seven kept). Foreground runs still log to stdout unchanged. - **`src/unix.rs`** additionally handles `SIGTERM` — what `systemctl stop` sends, and which previously took the default disposition mid-write. `windows-service` and `tracing-appender` are declared under `[target.'cfg(windows)'.dependencies]`, so Cargo neither resolves nor builds them for Linux. No protocol change, so `PROTOCOL_VERSION` stays at 3. ## Release note **This needs to release as v1.2.0**, and a new bundle must carry it. Until then the installer keeps pulling v1.1.0 and hitting 1053. `installer#<fix/service-start-diagnosis>` names `v1.2.0` as the floor in its 1053 diagnosis, so if this lands under a different number that constant needs updating to match. ## How it was tested **Verified on Windows against a real SCM service** — registered the way the installer does (virtual service account `NT SERVICE\RunicGatewayLink`, `--config` in `binPath`, failure actions), 13/13 checks: ``` sc create PASS exit 0 binPath kept its quotes PASS sc start succeeds PASS exit 0 after 1s (was 1053 after 30s) start was not a 30s handshake timeout PASS 1s service reports RUNNING PASS RUNNING serves /health while running PASS {"protocol":3,...} writes a service log file PASS uo-link-sidecar.2026-08-07.log service reports STOPPED PASS STOPPED stopped with exit code 0 PASS ``` The service's own log, showing the shared path running under `ServiceMain` and a graceful stop via the SCM's Stop control rather than a kill: ``` uo_link_sidecar::app: uo-link sidecar starting uo_link_sidecar::shard: shard link listening addr=127.0.0.1:17788 uo_link_sidecar::store: store ready uo_link_sidecar::web: web server listening addr=127.0.0.1:18080 uo_link_sidecar::app: shutting down ``` Also: - **Console fallback**: ran the release binary in the foreground — dispatcher declined with 1063, it fell through, bound both ports and served `/health`. `--version` and `--print-config` unchanged. - **Linux build**: `cargo build` in `rust:1-slim-bookworm` succeeds, and grepping that build log for `windows-service` / `tracing-appender` returns **0 hits** — neither crate is even resolved for Linux. (`cargo check --target x86_64-unknown-linux-gnu` is not usable here; sqlx's C needs a cross toolchain.) - `cargo fmt`, `cargo clippy --all-targets` clean, `cargo test` 26 passed (4 new, covering the service log directory fallbacks and the service name matching the installer's). ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-08-07 18:51:10 +00:00
feat(sidecar): start as a real Windows service
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m22s
96af2afa68
`sc.exe start RunicGatewayLink` failed with 1053 on every Windows install:
"a timeout was reached (30000 milliseconds) while waiting for the service to
connect", with SERVICE_EXIT_CODE 0. Nothing had crashed. The sidecar was a
plain console program, and the Windows service control manager only supervises
a process that calls StartServiceCtrlDispatcher and identifies itself within
~30 seconds.

The installer's design assumed symmetry with systemd, which supervises any
foreground process. Windows has no equivalent: it is a service-aware binary or
a shim, and a shim was already rejected as a third binary to keep current.

Split the entry point so the platform only owns starting and stopping:

  systemd --> main --> unix::run ---------------+
                                                +--> app::run
  SCM ------> main --> windows::run --> ServiceMain
                                    \-> console fallback

- app.rs is the whole sidecar, unchanged and shared. No #[cfg] on the data path.
- windows.rs speaks the SCM handshake. The dispatcher is tried first and failing
  is expected: ERROR_FAILED_SERVICE_CONTROLLER_CONNECT (1063) means "not started
  by the SCM" and falls through to a normal foreground run, so one binary does
  both with no --service flag to forget.
- Running is reported only once the shard port is bound and the store is open, so
  a bad config fails the start instead of flapping Running -> Stopped, and a
  failed run leaves a nonzero SERVICE_EXIT_CODE instead of the misleading 0.
- A service has no stdout, so service mode logs to uo-link-sidecar.log.<date>
  beside its config, rolled daily, seven kept.
- unix.rs additionally handles SIGTERM, which is what systemctl stop sends and
  which previously took the default disposition mid-write.

The Windows crates are declared under [target.'cfg(windows)'.dependencies].
Verified: a Linux build in rust:1-slim-bookworm succeeds and resolves neither
windows-service nor tracing-appender.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 67d7800300 into main 2026-08-07 18:53:16 +00:00
whitlocktech deleted branch feat/windows-service 2026-08-07 18:53:17 +00:00
Sign in to join this conversation.
No description provided.