initial: per-user process supervisor (rss)

- start/stop/restart/status/logs/enable/disable/list/daemon
- PID-file based state, no daemon, no root
- config.toml with placeholders, restart policies, env vars
- log tailing with -n/-f and stderr colorization
- supervisor per service with SIGTERM handling
This commit is contained in:
Niko Marmeladkov 2026-07-03 10:47:25 +03:00
commit 653b8d1612
Signed by untrusted user who does not match committer: Niko
GPG key ID: E3B955F9442D44E3
12 changed files with 1600 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
config.toml
*.pid
*.log

601
Cargo.lock generated Normal file
View file

@ -0,0 +1,601 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "js-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rss"
version = "0.1.0"
dependencies = [
"chrono",
"clap",
"nix",
"serde",
"toml",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]

11
Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "rss"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
chrono = "0.4"
nix = { version = "0.29", default-features = false, features = ["signal", "fs"] }

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 niko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

57
README.md Normal file
View file

@ -0,0 +1,57 @@
# rss — RuSt Supervisor
Lightweight, per-user process supervisor. No daemon, no root, no virtualization.
## Philosophy
- **KISS** — one binary, one job. Each service gets its own supervisor process.
- **UNIX way** — PID files for state, no IPC, no sockets.
- **User-space** — everything under `~/.config/rss/` and `~/.local/share/rss/`.
- **No root** — runs entirely as your user.
## Usage
```
rss start <name> Start a service
rss stop <name> Stop a service
rss restart <name> Restart a service
rss status [name] Show service status
rss logs <name> [-n N] [-f] View logs (last N, follow)
rss enable <name> [--now] Enable auto-start
rss disable <name> Disable auto-start
rss list List all services
rss daemon Start all enabled services
```
## Configuration
Place `config.toml` in `~/.config/rss/config.toml`.
```toml
[service.NAME]
workdir = "/path/to/project"
command = "your command here"
stop_command = "optional stop command"
timeout_start = 15
timeout_stop = 10
restart = "on-failure" # no, always, on-failure
restart_delay = 3
restart_max = 5 # 0 = unlimited
oneshot = false
stop_signal = "TERM"
env = { KEY = "value" }
```
Placeholders in `command` / `stop_command`: `{name}`, `{workdir}`, `{pid}`, `{log_file}`, plus any env var.
See `config.example.toml` for a full example.
## Install
```sh
cargo install --path .
```
## License
MIT

30
config.example.toml Normal file
View file

@ -0,0 +1,30 @@
[service.web]
workdir = "/home/user/projects/web"
command = "python3 -m http.server {port}"
stop_command = "curl -s -X POST http://localhost:{port}/shutdown"
timeout_start = 15
timeout_stop = 10
restart = "on-failure"
restart_delay = 3
restart_max = 5
oneshot = false
stop_signal = "TERM"
env = { port = "8080", HOST = "0.0.0.0" }
[service.backup]
workdir = "/home/user"
command = "tar czf /tmp/backup.tar.gz ."
oneshot = true
restart = "no"
[service.app]
workdir = "/home/user/app"
command = "node server.js --port {PORT}"
stop_command = "kill -INT {pid}"
restart = "always"
restart_delay = 2
restart_max = 10
timeout_start = 30
timeout_stop = 15
stop_signal = "INT"
env = { PORT = "3000", NODE_ENV = "production" }

58
src/cli.rs Normal file
View file

@ -0,0 +1,58 @@
use clap::Parser;
#[derive(Parser)]
#[command(name = "rss", version = "0.1.0", about = "Lightweight process supervisor")]
pub struct Cli {
#[arg(short = 'C', long, global = true, help = "Path to config file")]
pub config: Option<String>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(clap::Subcommand)]
pub enum Commands {
/// Start a service
Start {
name: String,
},
/// Stop a service
Stop {
name: String,
},
/// Restart a service
Restart {
name: String,
},
/// Show service status
Status {
name: Option<String>,
},
/// Show service logs
Logs {
name: String,
#[arg(short = 'n', value_name = "N", help = "Show last N lines")]
lines: Option<usize>,
#[arg(short = 'f', help = "Follow new log entries")]
follow: bool,
},
/// Enable auto-start for a service
Enable {
name: String,
#[arg(long, help = "Start the service immediately")]
now: bool,
},
/// Disable auto-start for a service
Disable {
name: String,
},
/// List all configured services
List,
/// Start all enabled services
Daemon,
/// Internal: run supervisor for a service
#[command(hide = true)]
Supervise {
name: String,
},
}

