WinSW-style declarative configuration + a native Rust runtime. rsw wraps any
executable as a Windows service: one small static binary, a TOML (or YAML) config
file next to it, and nothing else to install.
# myapp.toml — relative paths resolve against this file's directory
[service]
id = "myapp"
name = "My Awesome App"
description = "Rust driven backend service"
[process]
executable = "app.exe"
arguments = ["--port", "8080"]
[env]
DATABASE_URL = "postgres://user:pass@localhost/db"
[logging]
mode = "roll-by-size" # rotate at 10 MB, keep 8 files (defaults shown)> rsw install myapp.toml # elevated prompt
> rsw start myapp.toml
Grab a prebuilt rsw.exe from the
releases page, or build
it yourself with cargo build --release --bin rsw.
Two release flavors ship side by side (see rsw --version): full (~3 MB)
with everything below, and lite (~1.6 MB, built with
--no-default-features) for fully offline machines — it drops only the
[[download]] feature. A lite binary facing a config with [[download]]
entries logs a warning per entry and skips them (entries with
fail_on_error = true abort the start); rsw validate prints the same note
without failing.
- Zero runtime dependencies — no .NET, no JVM; a single ~3 MB
rsw.exe(a ~1.6 MB offline lite flavor is built from the same source). - Declarative & sidecar-friendly — rename
rsw.exetoapp.exeand it picks upapp.toml/app.yaml/app.ymlautomatically. - Supervised children — stdout/stderr capture through pipes (no locked log files), size/time-based rotation with retention, in-wrapper restart with exponential backoff plus SCM-native failure actions.
- Graceful stop ladder — stop helper command → console Ctrl event (reaching even separate/hidden consoles) → close-window → timeout → TerminateProcess → job-object tree kill. Grandchildren never survive.
- WinSW feature parity — start types incl. delayed, dependencies, service
accounts with automatic SeServiceLogonRight grant,
on_failurerecovery actions, pre/post hooks, startup downloads (with custom CA / mTLS PEM options — verification is never disabled), drive mapping, SDDL security descriptors (incl.allow_start_stopdelegation for UAC-free start/stop),refreshwithout reinstall. - Both config languages — TOML and YAML, same schema, detected by extension.
| Command | Effect |
|---|---|
rsw install [config] |
Install the service (triggers one UAC prompt when needed) |
rsw uninstall [config] |
Stop if needed, then delete the service |
rsw start / stop / restart [config] |
Lifecycle |
rsw status [config] |
Print state; exit 0 running/stopped, 1 transitional, 1060 not installed |
rsw refresh [config] |
Re-read config and update service properties in place |
rsw apply [config] |
Refresh config, then start if not running (one UAC prompt; never restarts a running service) |
rsw run [config] |
Run in the foreground for debugging (no SCM, no admin, Ctrl+C stops) |
rsw validate [config] |
Parse + validate and print the resolved config (secrets masked) |
rsw convert winsw.xml |
Convert a WinSW XML service definition into an rsw TOML file |
[config] is optional when using the sidecar rename convention.
install/uninstall/refresh/apply run from a non-elevated terminal
trigger one UAC prompt and complete in place; the elevated output is
echoed back to your terminal. start/stop/restart try unelevated first
and only prompt after an access-denied error — a service whose DACL delegates
start/stop (allow_start_stop) needs no elevation at all. Pass --no-elevate
to disable auto-elevation and fail with a hint instead.
Full schema (TOML shown; YAML is identical). Everything except service.id
and process.executable is optional — the values below are the defaults.
[service]
id = "myapp" # REQUIRED: SCM service id
name = "My App" # display name (default: id)
description = ""
start_type = "auto" # auto | delayed | manual | disabled | boot | system
dependencies = [] # other service ids that must start first
failure_reset_after = "1 day" # SCM failure-counter reset window
preshutdown = false # accept PRESHUTDOWN notifications
preshutdown_timeout_secs = 180
# security_descriptor = "D:P(A;;GA;;;BA)(A;;GR;;;AU)" # SDDL
allow_start_stop = [] # accounts that may start/stop without UAC (generated DACL; exclusive with security_descriptor)
# [service.account] # omit for LocalSystem
# username = ".\\svc_user" # or DOMAIN\\user or NT AUTHORITY\\NetworkService
# password = "..." # empty for virtual accounts
# allow_logon_as_service = true # grant SeServiceLogonRight at install
[process]
executable = "app.exe" # REQUIRED; bare names resolve via PATH
arguments = []
start_arguments = [] # if non-empty, used at start instead (WinSW)
working_dir = "" # default: config dir
stop_executable = "" # stop helper command (replaces signaling)
stop_arguments = []
stop_signal = "kill" # kill (immediate tree kill) | ctrl-c | ctrl-break
stop_timeout_secs = 15
priority = "normal" # idle|below_normal|normal|above_normal|high|realtime
hide_window = true # accepted for WinSW parity; children always share rsw's hidden console
success_exit_codes = [0] # mapped to service NO_ERROR
[process.restart] # in-wrapper supervision (milliseconds-fast)
policy = "on-failure" # on-failure | always | never | custom
restart_if = [] # custom: restart only for these codes
restart_if_not = [] # custom: restart for all but these codes
delay_secs = 5
backoff = true # 5→10→20… capped at 60s; resets after 60s stable
max_retries = 0 # 0 = unlimited; on limit, hand over to on_failure
[env] # injected into the child; SERVICE_ID is automatic
KEY = "value" # %BASE% and %VAR% expand everywhere
[[on_failure]] # SCM-native recovery (survives rsw itself dying)
action = "restart" # restart | reboot | none
delay = "5 sec" # ms/sec/min/hr/day suffixes; bare number = ms
[logging]
dir = "logs" # default: config dir
basename = "" # default: config file stem
wrapper_log = true # wrapper events → <base>.wrapper.log
mode = "roll-by-size" # append|reset|none|roll|roll-by-size|roll-by-time|roll-by-size-time
size_threshold_mb = 10
time_pattern = "yyyyMMdd" # tokens: yyyy yy MM dd HH mm ss
auto_roll_at = "00:00:00" # roll-by-size-time only
keep_files = 8 # -1 keeps everything
merge_stderr = false
[[download]] # fetched at every service start, before hooks
from = "https://example.com/agent.jar"
to = "agent.jar"
fail_on_error = false
timeout_secs = 120 # whole-request budget; a stalled server cannot wedge the start
# proxy = "http://user:pass@host:port"
# auth = { kind = "basic", user = "u", password = "p" }
# [download.tls] # per-entry TLS (full build only)
# ca = "internal-root.pem" # custom root(s) for server verification;
# # bundles with several certs are fine.
# # Verification is never disabled.
# client_cert = "client.pem" # mTLS pair — must be set together; PEM,
# client_key = "client-key.pem" # unencrypted PKCS8 recommended.
[[map_drive]] # WNetAddConnection2 before the child starts
label = "N:"
unc_path = "\\\\fileserver\\share"
[hooks] # each: executable + arguments + stdout/stderr paths
# pre_start = { executable = "prep.cmd", arguments = [], stdout = "NUL", stderr = "NUL", timeout_secs = 60 }
# post_start = { ... } pre_stop = { ... } post_stop = { ... }- Child stdout →
<base>.out.log, stderr →<base>.err.log(or merged). - Wrapper events →
<base>.wrapper.log(append). - Size rotation renames to
.log.1,.log.2, … (newest first), pruned tokeep_files. - Time rotation writes
<base>.<period>.out.logdirectly, deleting the oldest beyondkeep_files.
By default — no [process.restart] section — every child exit (any exit
code) exits the service with that code and is reported straight to the SCM:
that is WinSW's behavior, and [[on_failure]] recovery takes over
immediately.
With an explicit [process.restart] section you get in-wrapper restarts
(millisecond-fast, pipes and logs stay attached): policy = "on-failure"
retries by exit code, max_retries bounds the attempts, and once exhausted
the service exits with the child's code and hands over to [[on_failure]].
The two layers combine exactly as configured: retries happen when you ask
for them, failures are reported when you don't. Even if the rsw process
itself dies, the job object terminates the whole child tree.
Not ported: zip log archiving (broken in WinSW), beeponshutdown,
customize/dev commands, interactive services, the v2 extension model.
cargo build --release --bin rsw
cargo test # unit + foreground e2e (no admin needed)
cargo test --test service_scm -- --ignored # SCM roundtrip (elevated prompt)
scripts\test-service.ps1 # SCM regression (elevated PowerShell)
scripts\test-runtimes.ps1 # node/python/java smoke (no admin)
Prebuilt Windows binaries are attached to each
release (static, x86_64),
in two flavors with SHA-256 checksums in SHA256SUMS.txt:
rsw-<tag>-x86_64-pc-windows-msvc-full.zip(~3 MB) — all features;rsw-<tag>-x86_64-pc-windows-msvc-lite.zip(~1.6 MB) — offline wrapper without[[download]](build it yourself withcargo build --release --no-default-features --bin rsw).
MIT — see LICENSE.
- Signals in session 0: services have no console, so rsw allocates a hidden
one that the child shares. Ctrl events are broadcast by a short-lived helper
process (
rswrelaunching itself) that attaches to the child's console and swallows the event on its own side, so rsw never risks being terminated by the signal it sends. Processes created viaCREATE_NEW_PROCESS_GROUPstart with ctrl-c disabled; rsw re-enables it explicitly, both for itself and viactrl_breaktargeting. - Start progress: each start phase (pre-start hook, downloads, drive mapping, spawn) bumps the SCM checkpoint with a wait hint sized to that phase's timeout, so a slow hook or download never trips error 1053.
- Orphan protection: every child joins a kill-on-close job object, so if rsw dies for any reason the kernel terminates the whole tree.
- All-synchronous threading: no async runtime; reader threads pump child output line-by-line so rotation never fights the child over file handles.
This project went from an empty folder to a tagged release in a single day, built entirely through ZCode running GLM-5.3: schema design, the full SCM lifecycle, a cross-audit of the shawl and WinSW sources, hunting down Windows console-signal races, converting and deploying a real Node.js service, and the docs you just read — ~6,100 lines of Rust, 40+ tests, all green.
No code was written by hand. The human's contributions: picking the battles, supplying the real-world deployment (and keeping its passwords), and asking "why" at exactly the right moments.
Switching from WinSW right now:
rsw convert <yours>.xml— generates the TOML next to your XML- swap the WinSW exe for
rsw.exe, renamed to match the TOML stem rsw install <yours>.toml— one UAC prompt- accounts: declare
[service.account]for fleet/template deployments (log-on-as-a-service is granted automatically; passwords can come from env vars so they never sit in the file), or leave it out and set the account inservices.msc—rsw refreshkeeps whatever Windows already stores