99
src/config.rs Normal file
View file

@ -0,0 +1,99 @@
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
pub struct Config {
pub service: HashMap<String, ServiceConfig>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceConfig {
pub workdir: String,
pub command: String,
#[serde(default)]
pub stop_command: Option<String>,
#[serde(default = "default_timeout_start")]
#[allow(dead_code)]
pub timeout_start: u64,
#[serde(default = "default_timeout_stop")]
pub timeout_stop: u64,
#[serde(default = "default_restart")]
pub restart: RestartPolicy,
#[serde(default = "default_restart_delay")]
pub restart_delay: u64,
#[serde(default = "default_restart_max")]
pub restart_max: u32,
#[serde(default)]
pub oneshot: bool,
#[serde(default = "default_stop_signal")]
pub stop_signal: String,
#[serde(default)]
pub env: HashMap<String, String>,
}
fn default_timeout_start() -> u64 {
15
}
fn default_timeout_stop() -> u64 {
10
}
fn default_restart() -> RestartPolicy {
RestartPolicy::OnFailure
}
fn default_restart_delay() -> u64 {
3
}
fn default_restart_max() -> u32 {
5
}
fn default_stop_signal() -> String {
"TERM".to_string()
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum RestartPolicy {
No,
Always,
OnFailure,
}
impl Config {
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
if config.service.is_empty() {
return Err("no services defined in config".into());
}
Ok(config)
}
}
impl ServiceConfig {
pub fn resolve_command(&self, name: &str, log_file: &str) -> String {
let mut cmd = self
.command
.replace("{name}", name)
.replace("{workdir}", &self.workdir)
.replace("{log_file}", log_file);
for (k, v) in &self.env {
cmd = cmd.replace(&format!("{{{}}}", k), v);
}
cmd
}
pub fn resolve_stop_command(&self, name: &str, pid: u32, log_file: &str) -> Option<String> {
let pid_str = pid.to_string();
self.stop_command.as_ref().map(|s| {
let mut s = s
.replace("{name}", name)
.replace("{workdir}", &self.workdir)
.replace("{pid}", &pid_str)
.replace("{log_file}", log_file);
for (k, v) in &self.env {
s = s.replace(&format!("{{{}}}", k), v);
}
s
})
}
}

120
src/log.rs Normal file
View file

@ -0,0 +1,120 @@
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::time::Duration;
const RED: &str = "\x1b[31m";
const RESET: &str = "\x1b[0m";
pub fn show_logs(path: &str, lines: Option<usize>, follow: bool) -> std::io::Result<()> {
let color = use_color();
if let Some(n) = lines {
print_last_n(path, n, color)?;
} else {
let content = std::fs::read_to_string(path)?;
for line in content.lines() {
println!("{}", colorize(line, color));
}
}
if follow {
follow_logs(path, lines.unwrap_or(0), color)?;
}
Ok(())
}
fn print_last_n(path: &str, n: usize, color: bool) -> std::io::Result<()> {
let mut file = File::open(path)?;
let file_size = file.seek(SeekFrom::End(0))?;
if file_size == 0 {
return Ok(());
}
let mut buf = Vec::new();
let mut pos = file_size;
let block = 4096.min(file_size as usize);
let mut newline_count = 0;
while pos > 0 {
let read_size = block.min(pos as usize);
pos -= read_size as u64;
file.seek(SeekFrom::Start(pos))?;
let mut chunk = vec![0u8; read_size];
file.read_exact(&mut chunk)?;
buf.splice(0..0, chunk);
newline_count = buf.iter().filter(|&&b| b == b'\n').count();
if newline_count > n {
break;
}
}
let start = if newline_count > n {
let mut skip = newline_count - n;
let mut idx = 0;
for (i, &b) in buf.iter().enumerate() {
if b == b'\n' {
skip -= 1;
if skip == 0 {
idx = i + 1;
break;
}
}
}
idx
} else {
0
};
if let Ok(s) = std::str::from_utf8(&buf[start..]) {
for line in s.lines() {
println!("{}", colorize(line, color));
}
}
Ok(())
}
fn follow_logs(path: &str, _printed: usize, color: bool) -> std::io::Result<()> {
let mut file = File::open(path)?;
file.seek(SeekFrom::End(0))?;
let mut reader = BufReader::new(file);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => {
std::thread::sleep(Duration::from_millis(100));
}
Ok(_) => {
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
if !trimmed.is_empty() {
println!("{}", colorize(trimmed, color));
std::io::stdout().flush().ok();
}
}
Err(_) => break,
}
}
Ok(())
}
fn colorize(line: &str, color: bool) -> String {
if !color {
return line.to_string();
}
if line.contains("E: ") || line.starts_with("E:") {
format!("{}{}{}", RED, line, RESET)
} else {
line.to_string()
}
}
fn use_color() -> bool {
if std::env::var("NO_COLOR").is_ok() {
return false;
}
nix::unistd::isatty(1).unwrap_or(false)
}

314
src/main.rs Normal file
View file

@ -0,0 +1,314 @@
mod cli;
mod config;
mod log;
mod state;
mod supervisor;
use std::process::{Command, Stdio};
use clap::Parser;
use cli::{Cli, Commands};
use config::Config;
use state::State;
use supervisor::{parse_signal, process_running, wait_for_exit};
fn main() {
let cli = Cli::parse();
// Internal supervise subcommand — must run before config loading?
if let Commands::Supervise { ref name } = cli.command {
let config_path = resolve_config_path(cli.config.as_deref());
let config = Config::load(&config_path).unwrap_or_else(|e| {
eprintln!("error: config: {}", e);
std::process::exit(1);
});
let cfg = match config.service.get(name) {
Some(c) => c,
None => {
eprintln!("error: service '{}' not found in config", name);
std::process::exit(1);
}
};
supervisor::run_supervisor(name, cfg);
return;
}
let config_path = resolve_config_path(cli.config.as_deref());
let config = Config::load(&config_path).unwrap_or_else(|e| {
eprintln!("error: config: {}", e);
std::process::exit(1);
});
let state = State::new();
match cli.command {
Commands::Start { name } => cmd_start(&name, &config, &config_path),
Commands::Stop { name } => cmd_stop(&name, &config, &state),
Commands::Restart { name } => {
cmd_stop(&name, &config, &state);
cmd_start(&name, &config, &config_path);
}
Commands::Status { name } => cmd_status(name.as_deref(), &config, &state),
Commands::Logs { name, lines, follow } => cmd_logs(&name, lines, follow),
Commands::Enable { name, now } => cmd_enable(&name, now, &config, &state, &config_path),
Commands::Disable { name } => cmd_disable(&name, &state),
Commands::List => cmd_list(&config, &state),
Commands::Daemon => cmd_daemon(&config, &state, &config_path),
Commands::Supervise { .. } => unreachable!(),
}
}
fn resolve_config_path(custom: Option<&str>) -> String {
if let Some(path) = custom {
return path.to_string();
}
if let Ok(path) = std::env::var("RSS_CONFIG") {
return path;
}
let home = std::env::var("HOME").expect("HOME not set");
format!("{}/.config/rss/config.toml", home)
}
fn cmd_start(name: &str, config: &Config, config_path: &str) {
if !config.service.contains_key(name) {
eprintln!("error: service '{}' not found in config", name);
std::process::exit(1);
}
let self_exe = std::env::current_exe().unwrap_or_else(|_| {
eprintln!("error: cannot determine binary path");
std::process::exit(1);
});
let child = Command::new(&self_exe)
.arg("supervise")
.arg(name)
.env("RSS_CONFIG", config_path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
.unwrap_or_else(|e| {
eprintln!("error: failed to start supervisor: {}", e);
std::process::exit(1);
});
println!("{}", child.id());
// Detach — forget the child so it becomes orphaned and adopted by init
std::mem::forget(child);
}
fn cmd_stop(name: &str, config: &Config, state: &State) {
let cfg = match config.service.get(name) {
Some(c) => c,
None => {
eprintln!("error: service '{}' not found", name);
return;
}
};
let svc_pid_path = state.service_pid_path(name);
let sup_pid_path = state.supervise_pid_path(name);
let log_path = state.log_path(name);
let log_str = log_path.to_str().unwrap_or("");
// Tell supervisor to stop (sets TERMINATE flag) first
if let Some(pid) = State::read_pid(&sup_pid_path) {
if process_running(pid) {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(pid as i32),
nix::sys::signal::Signal::SIGTERM,
);
}
}
// Stop child process
if let Some(pid) = State::read_pid(&svc_pid_path) {
if process_running(pid) {
// Run stop_command if configured
if let Some(stop_cmd) = cfg.resolve_stop_command(name, pid, log_str) {
let _ = Command::new("sh").arg("-c").arg(&stop_cmd).current_dir(&cfg.workdir).output();
}
// Send configured signal
let sig = parse_signal(&cfg.stop_signal);
let _ = nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), sig);
// Wait for graceful exit
wait_for_exit(pid, cfg.timeout_stop);
// Force kill if still alive
if process_running(pid) {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(pid as i32),
nix::sys::signal::Signal::SIGKILL,
);
wait_for_exit(pid, 3);
}
}
}
// Wait for supervisor to exit (it sees TERMINATE + child dead)
if let Some(pid) = State::read_pid(&sup_pid_path) {
if process_running(pid) {
wait_for_exit(pid, 3);
if process_running(pid) {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(pid as i32),
nix::sys::signal::Signal::SIGKILL,
);
wait_for_exit(pid, 3);
}
}
}
// Cleanup PID files
State::remove_pid(&svc_pid_path);
State::remove_pid(&sup_pid_path);
}
fn cmd_status(name: Option<&str>, config: &Config, state: &State) {
if let Some(name) = name {
print_service_status(name, config, state);
} else {
let mut names: Vec<&String> = config.service.keys().collect();
names.sort();
for name in names {
print_service_status(name, config, state);
}
}
}
fn print_service_status(name: &str, _config: &Config, state: &State) {
let svc_pid_path = state.service_pid_path(name);
let sup_pid_path = state.supervise_pid_path(name);
let enabled = state.is_enabled(name);
let svc_pid = State::read_pid(&svc_pid_path);
let sup_pid = State::read_pid(&sup_pid_path);
let svc_running = svc_pid.map_or(false, process_running);
let sup_running = sup_pid.map_or(false, process_running);
let status = if svc_running {
"running"
} else if sup_running {
"restarting"
} else {
"stopped"
};
let enabled_str = if enabled { "+" } else { "-" };
println!(
"{} {} pid={} supervisor={} enabled={}",
status,
name,
svc_pid.map_or(0, |p| p),
sup_pid.map_or(0, |p| p),
enabled_str,
);
}
fn cmd_logs(name: &str, mut lines: Option<usize>, follow: bool) {
if lines.is_none() && follow {
lines = Some(10);
}
let state = State::new();
let log_path = state.log_path(name);
let log_str = log_path.to_str().unwrap_or("");
if !log_path.exists() {
eprintln!("error: no logs for '{}'", name);
std::process::exit(1);
}
if let Err(e) = log::show_logs(log_str, lines, follow) {
eprintln!("error: reading logs: {}", e);
std::process::exit(1);
}
}
fn cmd_enable(name: &str, now: bool, config: &Config, state: &State, config_path: &str) {
if !config.service.contains_key(name) {
eprintln!("error: service '{}' not found in config", name);
return;
}
if let Err(e) = state.enable(name) {
eprintln!("error: enabling '{}': {}", name, e);
return;
}
println!("enabled {}", name);
if now {
cmd_start(name, config, config_path);
}
}
fn cmd_disable(name: &str, state: &State) {
state.disable(name);
println!("disabled {}", name);
}
fn cmd_list(config: &Config, state: &State) {
let mut names: Vec<&String> = config.service.keys().collect();
names.sort();
if names.is_empty() {
println!("no services configured");
return;
}
println!("{:<12} {:<7} {:>6} {:>6} {}", "status", "name", "pid", "super", "enabled");
println!("{}", "-".repeat(50));
for name in names {
let svc_pid_path = state.service_pid_path(name);
let sup_pid_path = state.supervise_pid_path(name);
let enabled = state.is_enabled(name);
let svc_pid = State::read_pid(&svc_pid_path);
let sup_pid = State::read_pid(&sup_pid_path);
let svc_running = svc_pid.map_or(false, process_running);
let sup_running = sup_pid.map_or(false, process_running);
let status = if svc_running {
"running"
} else if sup_running {
"restart"
} else {
"stopped"
};
let enabled_str = if enabled { "yes" } else { "no" };
println!(
"{:<12} {:<7} {:>6} {:>6} {}",
status,
name,
svc_pid.map_or(0, |p| p),
sup_pid.map_or(0, |p| p),
enabled_str,
);
}
}
fn cmd_daemon(config: &Config, state: &State, config_path: &str) {
let enabled = state.list_enabled().unwrap_or_default();
if enabled.is_empty() {
println!("no enabled services");
return;
}
for name in &enabled {
if !config.service.contains_key(name) {
continue;
}
let svc_pid_path = state.service_pid_path(name);
let already = State::read_pid(&svc_pid_path).map_or(false, |p| process_running(p));
if already {
continue;
}
println!("starting {}...", name);
cmd_start(name, config, config_path);
}
}

89
src/state.rs Normal file
View file

@ -0,0 +1,89 @@
use std::path::PathBuf;
pub struct State {
state_dir: PathBuf,
config_dir: PathBuf,
}
impl State {
pub fn new() -> Self {
let home = std::env::var("HOME").expect("HOME not set");
State {
state_dir: PathBuf::from(format!("{}/.local/share/rss", home)),
config_dir: PathBuf::from(format!("{}/.config/rss", home)),
}
}
pub fn supervise_pid_path(&self, name: &str) -> PathBuf {
self.state_dir.join("supervise").join(format!("{}.pid", name))
}
pub fn service_pid_path(&self, name: &str) -> PathBuf {
self.state_dir.join("service").join(format!("{}.pid", name))
}
pub fn log_path(&self, name: &str) -> PathBuf {
self.state_dir.join("logs").join(format!("{}.log", name))
}
pub fn enabled_path(&self, name: &str) -> PathBuf {
self.config_dir.join("enabled").join(name)
}
pub fn write_pid(path: &PathBuf) -> std::io::Result<()> {
Self::write_pid_with(path, std::process::id())
}
pub fn write_pid_with(path: &PathBuf, pid: u32) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, format!("{}", pid))
}
pub fn read_pid(path: &PathBuf) -> Option<u32> {
let content = std::fs::read_to_string(path).ok()?;
content.trim().parse().ok()
}
pub fn remove_pid(path: &PathBuf) {
let _ = std::fs::remove_file(path);
}
pub fn process_exists(pid: u32) -> bool {
std::path::Path::new(&format!("/proc/{}", pid)).exists()
}
pub fn is_enabled(&self, name: &str) -> bool {
self.enabled_path(name).exists()
}
pub fn enable(&self, name: &str) -> std::io::Result<()> {
let path = self.enabled_path(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, "")?;
Ok(())
}
pub fn disable(&self, name: &str) {
let _ = std::fs::remove_file(self.enabled_path(name));
}
pub fn list_enabled(&self) -> std::io::Result<Vec<String>> {
let dir = self.config_dir.join("enabled");
if !dir.exists() {
return Ok(Vec::new());
}
let mut names = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
names.sort();
Ok(names)
}
}

196
src/supervisor.rs Normal file
View file

@ -0,0 +1,196 @@
use std::io::{BufRead, Write};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use chrono::Local;
use nix::sys::signal::{self, SigAction, SigHandler, Signal};
use crate::config::{RestartPolicy, ServiceConfig};
use crate::state::State;
pub static TERMINATE: AtomicBool = AtomicBool::new(false);
extern "C" fn sigterm_handler(_: i32) {
TERMINATE.store(true, Ordering::Relaxed);
}
pub fn setup_signal_handler() {
let handler = SigHandler::Handler(sigterm_handler);
let action = SigAction::new(handler, nix::sys::signal::SaFlags::SA_RESTART, signal::SigSet::empty());
unsafe {
let _ = signal::sigaction(Signal::SIGTERM, &action);
let _ = signal::sigaction(Signal::SIGINT, &action);
}
}
pub fn run_supervisor(name: &str, cfg: &ServiceConfig) {
setup_signal_handler();
let state = State::new();
let supervise_pid = state.supervise_pid_path(name);
let service_pid = state.service_pid_path(name);
let log_path = state.log_path(name);
State::write_pid(&supervise_pid).ok();
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let mut restarts: u32 = 0;
let max_restarts = if cfg.restart_max == 0 { u32::MAX } else { cfg.restart_max };
loop {
let cmd = cfg.resolve_command(name, log_path.to_str().unwrap_or(""));
let log_file = log_path.to_str().unwrap_or("").to_string();
let mut child = match spawn_child(name, cfg, &cmd, &log_file) {
Ok(c) => c,
Err(e) => {
append_log(&log_file, &format!("E: failed to spawn: {}", e));
break;
}
};
let child_pid = child.id();
State::write_pid_with(&service_pid, child_pid).ok();
append_log(&log_file, &format!("O: started (pid {})", child_pid));
let status = child.wait();
match status {
Ok(status) => {
let code = status.code().map(|c| format!("code {}", c)).unwrap_or_else(|| "signal".to_string());
append_log(&log_file, &format!("O: exited with {}", code));
if TERMINATE.load(Ordering::Relaxed) {
append_log(&log_file, "O: supervisor received stop signal, exiting");
break;
}
if cfg.oneshot {
append_log(&log_file, "O: oneshot service completed, exiting");
break;
}
let should_restart = match cfg.restart {
RestartPolicy::No => false,
RestartPolicy::Always => true,
RestartPolicy::OnFailure => !status.success(),
};
if should_restart && restarts < max_restarts {
restarts += 1;
let max_str = if cfg.restart_max == 0 {
"unlimited".to_string()
} else {
cfg.restart_max.to_string()
};
append_log(
&log_file,
&format!("O: restarting in {}s (attempt {}/{})", cfg.restart_delay, restarts, max_str),
);
std::thread::sleep(Duration::from_secs(cfg.restart_delay));
} else {
if cfg.restart != RestartPolicy::No {
append_log(&log_file, "O: max restarts reached, exiting");
}
break;
}
}
Err(e) => {
append_log(&log_file, &format!("E: wait error: {}", e));
break;
}
}
}
State::remove_pid(&service_pid);
State::remove_pid(&supervise_pid);
// Give pipe-reader threads time to flush
std::thread::sleep(Duration::from_millis(200));
}
fn spawn_child(
_name: &str,
cfg: &ServiceConfig,
cmd: &str,
log_file: &str,
) -> std::io::Result<Child> {
let mut child = Command::new("sh")
.arg("-c")
.arg(cmd)
.current_dir(&cfg.workdir)
.envs(&cfg.env)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.spawn()?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let log_out = log_file.to_string();
std::thread::spawn(move || {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
if let Ok(line) = line {
append_log(&log_out, &format!("O: {}", line));
}
}
});
let log_err = log_file.to_string();
std::thread::spawn(move || {
let reader = std::io::BufReader::new(stderr);
for line in reader.lines() {
if let Ok(line) = line {
append_log(&log_err, &format!("E: {}", line));
}
}
});
Ok(child)
}
fn append_log(path: &str, msg: &str) {
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let ts = Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(file, "[{}] {}", ts, msg);
let _ = file.flush();
}
}
pub fn parse_signal(name: &str) -> Signal {
match name.to_uppercase().as_str() {
"TERM" => Signal::SIGTERM,
"INT" => Signal::SIGINT,
"QUIT" => Signal::SIGQUIT,
"KILL" => Signal::SIGKILL,
"HUP" => Signal::SIGHUP,
"USR1" => Signal::SIGUSR1,
"USR2" => Signal::SIGUSR2,
_ => Signal::SIGTERM,
}
}
pub fn process_running(pid: u32) -> bool {
State::process_exists(pid)
}
pub fn wait_for_exit(pid: u32, timeout_secs: u64) {
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
if !process_running(pid) {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
}