Compare commits
35
Commits
40ffd9b06c
...
29df988d9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29df988d9d | ||
|
|
3dcda14ba5 | ||
|
|
44304d0625 | ||
|
|
938cbcbc08 | ||
|
|
516d7d0b86 | ||
|
|
a533da8e80 | ||
|
|
e556335102 | ||
|
|
c7a26332e8 | ||
|
|
2b84f1d1e0 | ||
|
|
9307af46a8 | ||
|
|
4c34e336a2 | ||
|
|
7631eefc67 | ||
|
|
dbf600c1f5 | ||
|
|
fbb58ebfef | ||
|
|
c000a00f05 | ||
|
|
73e17d51f3 | ||
|
|
ba04cb1ec9 | ||
|
|
0c8ae3b617 | ||
|
|
d75d57ff0b | ||
|
|
ecff75d514 | ||
|
|
7bb80803fe | ||
|
|
fd842289e3 | ||
|
|
41acc3e21a | ||
|
|
1d73057444 | ||
|
|
f48221b09c | ||
|
|
999134657c | ||
|
|
f267efc967 | ||
|
|
ae37060160 | ||
|
|
b69426f4df | ||
|
|
f70092f1a0 | ||
|
|
2ab74c992b | ||
|
|
f74eb1e865 | ||
|
|
dff798a9be | ||
|
|
5b088f119c | ||
|
|
3a434657bc |
Generated
+275
-7
@@ -84,6 +84,12 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -148,12 +154,73 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codepage"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "4.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"redox_users",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding-utils"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87b881ab2524b96a5ce932056c7482ba6152e2226fed3936b3e592adeb95ca6d"
|
||||||
|
dependencies = [
|
||||||
|
"codepage",
|
||||||
|
"encoding_rs",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -199,6 +266,17 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -317,6 +395,21 @@ version = "0.2.186"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -419,6 +512,12 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@@ -477,6 +576,25 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plist"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"indexmap",
|
||||||
|
"quick-xml",
|
||||||
|
"serde",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -496,6 +614,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -511,6 +638,17 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -528,6 +666,19 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys 0.4.15",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -537,7 +688,7 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -590,6 +741,22 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "service-manager"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ff6975a4ea07dda326cf122fcc1b5cf6266daad42d1442a666a99fe50f0de9"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"dirs",
|
||||||
|
"encoding-utils",
|
||||||
|
"encoding_rs",
|
||||||
|
"log",
|
||||||
|
"plist",
|
||||||
|
"which",
|
||||||
|
"xml-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -649,19 +816,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -684,6 +871,36 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.54"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.3"
|
version = "1.52.3"
|
||||||
@@ -882,12 +1099,55 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "which"
|
||||||
|
version = "4.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"home",
|
||||||
|
"once_cell",
|
||||||
|
"rustix 0.38.44",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -1073,6 +1333,12 @@ dependencies = [
|
|||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xml-rs"
|
||||||
|
version = "0.8.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xy"
|
name = "xy"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1082,8 +1348,10 @@ dependencies = [
|
|||||||
"etcetera",
|
"etcetera",
|
||||||
"humantime",
|
"humantime",
|
||||||
"nix",
|
"nix",
|
||||||
|
"plist",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"service-manager",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -1100,7 +1368,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"xy-protocol",
|
"xy-protocol",
|
||||||
@@ -1116,7 +1384,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1126,7 +1394,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"nix",
|
"nix",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ humantime-serde = "1"
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
service-manager = "0.11"
|
||||||
|
plist = "1"
|
||||||
|
|||||||
@@ -24,3 +24,47 @@ Commands:
|
|||||||
xy logs <name> [--tail N] [--follow]
|
xy logs <name> [--tail N] [--follow]
|
||||||
|
|
||||||
Exit codes: 0 success, 1 operational error, 2 daemon unreachable, 3 config invalid.
|
Exit codes: 0 success, 1 operational error, 2 daemon unreachable, 3 config invalid.
|
||||||
|
|
||||||
|
## Waiting on a dependency
|
||||||
|
|
||||||
|
A server can declare a precondition that must hold before it is started:
|
||||||
|
|
||||||
|
wait-for {
|
||||||
|
path "~/.orbstack/run/docker.sock"
|
||||||
|
timeout "180s"
|
||||||
|
interval "1s"
|
||||||
|
}
|
||||||
|
|
||||||
|
Exactly one condition per block — `path "<p>"` (the path exists),
|
||||||
|
`tcp "<host:port>"` (a connection succeeds), or `command "<c>"` with optional
|
||||||
|
`args` (the process exits 0). `timeout` defaults to 120s and `interval` to 1s.
|
||||||
|
|
||||||
|
While waiting the server reports state `waiting`, and `xy status <name>` shows
|
||||||
|
which condition it is blocked on and for how long. If the timeout expires the
|
||||||
|
server is marked `failed` and is never spawned.
|
||||||
|
|
||||||
|
Waiting does **not** consume the restart budget: a slow dependency costs
|
||||||
|
patience, not retries. This is what stops a Docker-backed server from being
|
||||||
|
marked failed at login while the Docker daemon is still starting.
|
||||||
|
|
||||||
|
## Start on login (macOS)
|
||||||
|
|
||||||
|
xy service install # write the LaunchAgent, load it, start the daemon
|
||||||
|
xy service uninstall # unload and remove the agent
|
||||||
|
xy service start # load an installed agent
|
||||||
|
xy service stop # unload until the next login
|
||||||
|
xy service status # show agent state
|
||||||
|
|
||||||
|
`xy service install` snapshots the current `PATH` into the agent, because a
|
||||||
|
launchd agent otherwise inherits only `/usr/bin:/bin:/usr/sbin:/sbin` and
|
||||||
|
supervised servers would not find their toolchains. Re-run with `--force`
|
||||||
|
after installing a new toolchain to refresh the snapshot.
|
||||||
|
|
||||||
|
`xy service stop` lasts until the next login. To disable start-on-login
|
||||||
|
permanently, use `xy service uninstall`.
|
||||||
|
|
||||||
|
The daemon writes to `$XDG_STATE_HOME/xy/logs/daemon.log`. Failures that happen
|
||||||
|
before the daemon starts logging — a missing binary, a malformed plist — are
|
||||||
|
visible only to launchd:
|
||||||
|
|
||||||
|
launchctl print gui/$UID/se.aceofba.xy
|
||||||
|
|||||||
@@ -81,6 +81,33 @@ pub struct ServerConfig {
|
|||||||
pub restart: RestartConfig,
|
pub restart: RestartConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stop: StopConfig,
|
pub stop: StopConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wait_for: Option<WaitForConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum WaitCondition {
|
||||||
|
Path(PathBuf),
|
||||||
|
Tcp(String),
|
||||||
|
Command { command: PathBuf, args: Vec<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WaitForConfig {
|
||||||
|
pub condition: WaitCondition,
|
||||||
|
#[serde(default = "default_wait_timeout", with = "humantime_serde")]
|
||||||
|
pub timeout: Duration,
|
||||||
|
#[serde(default = "default_wait_interval", with = "humantime_serde")]
|
||||||
|
pub interval: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_wait_timeout() -> Duration {
|
||||||
|
Duration::from_secs(120)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_wait_interval() -> Duration {
|
||||||
|
Duration::from_secs(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use crate::{ConfigError, RestartConfig, RestartPolicy, ServerConfig, StopConfig};
|
use crate::{
|
||||||
|
ConfigError, RestartConfig, RestartPolicy, ServerConfig, StopConfig, WaitCondition,
|
||||||
|
WaitForConfig, default_wait_interval, default_wait_timeout,
|
||||||
|
};
|
||||||
use kdl::{KdlDocument, KdlNode};
|
use kdl::{KdlDocument, KdlNode};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -27,6 +30,7 @@ pub fn parse_server_config(
|
|||||||
let working_dir = optional_string_arg(&doc, "working-dir").map(PathBuf::from);
|
let working_dir = optional_string_arg(&doc, "working-dir").map(PathBuf::from);
|
||||||
let restart = parse_restart(&doc, source_path)?;
|
let restart = parse_restart(&doc, source_path)?;
|
||||||
let stop = parse_stop(&doc, source_path)?;
|
let stop = parse_stop(&doc, source_path)?;
|
||||||
|
let wait_for = parse_wait_for(&doc, source_path)?;
|
||||||
|
|
||||||
Ok(ServerConfig {
|
Ok(ServerConfig {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
@@ -37,6 +41,7 @@ pub fn parse_server_config(
|
|||||||
working_dir,
|
working_dir,
|
||||||
restart,
|
restart,
|
||||||
stop,
|
stop,
|
||||||
|
wait_for,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +154,8 @@ fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, Config
|
|||||||
for child in children.nodes() {
|
for child in children.nodes() {
|
||||||
match child.name().value() {
|
match child.name().value() {
|
||||||
"policy" => {
|
"policy" => {
|
||||||
|
single_arg(child, "restart", path)?;
|
||||||
|
|
||||||
let s = string_arg(child, "policy", path)?;
|
let s = string_arg(child, "policy", path)?;
|
||||||
|
|
||||||
out.policy = match s.as_str() {
|
out.policy = match s.as_str() {
|
||||||
@@ -165,12 +172,18 @@ fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, Config
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
"backoff-initial" => {
|
"backoff-initial" => {
|
||||||
|
single_arg(child, "restart", path)?;
|
||||||
|
|
||||||
out.backoff_initial = parse_duration_arg(child, "restart.backoff-initial", path)?;
|
out.backoff_initial = parse_duration_arg(child, "restart.backoff-initial", path)?;
|
||||||
}
|
}
|
||||||
"backoff-max" => {
|
"backoff-max" => {
|
||||||
|
single_arg(child, "restart", path)?;
|
||||||
|
|
||||||
out.backoff_max = parse_duration_arg(child, "restart.backoff-max", path)?;
|
out.backoff_max = parse_duration_arg(child, "restart.backoff-max", path)?;
|
||||||
}
|
}
|
||||||
"max-retries-per-minute" => {
|
"max-retries-per-minute" => {
|
||||||
|
single_arg(child, "restart", path)?;
|
||||||
|
|
||||||
let v = child
|
let v = child
|
||||||
.entries()
|
.entries()
|
||||||
.first()
|
.first()
|
||||||
@@ -215,6 +228,8 @@ fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError>
|
|||||||
for child in children.nodes() {
|
for child in children.nodes() {
|
||||||
match child.name().value() {
|
match child.name().value() {
|
||||||
"grace" => {
|
"grace" => {
|
||||||
|
single_arg(child, "stop", path)?;
|
||||||
|
|
||||||
out.grace = parse_duration_arg(child, "stop.grace", path)?;
|
out.grace = parse_duration_arg(child, "stop.grace", path)?;
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
@@ -230,6 +245,135 @@ fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError>
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn expand_tilde(raw: &str, home: Option<&Path>) -> PathBuf {
|
||||||
|
let Some(rest) = raw.strip_prefix("~/") else {
|
||||||
|
return PathBuf::from(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
match home {
|
||||||
|
Some(home) => home.join(rest),
|
||||||
|
None => PathBuf::from(raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_wait_for(doc: &KdlDocument, path: &Path) -> Result<Option<WaitForConfig>, ConfigError> {
|
||||||
|
let Some(node) = find_node(doc, "wait-for") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let invalid = |message: String| ConfigError::InvalidValue {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
field: "wait-for",
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(children) = node.children() else {
|
||||||
|
return Err(invalid(
|
||||||
|
"expected a block with exactly one condition".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||||
|
|
||||||
|
let mut conditions: Vec<WaitCondition> = Vec::new();
|
||||||
|
let mut command: Option<PathBuf> = None;
|
||||||
|
let mut args: Option<Vec<String>> = None;
|
||||||
|
let mut timeout = default_wait_timeout();
|
||||||
|
let mut interval = default_wait_interval();
|
||||||
|
|
||||||
|
for child in children.nodes() {
|
||||||
|
match child.name().value() {
|
||||||
|
"path" => {
|
||||||
|
single_arg(child, "wait-for", path)?;
|
||||||
|
|
||||||
|
let raw = string_arg(child, "wait-for", path)?;
|
||||||
|
|
||||||
|
conditions.push(WaitCondition::Path(expand_tilde(&raw, home.as_deref())));
|
||||||
|
}
|
||||||
|
"tcp" => {
|
||||||
|
single_arg(child, "wait-for", path)?;
|
||||||
|
|
||||||
|
let addr = string_arg(child, "wait-for", path)?;
|
||||||
|
|
||||||
|
conditions.push(WaitCondition::Tcp(addr));
|
||||||
|
}
|
||||||
|
"command" => {
|
||||||
|
single_arg(child, "wait-for", path)?;
|
||||||
|
|
||||||
|
if command.is_some() {
|
||||||
|
return Err(invalid("duplicate key `command`".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
command = Some(PathBuf::from(string_arg(child, "wait-for", path)?));
|
||||||
|
}
|
||||||
|
"args" => {
|
||||||
|
if args.is_some() {
|
||||||
|
return Err(invalid("duplicate key `args`".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
args = Some(
|
||||||
|
child
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.value().as_string().map(str::to_string))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"timeout" => {
|
||||||
|
single_arg(child, "wait-for", path)?;
|
||||||
|
|
||||||
|
timeout = parse_duration_arg(child, "wait-for", path)?;
|
||||||
|
}
|
||||||
|
"interval" => {
|
||||||
|
single_arg(child, "wait-for", path)?;
|
||||||
|
|
||||||
|
interval = parse_duration_arg(child, "wait-for", path)?;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(invalid(format!("unknown key `{other}`")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(command) = command {
|
||||||
|
conditions.push(WaitCondition::Command {
|
||||||
|
command,
|
||||||
|
args: args.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
} else if args.is_some() {
|
||||||
|
return Err(invalid("`args` requires `command`".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
match conditions.len() {
|
||||||
|
1 => Ok(Some(WaitForConfig {
|
||||||
|
condition: conditions.remove(0),
|
||||||
|
timeout,
|
||||||
|
interval,
|
||||||
|
})),
|
||||||
|
0 => Err(invalid(
|
||||||
|
"expected exactly one of `path`, `tcp` or `command`".into(),
|
||||||
|
)),
|
||||||
|
n => Err(invalid(format!(
|
||||||
|
"expected exactly one condition, found {n}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn single_arg(node: &KdlNode, field: &'static str, path: &Path) -> Result<(), ConfigError> {
|
||||||
|
if node.entries().len() == 1 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ConfigError::InvalidValue {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
field,
|
||||||
|
message: format!(
|
||||||
|
"unexpected extra arguments for key `{}`; put each key on its own line",
|
||||||
|
node.name().value()
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn string_arg(node: &KdlNode, field: &'static str, path: &Path) -> Result<String, ConfigError> {
|
fn string_arg(node: &KdlNode, field: &'static str, path: &Path) -> Result<String, ConfigError> {
|
||||||
node.entries()
|
node.entries()
|
||||||
.first()
|
.first()
|
||||||
@@ -444,4 +588,226 @@ stop {
|
|||||||
other => panic!("unexpected error: {other:?}"),
|
other => panic!("unexpected error: {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_path_condition_with_defaults() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/var/run/d.sock\" }";
|
||||||
|
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||||
|
|
||||||
|
let wait = cfg.wait_for.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
wait.condition,
|
||||||
|
WaitCondition::Path(PathBuf::from("/var/run/d.sock"))
|
||||||
|
);
|
||||||
|
assert_eq!(wait.timeout, Duration::from_secs(120));
|
||||||
|
assert_eq!(wait.interval, Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_a_leading_tilde_against_the_given_home() {
|
||||||
|
let home = PathBuf::from("/home/someone");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
expand_tilde("~/.orbstack/run/docker.sock", Some(&home)),
|
||||||
|
PathBuf::from("/home/someone/.orbstack/run/docker.sock")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_non_tilde_paths_and_unknown_home_alone() {
|
||||||
|
let home = PathBuf::from("/home/someone");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
expand_tilde("/var/run/d.sock", Some(&home)),
|
||||||
|
PathBuf::from("/var/run/d.sock")
|
||||||
|
);
|
||||||
|
assert_eq!(expand_tilde("~/a", None), PathBuf::from("~/a"));
|
||||||
|
assert_eq!(
|
||||||
|
expand_tilde("~weird/a", Some(&home)),
|
||||||
|
PathBuf::from("~weird/a")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_tcp_condition_with_overrides() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nwait-for { tcp \"127.0.0.1:5432\"\ntimeout \"30s\"\ninterval \"250ms\" }";
|
||||||
|
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||||
|
|
||||||
|
let wait = cfg.wait_for.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(wait.condition, WaitCondition::Tcp("127.0.0.1:5432".into()));
|
||||||
|
assert_eq!(wait.timeout, Duration::from_secs(30));
|
||||||
|
assert_eq!(wait.interval, Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_command_condition_from_sibling_nodes() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nwait-for { command \"docker\"\nargs \"info\" }";
|
||||||
|
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cfg.wait_for.unwrap().condition,
|
||||||
|
WaitCondition::Command {
|
||||||
|
command: PathBuf::from("docker"),
|
||||||
|
args: vec!["info".to_string()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_wait_for_leaves_no_gate() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1";
|
||||||
|
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||||
|
|
||||||
|
assert!(cfg.wait_for.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wait_for_without_a_condition_fails() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nwait-for { timeout \"5s\" }";
|
||||||
|
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ConfigError::InvalidValue {
|
||||||
|
field: "wait-for",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wait_for_with_two_conditions_fails() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/a\"\ntcp \"127.0.0.1:1\" }";
|
||||||
|
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ConfigError::InvalidValue {
|
||||||
|
field: "wait-for",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wait_for_args_without_command_fails() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nwait-for { args \"info\" }";
|
||||||
|
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ConfigError::InvalidValue {
|
||||||
|
field: "wait-for",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_wait_for_key_fails() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/a\"\nnope \"x\" }";
|
||||||
|
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ConfigError::InvalidValue {
|
||||||
|
field: "wait-for",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_command_key_fails() {
|
||||||
|
let text =
|
||||||
|
"command \"/bin/x\"\nport 1\nwait-for { command \"/bin/a\"\ncommand \"/bin/b\" }";
|
||||||
|
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ConfigError::InvalidValue {
|
||||||
|
field: "wait-for",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_args_key_fails() {
|
||||||
|
let text =
|
||||||
|
"command \"/bin/x\"\nport 1\nwait-for { command \"docker\"\nargs \"a\"\nargs \"b\" }";
|
||||||
|
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ConfigError::InvalidValue {
|
||||||
|
field: "wait-for",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_tilde_alone_is_left_unchanged() {
|
||||||
|
let home = PathBuf::from("/home/someone");
|
||||||
|
|
||||||
|
assert_eq!(expand_tilde("~", Some(&home)), PathBuf::from("~"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_tilde_of_empty_string_is_left_unchanged() {
|
||||||
|
let home = PathBuf::from("/home/someone");
|
||||||
|
|
||||||
|
assert_eq!(expand_tilde("", Some(&home)), PathBuf::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_line_restart_block_with_two_keys_fails() {
|
||||||
|
let text =
|
||||||
|
"command \"/bin/x\"\nport 1\nrestart { policy \"always\" backoff-initial \"10ms\" }";
|
||||||
|
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ConfigError::InvalidValue {
|
||||||
|
field: "restart",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_line_wait_for_block_with_two_keys_fails() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/x\" timeout \"5s\" }";
|
||||||
|
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
ConfigError::InvalidValue {
|
||||||
|
field: "wait-for",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_line_single_key_stop_block_still_parses() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nstop { grace \"30s\" }";
|
||||||
|
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(cfg.stop.grace, Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_line_single_key_wait_for_block_still_parses() {
|
||||||
|
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/x\" }";
|
||||||
|
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cfg.wait_for.unwrap().condition,
|
||||||
|
WaitCondition::Path(PathBuf::from("/x"))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ pub mod kdl_parse;
|
|||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig};
|
pub use config::{
|
||||||
|
RestartConfig, RestartPolicy, ServerConfig, StopConfig, WaitCondition, WaitForConfig,
|
||||||
|
default_wait_interval, default_wait_timeout,
|
||||||
|
};
|
||||||
pub use error::{ConfigError, RpcErrorCode};
|
pub use error::{ConfigError, RpcErrorCode};
|
||||||
pub use kdl_parse::{load_all_configs, parse_server_config};
|
pub use kdl_parse::{load_all_configs, parse_server_config};
|
||||||
pub use state::ServerState;
|
pub use state::ServerState;
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ pub struct StatusDetail {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub summary: ServerSummary,
|
pub summary: ServerSummary,
|
||||||
pub recent_transitions: Vec<StateTransition>,
|
pub recent_transitions: Vec<StateTransition>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wait_for: Option<WaitInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WaitInfo {
|
||||||
|
pub description: String,
|
||||||
|
pub elapsed_secs: u64,
|
||||||
|
pub timeout_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum ServerState {
|
pub enum ServerState {
|
||||||
Stopped,
|
Stopped,
|
||||||
|
Waiting,
|
||||||
Starting,
|
Starting,
|
||||||
Running,
|
Running,
|
||||||
Restarting,
|
Restarting,
|
||||||
@@ -28,4 +29,15 @@ mod tests {
|
|||||||
let s: ServerState = serde_json::from_str("\"failed\"").unwrap();
|
let s: ServerState = serde_json::from_str("\"failed\"").unwrap();
|
||||||
assert_eq!(s, ServerState::Failed);
|
assert_eq!(s, ServerState::Failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waiting_state_round_trips_as_json() {
|
||||||
|
let json = serde_json::to_string(&ServerState::Waiting).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json, "\"waiting\"");
|
||||||
|
|
||||||
|
let back: ServerState = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(back, ServerState::Waiting);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod backoff;
|
|||||||
pub mod child;
|
pub mod child;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
|
mod ready;
|
||||||
pub mod retry_window;
|
pub mod retry_window;
|
||||||
pub mod supervisor;
|
pub mod supervisor;
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,24 @@ impl RotatingLogWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::io::Write for RotatingLogWriter {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.file.write_all(buf)?;
|
||||||
|
|
||||||
|
self.written += buf.len() as u64;
|
||||||
|
|
||||||
|
if self.written >= self.max_bytes {
|
||||||
|
self.rotate()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
self.file.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RingBuffer {
|
pub struct RingBuffer {
|
||||||
inner: Arc<Mutex<RingBufferInner>>,
|
inner: Arc<Mutex<RingBufferInner>>,
|
||||||
@@ -260,4 +278,39 @@ mod tests {
|
|||||||
assert_eq!(got.stream, LogStream::Stdout);
|
assert_eq!(got.stream, LogStream::Stdout);
|
||||||
assert_eq!(sink.ring.snapshot_tail(None).len(), 1);
|
assert_eq!(sink.ring.snapshot_tail(None).len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_trait_appends_bytes() {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let base = tmp.path().join("daemon.log");
|
||||||
|
|
||||||
|
let mut writer = RotatingLogWriter::open(&base, 1024, 3).unwrap();
|
||||||
|
|
||||||
|
writer.write_all(b"hello\n").unwrap();
|
||||||
|
writer.flush().unwrap();
|
||||||
|
|
||||||
|
let contents = std::fs::read_to_string(&base).unwrap();
|
||||||
|
assert_eq!(contents, "hello\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_trait_rotates_at_threshold() {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let base = tmp.path().join("daemon.log");
|
||||||
|
|
||||||
|
let mut writer = RotatingLogWriter::open(&base, 8, 3).unwrap();
|
||||||
|
|
||||||
|
writer.write_all(b"0123456789").unwrap();
|
||||||
|
writer.write_all(b"after\n").unwrap();
|
||||||
|
writer.flush().unwrap();
|
||||||
|
|
||||||
|
let rotated = tmp.path().join("daemon.log.1");
|
||||||
|
assert!(rotated.exists());
|
||||||
|
assert_eq!(std::fs::read_to_string(&rotated).unwrap(), "0123456789");
|
||||||
|
assert_eq!(std::fs::read_to_string(&base).unwrap(), "after\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::debug;
|
||||||
|
use xy_protocol::WaitCondition;
|
||||||
|
|
||||||
|
pub(crate) async fn is_ready(condition: &WaitCondition, budget: Duration) -> bool {
|
||||||
|
match condition {
|
||||||
|
WaitCondition::Path(p) => p.exists(),
|
||||||
|
WaitCondition::Tcp(addr) => {
|
||||||
|
let connect = tokio::net::TcpStream::connect(addr);
|
||||||
|
|
||||||
|
matches!(tokio::time::timeout(budget, connect).await, Ok(Ok(_)))
|
||||||
|
}
|
||||||
|
WaitCondition::Command { command, args } => {
|
||||||
|
let mut cmd = tokio::process::Command::new(command);
|
||||||
|
|
||||||
|
cmd.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
|
||||||
|
match tokio::time::timeout(budget, cmd.status()).await {
|
||||||
|
Ok(Ok(status)) => status.success(),
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
debug!(
|
||||||
|
command = %command.display(),
|
||||||
|
error = %err,
|
||||||
|
"wait-for command could not be run",
|
||||||
|
);
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn describe(condition: &WaitCondition) -> String {
|
||||||
|
match condition {
|
||||||
|
WaitCondition::Path(p) => format!("path {}", p.display()),
|
||||||
|
WaitCondition::Tcp(addr) => format!("tcp {addr}"),
|
||||||
|
WaitCondition::Command { command, args } => {
|
||||||
|
let mut out = format!("command {}", command.display());
|
||||||
|
|
||||||
|
for arg in args {
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn ms(n: u64) -> Duration {
|
||||||
|
Duration::from_millis(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_existing_path_is_ready() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let file = tmp.path().join("sock");
|
||||||
|
|
||||||
|
std::fs::write(&file, b"").unwrap();
|
||||||
|
|
||||||
|
assert!(is_ready(&WaitCondition::Path(file), ms(50)).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_missing_path_is_not_ready() {
|
||||||
|
let condition = WaitCondition::Path(PathBuf::from("/definitely/not/here.sock"));
|
||||||
|
|
||||||
|
assert!(!is_ready(&condition, ms(50)).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_listening_port_is_ready() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap().to_string();
|
||||||
|
|
||||||
|
assert!(is_ready(&WaitCondition::Tcp(addr), ms(500)).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_closed_port_is_not_ready() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap().to_string();
|
||||||
|
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
assert!(!is_ready(&WaitCondition::Tcp(addr), ms(500)).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_command_exiting_zero_is_ready() {
|
||||||
|
let condition = WaitCondition::Command {
|
||||||
|
command: PathBuf::from("/usr/bin/true"),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(is_ready(&condition, ms(500)).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_command_exiting_nonzero_is_not_ready() {
|
||||||
|
let condition = WaitCondition::Command {
|
||||||
|
command: PathBuf::from("/usr/bin/false"),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!is_ready(&condition, ms(500)).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_command_that_cannot_be_run_is_not_ready() {
|
||||||
|
let condition = WaitCondition::Command {
|
||||||
|
command: PathBuf::from("/definitely/not/here/docker"),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!is_ready(&condition, ms(500)).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_command_that_outlasts_its_budget_is_not_ready() {
|
||||||
|
let condition = WaitCondition::Command {
|
||||||
|
command: PathBuf::from("/bin/sleep"),
|
||||||
|
args: vec!["5".to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!is_ready(&condition, ms(100)).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descriptions_name_the_condition() {
|
||||||
|
assert_eq!(
|
||||||
|
describe(&WaitCondition::Path(PathBuf::from("/a/b.sock"))),
|
||||||
|
"path /a/b.sock"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
describe(&WaitCondition::Tcp("127.0.0.1:5432".into())),
|
||||||
|
"tcp 127.0.0.1:5432"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
describe(&WaitCondition::Command {
|
||||||
|
command: PathBuf::from("docker"),
|
||||||
|
args: vec!["info".to_string()],
|
||||||
|
}),
|
||||||
|
"command docker info"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ pub enum SupervisorCmd {
|
|||||||
ack: oneshot::Sender<()>,
|
ack: oneshot::Sender<()>,
|
||||||
},
|
},
|
||||||
Reconfigure {
|
Reconfigure {
|
||||||
new: ServerConfig,
|
new: Box<ServerConfig>,
|
||||||
ack: oneshot::Sender<()>,
|
ack: oneshot::Sender<()>,
|
||||||
},
|
},
|
||||||
Shutdown {
|
Shutdown {
|
||||||
@@ -43,14 +43,22 @@ pub enum StopAck {
|
|||||||
NotRunning,
|
NotRunning,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WaitProgress {
|
||||||
|
pub description: String,
|
||||||
|
pub started_at: Instant,
|
||||||
|
pub timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Status {
|
pub struct Status {
|
||||||
pub state: ServerState,
|
pub state: ServerState,
|
||||||
pub pid: Option<u32>,
|
pub pid: Option<u32>,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub uptime_secs: Option<u64>,
|
pub started_at: Option<Instant>,
|
||||||
pub restart_count: u32,
|
pub restart_count: u32,
|
||||||
pub last_exit: Option<i32>,
|
pub last_exit: Option<i32>,
|
||||||
|
pub waiting: Option<WaitProgress>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -79,6 +87,7 @@ pub struct SupervisorTask<S: Spawner> {
|
|||||||
last_exit: Option<i32>,
|
last_exit: Option<i32>,
|
||||||
started_at: Option<Instant>,
|
started_at: Option<Instant>,
|
||||||
current_pid: Option<u32>,
|
current_pid: Option<u32>,
|
||||||
|
waiting: Option<WaitProgress>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S: Spawner> SupervisorTask<S> {
|
impl<S: Spawner> SupervisorTask<S> {
|
||||||
@@ -105,19 +114,19 @@ impl<S: Spawner> SupervisorTask<S> {
|
|||||||
last_exit: None,
|
last_exit: None,
|
||||||
started_at: None,
|
started_at: None,
|
||||||
current_pid: None,
|
current_pid: None,
|
||||||
|
waiting: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_state(&mut self, s: ServerState) {
|
fn set_state(&mut self, s: ServerState) {
|
||||||
let uptime_secs = self.started_at.map(|t| t.elapsed().as_secs());
|
|
||||||
|
|
||||||
let _ = self.status_tx.send(Status {
|
let _ = self.status_tx.send(Status {
|
||||||
state: s,
|
state: s,
|
||||||
pid: self.current_pid,
|
pid: self.current_pid,
|
||||||
port: self.cfg.port,
|
port: self.cfg.port,
|
||||||
uptime_secs,
|
started_at: self.started_at,
|
||||||
restart_count: self.restart_count,
|
restart_count: self.restart_count,
|
||||||
last_exit: self.last_exit,
|
last_exit: self.last_exit,
|
||||||
|
waiting: self.waiting.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,16 +143,30 @@ impl<S: Spawner> SupervisorTask<S> {
|
|||||||
if child.is_some() {
|
if child.is_some() {
|
||||||
let _ = ack.send(StartAck::AlreadyRunning);
|
let _ = ack.send(StartAck::AlreadyRunning);
|
||||||
} else {
|
} else {
|
||||||
match self.do_start().await {
|
match self.do_start(StartCause::Initial).await {
|
||||||
Ok(c) => {
|
Ok(c) => {
|
||||||
child = Some(c);
|
child = Some(c);
|
||||||
let _ = ack.send(StartAck::Started);
|
let _ = ack.send(StartAck::Started);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(StartFailure::Spawn(err)) => {
|
||||||
warn!(name = %self.cfg.name, error = %err, "spawn failed");
|
warn!(name = %self.cfg.name, error = %err, "spawn failed");
|
||||||
|
self.started_at = None;
|
||||||
self.set_state(ServerState::Failed);
|
self.set_state(ServerState::Failed);
|
||||||
let _ = ack.send(StartAck::SpawnFailed(err.to_string()));
|
let _ = ack.send(StartAck::SpawnFailed(err.to_string()));
|
||||||
}
|
}
|
||||||
|
Err(StartFailure::WaitTimedOut) => {
|
||||||
|
warn!(name = %self.cfg.name, "wait-for timed out");
|
||||||
|
self.started_at = None;
|
||||||
|
self.set_state(ServerState::Failed);
|
||||||
|
let _ = ack.send(StartAck::SpawnFailed(
|
||||||
|
"wait-for timed out".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(StartFailure::Cancelled) => {
|
||||||
|
self.started_at = None;
|
||||||
|
self.set_state(ServerState::Stopped);
|
||||||
|
}
|
||||||
|
Err(StartFailure::Shutdown) => return,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,18 +184,29 @@ impl<S: Spawner> SupervisorTask<S> {
|
|||||||
self.do_stop(c).await;
|
self.do_stop(c).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
match self.do_start().await {
|
match self.do_start(StartCause::Restart).await {
|
||||||
Ok(c) => child = Some(c),
|
Ok(c) => child = Some(c),
|
||||||
Err(err) => {
|
Err(StartFailure::Spawn(err)) => {
|
||||||
warn!(name = %self.cfg.name, error = %err, "restart spawn failed");
|
warn!(name = %self.cfg.name, error = %err, "restart spawn failed");
|
||||||
|
self.started_at = None;
|
||||||
self.set_state(ServerState::Failed);
|
self.set_state(ServerState::Failed);
|
||||||
}
|
}
|
||||||
|
Err(StartFailure::WaitTimedOut) => {
|
||||||
|
warn!(name = %self.cfg.name, "wait-for timed out");
|
||||||
|
self.started_at = None;
|
||||||
|
self.set_state(ServerState::Failed);
|
||||||
|
}
|
||||||
|
Err(StartFailure::Cancelled) => {
|
||||||
|
self.started_at = None;
|
||||||
|
self.set_state(ServerState::Stopped);
|
||||||
|
}
|
||||||
|
Err(StartFailure::Shutdown) => return,
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = ack.send(());
|
let _ = ack.send(());
|
||||||
}
|
}
|
||||||
SupervisorCmd::Reconfigure { new, ack } => {
|
SupervisorCmd::Reconfigure { new, ack } => {
|
||||||
self.cfg = new;
|
self.cfg = *new;
|
||||||
self.backoff =
|
self.backoff =
|
||||||
Backoff::new(self.cfg.restart.backoff_initial, self.cfg.restart.backoff_max);
|
Backoff::new(self.cfg.restart.backoff_initial, self.cfg.restart.backoff_max);
|
||||||
self.retry_window = RetryWindow::new(
|
self.retry_window = RetryWindow::new(
|
||||||
@@ -250,7 +284,7 @@ impl<S: Spawner> SupervisorTask<S> {
|
|||||||
Action::RetryNow
|
Action::RetryNow
|
||||||
}
|
}
|
||||||
Some(SupervisorCmd::Reconfigure { new, ack }) => {
|
Some(SupervisorCmd::Reconfigure { new, ack }) => {
|
||||||
self.cfg = new;
|
self.cfg = *new;
|
||||||
self.backoff = Backoff::new(
|
self.backoff = Backoff::new(
|
||||||
self.cfg.restart.backoff_initial,
|
self.cfg.restart.backoff_initial,
|
||||||
self.cfg.restart.backoff_max,
|
self.cfg.restart.backoff_max,
|
||||||
@@ -267,12 +301,23 @@ impl<S: Spawner> SupervisorTask<S> {
|
|||||||
|
|
||||||
match action {
|
match action {
|
||||||
Action::RetryNow => {
|
Action::RetryNow => {
|
||||||
match self.do_start().await {
|
match self.do_start(StartCause::Restart).await {
|
||||||
Ok(c) => child = Some(c),
|
Ok(c) => child = Some(c),
|
||||||
Err(err) => {
|
Err(StartFailure::Spawn(err)) => {
|
||||||
warn!(name = %self.cfg.name, error = %err, "restart spawn failed");
|
warn!(name = %self.cfg.name, error = %err, "restart spawn failed");
|
||||||
|
self.started_at = None;
|
||||||
self.set_state(ServerState::Failed);
|
self.set_state(ServerState::Failed);
|
||||||
}
|
}
|
||||||
|
Err(StartFailure::WaitTimedOut) => {
|
||||||
|
warn!(name = %self.cfg.name, "wait-for timed out");
|
||||||
|
self.started_at = None;
|
||||||
|
self.set_state(ServerState::Failed);
|
||||||
|
}
|
||||||
|
Err(StartFailure::Cancelled) => {
|
||||||
|
self.started_at = None;
|
||||||
|
self.set_state(ServerState::Stopped);
|
||||||
|
}
|
||||||
|
Err(StartFailure::Shutdown) => return,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Cancel => {
|
Action::Cancel => {
|
||||||
@@ -288,12 +333,98 @@ impl<S: Spawner> SupervisorTask<S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn do_start(&mut self) -> std::io::Result<S::Child> {
|
async fn await_ready(&mut self) -> GateOutcome {
|
||||||
|
let Some(wait) = self.cfg.wait_for.clone() else {
|
||||||
|
return GateOutcome::Ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
let started_at = Instant::now();
|
||||||
|
|
||||||
|
self.waiting = Some(WaitProgress {
|
||||||
|
description: crate::ready::describe(&wait.condition),
|
||||||
|
started_at,
|
||||||
|
timeout: wait.timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.started_at = None;
|
||||||
|
|
||||||
|
self.set_state(ServerState::Waiting);
|
||||||
|
|
||||||
|
let outcome = loop {
|
||||||
|
if crate::ready::is_ready(&wait.condition, wait.interval).await {
|
||||||
|
break GateOutcome::Ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
if started_at.elapsed() >= wait.timeout {
|
||||||
|
break GateOutcome::TimedOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tick = std::pin::pin!(sleep(wait.interval));
|
||||||
|
|
||||||
|
let interrupted = tokio::select! {
|
||||||
|
_ = &mut tick => None,
|
||||||
|
cmd = self.cmd_rx.recv() => match cmd {
|
||||||
|
None => Some(GateOutcome::Shutdown),
|
||||||
|
Some(SupervisorCmd::Stop { ack }) => {
|
||||||
|
let _ = ack.send(StopAck::NotRunning);
|
||||||
|
Some(GateOutcome::Cancelled)
|
||||||
|
}
|
||||||
|
Some(SupervisorCmd::Shutdown { ack }) => {
|
||||||
|
let _ = ack.send(());
|
||||||
|
Some(GateOutcome::Shutdown)
|
||||||
|
}
|
||||||
|
Some(SupervisorCmd::Start { ack }) => {
|
||||||
|
let _ = ack.send(StartAck::Started);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Some(SupervisorCmd::Restart { ack }) => {
|
||||||
|
let _ = ack.send(());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Some(SupervisorCmd::Reconfigure { new, ack }) => {
|
||||||
|
self.cfg = *new;
|
||||||
|
self.backoff =
|
||||||
|
Backoff::new(self.cfg.restart.backoff_initial, self.cfg.restart.backoff_max);
|
||||||
|
self.retry_window = RetryWindow::new(
|
||||||
|
Duration::from_secs(60),
|
||||||
|
self.cfg.restart.max_retries_per_minute,
|
||||||
|
);
|
||||||
|
let _ = ack.send(());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(outcome) = interrupted {
|
||||||
|
break outcome;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.waiting = None;
|
||||||
|
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_start(&mut self, cause: StartCause) -> Result<S::Child, StartFailure> {
|
||||||
|
match self.await_ready().await {
|
||||||
|
GateOutcome::Ready => {}
|
||||||
|
GateOutcome::TimedOut => return Err(StartFailure::WaitTimedOut),
|
||||||
|
GateOutcome::Cancelled => return Err(StartFailure::Cancelled),
|
||||||
|
GateOutcome::Shutdown => return Err(StartFailure::Shutdown),
|
||||||
|
}
|
||||||
|
|
||||||
self.set_state(ServerState::Starting);
|
self.set_state(ServerState::Starting);
|
||||||
|
|
||||||
let c = self.spawner.spawn(&self.cfg, self.log_sink.clone()).await?;
|
let c = self
|
||||||
|
.spawner
|
||||||
|
.spawn(&self.cfg, self.log_sink.clone())
|
||||||
|
.await
|
||||||
|
.map_err(StartFailure::Spawn)?;
|
||||||
|
|
||||||
|
if cause == StartCause::Restart {
|
||||||
self.restart_count = self.restart_count.saturating_add(1);
|
self.restart_count = self.restart_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
self.started_at = Some(Instant::now());
|
self.started_at = Some(Instant::now());
|
||||||
self.current_pid = Some(c.pid());
|
self.current_pid = Some(c.pid());
|
||||||
self.backoff.reset();
|
self.backoff.reset();
|
||||||
@@ -332,6 +463,26 @@ async fn wait_child<C: ChildHandle>(slot: &mut Option<C>) -> Option<i32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum StartCause {
|
||||||
|
Initial,
|
||||||
|
Restart,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StartFailure {
|
||||||
|
Spawn(std::io::Error),
|
||||||
|
WaitTimedOut,
|
||||||
|
Cancelled,
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GateOutcome {
|
||||||
|
Ready,
|
||||||
|
TimedOut,
|
||||||
|
Cancelled,
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RealSpawner;
|
pub struct RealSpawner;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -348,9 +499,10 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::child::MockChild;
|
use crate::child::MockChild;
|
||||||
use crate::logs::{LogSink, RotatingLogWriter};
|
use crate::logs::{LogSink, RotatingLogWriter};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use xy_protocol::{RestartConfig, RestartPolicy, StopConfig};
|
use xy_protocol::{RestartConfig, RestartPolicy, StopConfig, WaitCondition, WaitForConfig};
|
||||||
|
|
||||||
struct QueueSpawner {
|
struct QueueSpawner {
|
||||||
queue: Arc<Mutex<Vec<MockChild>>>,
|
queue: Arc<Mutex<Vec<MockChild>>>,
|
||||||
@@ -383,9 +535,16 @@ mod tests {
|
|||||||
stop: StopConfig {
|
stop: StopConfig {
|
||||||
grace: Duration::from_millis(50),
|
grace: Duration::from_millis(50),
|
||||||
},
|
},
|
||||||
|
wait_for: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cfg_with_wait(name: &str, wait: WaitForConfig) -> ServerConfig {
|
||||||
|
let mut c = cfg(name, RestartPolicy::Never, 5);
|
||||||
|
c.wait_for = Some(wait);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
fn sink(name: &str) -> LogSink {
|
fn sink(name: &str) -> LogSink {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let writer = RotatingLogWriter::open(&dir.path().join("s.log"), 1024, 3).unwrap();
|
let writer = RotatingLogWriter::open(&dir.path().join("s.log"), 1024, 3).unwrap();
|
||||||
@@ -398,9 +557,23 @@ mod tests {
|
|||||||
state: ServerState::Stopped,
|
state: ServerState::Stopped,
|
||||||
pid: None,
|
pid: None,
|
||||||
port: cfg.port,
|
port: cfg.port,
|
||||||
uptime_secs: None,
|
started_at: None,
|
||||||
restart_count: 0,
|
restart_count: 0,
|
||||||
last_exit: None,
|
last_exit: None,
|
||||||
|
waiting: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_pid(rx: &mut watch::Receiver<Status>, want: u32) {
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||||
|
loop {
|
||||||
|
if rx.borrow().pid == Some(want) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = rx.changed() => {}
|
||||||
|
_ = tokio::time::sleep_until(deadline) => panic!("never saw pid {want}, last={:?}", rx.borrow().pid),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,4 +621,389 @@ mod tests {
|
|||||||
ack_rx.await.unwrap();
|
ack_rx.await.unwrap();
|
||||||
h.await.unwrap();
|
h.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn first_start_is_not_counted_as_a_restart() {
|
||||||
|
let cfg = cfg("x", RestartPolicy::Never, 5);
|
||||||
|
let (mock, ctl) = MockChild::new(1);
|
||||||
|
let queue = Arc::new(Mutex::new(vec![mock]));
|
||||||
|
let spawner = QueueSpawner { queue };
|
||||||
|
|
||||||
|
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||||
|
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
|
||||||
|
let h = tokio::spawn(task.run());
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
|
||||||
|
wait_for(&mut status_rx, ServerState::Running).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
status_rx.borrow().restart_count,
|
||||||
|
0,
|
||||||
|
"a server started once has not restarted"
|
||||||
|
);
|
||||||
|
|
||||||
|
// do_stop's post-kill wait() is unbounded, and MockChild only reports an
|
||||||
|
// exit when its controller does; dropping it closes the channel so
|
||||||
|
// shutdown can finish.
|
||||||
|
drop(ctl);
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Shutdown { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ack_rx.await.unwrap();
|
||||||
|
h.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_automatic_restart_after_a_crash_is_counted() {
|
||||||
|
let cfg = cfg("x", RestartPolicy::Always, 5);
|
||||||
|
let (first, mut ctl) = MockChild::new(1);
|
||||||
|
let (second, ctl2) = MockChild::new(2);
|
||||||
|
let queue = Arc::new(Mutex::new(vec![first, second]));
|
||||||
|
let spawner = QueueSpawner { queue };
|
||||||
|
|
||||||
|
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||||
|
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
|
||||||
|
let h = tokio::spawn(task.run());
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
|
||||||
|
wait_for(&mut status_rx, ServerState::Running).await;
|
||||||
|
|
||||||
|
ctl.exit_tx.take().unwrap().send(Some(1)).unwrap();
|
||||||
|
|
||||||
|
wait_for_pid(&mut status_rx, 2).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
status_rx.borrow().restart_count,
|
||||||
|
1,
|
||||||
|
"one crash-and-respawn is one restart"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(ctl2);
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Shutdown { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ack_rx.await.unwrap();
|
||||||
|
h.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_met_condition_spawns_immediately() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let file = tmp.path().join("sock");
|
||||||
|
|
||||||
|
std::fs::write(&file, b"").unwrap();
|
||||||
|
|
||||||
|
let cfg = cfg_with_wait(
|
||||||
|
"x",
|
||||||
|
WaitForConfig {
|
||||||
|
condition: WaitCondition::Path(file),
|
||||||
|
timeout: Duration::from_millis(500),
|
||||||
|
interval: Duration::from_millis(10),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let (mock, ctl) = MockChild::new(1);
|
||||||
|
let queue = Arc::new(Mutex::new(vec![mock]));
|
||||||
|
let spawner = QueueSpawner { queue };
|
||||||
|
|
||||||
|
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||||
|
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
|
||||||
|
let h = tokio::spawn(task.run());
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
|
||||||
|
wait_for(&mut status_rx, ServerState::Running).await;
|
||||||
|
|
||||||
|
drop(ctl);
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Shutdown { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ack_rx.await.unwrap();
|
||||||
|
h.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_timed_out_gate_fails_without_spawning() {
|
||||||
|
let cfg = cfg_with_wait(
|
||||||
|
"x",
|
||||||
|
WaitForConfig {
|
||||||
|
condition: WaitCondition::Path(PathBuf::from("/definitely/not/here.sock")),
|
||||||
|
timeout: Duration::from_millis(80),
|
||||||
|
interval: Duration::from_millis(10),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// An empty queue: any spawn attempt panics, which is the assertion that
|
||||||
|
// a timed-out gate never reaches the spawner.
|
||||||
|
let queue = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let spawner = QueueSpawner { queue };
|
||||||
|
|
||||||
|
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||||
|
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
|
||||||
|
let h = tokio::spawn(task.run());
|
||||||
|
|
||||||
|
let (ack_tx, _ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
wait_for(&mut status_rx, ServerState::Waiting).await;
|
||||||
|
wait_for(&mut status_rx, ServerState::Failed).await;
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Shutdown { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ack_rx.await.unwrap();
|
||||||
|
h.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repeated_gate_timeouts_do_not_pollute_the_retry_window() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let file = tmp.path().join("sock");
|
||||||
|
|
||||||
|
let mut cfg = cfg("x", RestartPolicy::Always, 2);
|
||||||
|
|
||||||
|
cfg.wait_for = Some(WaitForConfig {
|
||||||
|
condition: WaitCondition::Path(file.clone()),
|
||||||
|
timeout: Duration::from_millis(20),
|
||||||
|
interval: Duration::from_millis(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
let (first, mut ctl) = MockChild::new(1);
|
||||||
|
let (second, ctl2) = MockChild::new(2);
|
||||||
|
let queue = Arc::new(Mutex::new(vec![first, second]));
|
||||||
|
let spawner = QueueSpawner { queue };
|
||||||
|
|
||||||
|
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||||
|
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
|
||||||
|
let h = tokio::spawn(task.run());
|
||||||
|
|
||||||
|
let (ack_tx, _ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wait_for(&mut status_rx, ServerState::Waiting).await;
|
||||||
|
wait_for(&mut status_rx, ServerState::Failed).await;
|
||||||
|
|
||||||
|
let (ack_tx, _ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wait_for(&mut status_rx, ServerState::Waiting).await;
|
||||||
|
wait_for(&mut status_rx, ServerState::Failed).await;
|
||||||
|
|
||||||
|
std::fs::write(&file, b"").unwrap();
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
|
||||||
|
wait_for(&mut status_rx, ServerState::Running).await;
|
||||||
|
|
||||||
|
ctl.exit_tx.take().unwrap().send(Some(1)).unwrap();
|
||||||
|
|
||||||
|
wait_for_pid(&mut status_rx, 2).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
status_rx.borrow().state,
|
||||||
|
ServerState::Running,
|
||||||
|
"the crash after two gate timeouts must still be within the retry budget"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(ctl2);
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Shutdown { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ack_rx.await.unwrap();
|
||||||
|
h.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stop_during_a_wait_cancels_promptly() {
|
||||||
|
let cfg = cfg_with_wait(
|
||||||
|
"x",
|
||||||
|
WaitForConfig {
|
||||||
|
condition: WaitCondition::Path(PathBuf::from("/definitely/not/here.sock")),
|
||||||
|
timeout: Duration::from_secs(30),
|
||||||
|
interval: Duration::from_millis(10),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let queue = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let spawner = QueueSpawner { queue };
|
||||||
|
|
||||||
|
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||||
|
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
|
||||||
|
let h = tokio::spawn(task.run());
|
||||||
|
|
||||||
|
let (ack_tx, _ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wait_for(&mut status_rx, ServerState::Waiting).await;
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Stop { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The 30s timeout would swamp this if the poll loop were not interruptible.
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), ack_rx)
|
||||||
|
.await
|
||||||
|
.expect("stop must not wait for the gate timeout")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
wait_for(&mut status_rx, ServerState::Stopped).await;
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Shutdown { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ack_rx.await.unwrap();
|
||||||
|
h.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_during_a_wait_ends_the_task_promptly() {
|
||||||
|
let cfg = cfg_with_wait(
|
||||||
|
"x",
|
||||||
|
WaitForConfig {
|
||||||
|
condition: WaitCondition::Path(PathBuf::from("/definitely/not/here.sock")),
|
||||||
|
timeout: Duration::from_secs(30),
|
||||||
|
interval: Duration::from_millis(10),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let queue = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let spawner = QueueSpawner { queue };
|
||||||
|
|
||||||
|
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||||
|
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
|
||||||
|
let h = tokio::spawn(task.run());
|
||||||
|
|
||||||
|
let (ack_tx, _ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
wait_for(&mut status_rx, ServerState::Waiting).await;
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Shutdown { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The 30s gate timeout would swamp both of these if Shutdown were only
|
||||||
|
// observed once the gate resolved.
|
||||||
|
tokio::time::timeout(Duration::from_millis(2_000), ack_rx)
|
||||||
|
.await
|
||||||
|
.expect("shutdown must not wait for the gate timeout")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_millis(2_000), h)
|
||||||
|
.await
|
||||||
|
.expect("the supervisor task must return once shut down")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_server_waiting_on_its_gate_reports_no_uptime() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let file = tmp.path().join("sock");
|
||||||
|
|
||||||
|
std::fs::write(&file, b"").unwrap();
|
||||||
|
|
||||||
|
let mut cfg = cfg("x", RestartPolicy::Always, 5);
|
||||||
|
|
||||||
|
cfg.wait_for = Some(WaitForConfig {
|
||||||
|
condition: WaitCondition::Path(file.clone()),
|
||||||
|
timeout: Duration::from_millis(2_000),
|
||||||
|
interval: Duration::from_millis(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only one child: the gate must never clear again, so a second spawn
|
||||||
|
// would panic on the empty queue.
|
||||||
|
let (first, mut ctl) = MockChild::new(1);
|
||||||
|
let queue = Arc::new(Mutex::new(vec![first]));
|
||||||
|
let spawner = QueueSpawner { queue };
|
||||||
|
|
||||||
|
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel(8);
|
||||||
|
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
|
||||||
|
let h = tokio::spawn(task.run());
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
|
||||||
|
wait_for(&mut status_rx, ServerState::Running).await;
|
||||||
|
assert!(status_rx.borrow().started_at.is_some());
|
||||||
|
|
||||||
|
std::fs::remove_file(&file).unwrap();
|
||||||
|
|
||||||
|
ctl.exit_tx.take().unwrap().send(Some(1)).unwrap();
|
||||||
|
|
||||||
|
wait_for(&mut status_rx, ServerState::Waiting).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
status_rx.borrow().started_at.is_none(),
|
||||||
|
"a server waiting on its gate has no live process, so it has no uptime"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (ack_tx, ack_rx) = oneshot::channel();
|
||||||
|
cmd_tx
|
||||||
|
.send(SupervisorCmd::Shutdown { ack: ack_tx })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ack_rx.await.unwrap();
|
||||||
|
h.await.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ anyhow.workspace = true
|
|||||||
etcetera.workspace = true
|
etcetera.workspace = true
|
||||||
nix.workspace = true
|
nix.workspace = true
|
||||||
humantime.workspace = true
|
humantime.workspace = true
|
||||||
|
service-manager = { workspace = true }
|
||||||
|
plist = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
+126
-5
@@ -1,4 +1,4 @@
|
|||||||
use xy_protocol::rpc::ServerSummary;
|
use xy_protocol::rpc::{ServerSummary, WaitInfo};
|
||||||
|
|
||||||
pub fn list_table(rows: &[ServerSummary]) -> String {
|
pub fn list_table(rows: &[ServerSummary]) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
@@ -7,10 +7,7 @@ pub fn list_table(rows: &[ServerSummary]) -> String {
|
|||||||
|
|
||||||
for r in rows {
|
for r in rows {
|
||||||
let pid = r.pid.map(|p| p.to_string()).unwrap_or_else(|| "-".into());
|
let pid = r.pid.map(|p| p.to_string()).unwrap_or_else(|| "-".into());
|
||||||
let up = r
|
let up = r.uptime_secs.map(uptime).unwrap_or_else(|| "-".into());
|
||||||
.uptime_secs
|
|
||||||
.map(|s| format!("{}s", s))
|
|
||||||
.unwrap_or_else(|| "-".into());
|
|
||||||
|
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"{:<20}{:<12}{:<8}{:<8}{:<10}{}\n",
|
"{:<20}{:<12}{:<8}{:<8}{:<10}{}\n",
|
||||||
@@ -25,3 +22,127 @@ pub fn list_table(rows: &[ServerSummary]) -> String {
|
|||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn uptime(secs: u64) -> String {
|
||||||
|
const MINUTE: u64 = 60;
|
||||||
|
const HOUR: u64 = 60 * MINUTE;
|
||||||
|
const DAY: u64 = 24 * HOUR;
|
||||||
|
|
||||||
|
let (major, minor, major_unit, minor_unit) = if secs >= DAY {
|
||||||
|
(secs / DAY, secs % DAY / HOUR, "d", "h")
|
||||||
|
} else if secs >= HOUR {
|
||||||
|
(secs / HOUR, secs % HOUR / MINUTE, "h", "m")
|
||||||
|
} else if secs >= MINUTE {
|
||||||
|
(secs / MINUTE, secs % MINUTE, "m", "s")
|
||||||
|
} else {
|
||||||
|
return format!("{secs}s");
|
||||||
|
};
|
||||||
|
|
||||||
|
if minor == 0 {
|
||||||
|
return format!("{major}{major_unit}");
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("{major}{major_unit} {minor}{minor_unit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn wait_line(info: &WaitInfo) -> String {
|
||||||
|
format!(
|
||||||
|
" wait-for: {}\n {}s elapsed, timeout {}s\n",
|
||||||
|
info.description, info.elapsed_secs, info.timeout_secs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use xy_protocol::ServerState;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_renders_the_waiting_state() {
|
||||||
|
let rows = vec![ServerSummary {
|
||||||
|
name: "gitea".to_string(),
|
||||||
|
state: ServerState::Waiting,
|
||||||
|
pid: None,
|
||||||
|
port: 8181,
|
||||||
|
uptime_secs: None,
|
||||||
|
restart_count: 0,
|
||||||
|
last_exit: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let out = list_table(&rows);
|
||||||
|
|
||||||
|
assert!(out.contains("waiting"), "got: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uptime_under_a_minute_is_bare_seconds() {
|
||||||
|
assert_eq!(uptime(0), "0s");
|
||||||
|
assert_eq!(uptime(49), "49s");
|
||||||
|
assert_eq!(uptime(59), "59s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uptime_under_an_hour_is_minutes_and_seconds() {
|
||||||
|
assert_eq!(uptime(60), "1m");
|
||||||
|
assert_eq!(uptime(724), "12m 4s");
|
||||||
|
assert_eq!(uptime(3_599), "59m 59s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uptime_under_a_day_is_hours_and_minutes() {
|
||||||
|
assert_eq!(uptime(3_600), "1h");
|
||||||
|
assert_eq!(uptime(10_549), "2h 55m");
|
||||||
|
assert_eq!(uptime(86_399), "23h 59m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uptime_of_a_day_or_more_is_days_and_hours() {
|
||||||
|
assert_eq!(uptime(86_400), "1d");
|
||||||
|
assert_eq!(uptime(529_200), "6d 3h");
|
||||||
|
assert_eq!(uptime(2_707_200), "31d 8h");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uptime_drops_a_trailing_zero_unit() {
|
||||||
|
assert_eq!(uptime(120), "2m");
|
||||||
|
assert_eq!(uptime(7_200), "2h");
|
||||||
|
assert_eq!(uptime(518_400), "6d");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_renders_uptime_in_human_units() {
|
||||||
|
let rows = vec![ServerSummary {
|
||||||
|
name: "vestige".to_string(),
|
||||||
|
state: ServerState::Running,
|
||||||
|
pid: Some(4821),
|
||||||
|
port: 3928,
|
||||||
|
uptime_secs: Some(10_549),
|
||||||
|
restart_count: 0,
|
||||||
|
last_exit: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let out = list_table(&rows);
|
||||||
|
|
||||||
|
assert!(out.contains("2h 55m"), "got: {out}");
|
||||||
|
assert!(
|
||||||
|
!out.contains("10549"),
|
||||||
|
"raw seconds must not leak; got: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_renders_the_wait_condition_and_elapsed() {
|
||||||
|
let info = WaitInfo {
|
||||||
|
description: "path /Users/me/.orbstack/run/docker.sock".to_string(),
|
||||||
|
elapsed_secs: 43,
|
||||||
|
timeout_secs: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
let out = wait_line(&info);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
" wait-for: path /Users/me/.orbstack/run/docker.sock\n 43s elapsed, timeout 120s\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use xy_protocol::rpc::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod format;
|
mod format;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
async fn connect(paths: &Paths) -> Result<Client> {
|
async fn connect(paths: &Paths) -> Result<Client> {
|
||||||
match Client::connect(&paths.socket).await {
|
match Client::connect(&paths.socket).await {
|
||||||
@@ -57,6 +58,10 @@ pub async fn status(paths: Paths, name: String) -> Result<i32> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(info) = &d.wait_for {
|
||||||
|
print!("{}", format::wait_line(info));
|
||||||
|
}
|
||||||
|
|
||||||
println!("{:#?}", d);
|
println!("{:#?}", d);
|
||||||
|
|
||||||
Ok(0)
|
Ok(0)
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
use crate::logging;
|
||||||
|
use crate::paths::Paths;
|
||||||
|
use crate::service::{self, AgentSpec, AgentState, AgentStatus, DEFAULT_LABEL};
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub(crate) fn render_status(status: &AgentStatus) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
|
||||||
|
out.push_str(&format!(" agent: {} (user)\n", status.label));
|
||||||
|
out.push_str(&format!(" plist: {}\n", status.plist.display()));
|
||||||
|
|
||||||
|
let state = match (&status.state, status.pid) {
|
||||||
|
(AgentState::Running, Some(pid)) => format!("running (pid {pid})"),
|
||||||
|
(AgentState::Running, None) => "running".to_string(),
|
||||||
|
(AgentState::Stopped, _) => "stopped".to_string(),
|
||||||
|
(AgentState::NotInstalled, _) => "not installed".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
out.push_str(&format!(" state: {state}\n"));
|
||||||
|
|
||||||
|
if let Some(program) = &status.program {
|
||||||
|
out.push_str(&format!(" program: {}\n", program.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path_env) = &status.path_env {
|
||||||
|
match status.snapshotted.and_then(snapshot_date) {
|
||||||
|
Some(date) => out.push_str(&format!(" path: {path_env} (snapshotted {date})\n")),
|
||||||
|
None => out.push_str(&format!(" path: {path_env}\n")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&format!(" log: {}\n", status.log.display()));
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_date(at: std::time::SystemTime) -> Option<String> {
|
||||||
|
let stamp = humantime::format_rfc3339_seconds(at).to_string();
|
||||||
|
|
||||||
|
stamp.split('T').next().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(paths: Paths, cmd: crate::ServiceCmd) -> Result<i32> {
|
||||||
|
if let Err(err) = service::ensure_supported() {
|
||||||
|
eprintln!("xy: {err}");
|
||||||
|
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
crate::ServiceCmd::Install { force } => install(force),
|
||||||
|
crate::ServiceCmd::Uninstall => uninstall(),
|
||||||
|
crate::ServiceCmd::Start => toggle(service::start, "loaded"),
|
||||||
|
crate::ServiceCmd::Stop => toggle(service::stop, "unloaded"),
|
||||||
|
crate::ServiceCmd::Status => status(&paths),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(force: bool) -> Result<i32> {
|
||||||
|
let spec = AgentSpec::for_current_exe()?;
|
||||||
|
|
||||||
|
let plist = spec.plist_path()?;
|
||||||
|
|
||||||
|
if plist.exists() {
|
||||||
|
if !force {
|
||||||
|
eprintln!("xy: agent already installed at {}", plist.display());
|
||||||
|
eprintln!("xy: pass --force to replace it");
|
||||||
|
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
service::uninstall(&spec.label)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if service::is_build_tree_path(&spec.program) {
|
||||||
|
eprintln!(
|
||||||
|
"xy: warning: pointing the agent at a build-tree binary\n {}\n it will disappear on `cargo clean`",
|
||||||
|
spec.program.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
service::install(&spec)?;
|
||||||
|
service::start(&spec.label)?;
|
||||||
|
|
||||||
|
println!("wrote {}", plist.display());
|
||||||
|
println!("loaded {}", spec.label);
|
||||||
|
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uninstall() -> Result<i32> {
|
||||||
|
let plist = service::plist_path_for(DEFAULT_LABEL)?;
|
||||||
|
|
||||||
|
if !plist.exists() {
|
||||||
|
println!("not installed");
|
||||||
|
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
service::uninstall(DEFAULT_LABEL)?;
|
||||||
|
|
||||||
|
println!("removed {}", plist.display());
|
||||||
|
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle(action: fn(&str) -> Result<()>, verb: &str) -> Result<i32> {
|
||||||
|
let plist = service::plist_path_for(DEFAULT_LABEL)?;
|
||||||
|
|
||||||
|
if !plist.exists() {
|
||||||
|
eprintln!("xy: agent is not installed");
|
||||||
|
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
match action(DEFAULT_LABEL) {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("{verb} {DEFAULT_LABEL}");
|
||||||
|
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xy: {err:#}");
|
||||||
|
|
||||||
|
Ok(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(paths: &Paths) -> Result<i32> {
|
||||||
|
let log = logging::daemon_log_path(&paths.log_dir);
|
||||||
|
|
||||||
|
let status = service::status(DEFAULT_LABEL, &paths.pidfile, &log)?;
|
||||||
|
|
||||||
|
print!("{}", render_status(&status));
|
||||||
|
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn running_status() -> AgentStatus {
|
||||||
|
AgentStatus {
|
||||||
|
label: "se.aceofba.xy".to_string(),
|
||||||
|
plist: PathBuf::from("/Users/me/Library/LaunchAgents/se.aceofba.xy.plist"),
|
||||||
|
log: PathBuf::from("/Users/me/.local/state/xy/logs/daemon.log"),
|
||||||
|
state: AgentState::Running,
|
||||||
|
program: Some(PathBuf::from("/Users/me/.cargo/bin/xy")),
|
||||||
|
path_env: Some("/opt/homebrew/bin:/usr/bin".to_string()),
|
||||||
|
snapshotted: None,
|
||||||
|
pid: Some(4821),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_status_shows_running_state_with_pid() {
|
||||||
|
let out = render_status(&running_status());
|
||||||
|
|
||||||
|
assert!(out.contains("agent: se.aceofba.xy (user)"));
|
||||||
|
assert!(out.contains("state: running (pid 4821)"));
|
||||||
|
assert!(out.contains("program: /Users/me/.cargo/bin/xy"));
|
||||||
|
assert!(out.contains("path: /opt/homebrew/bin:/usr/bin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_status_omits_pid_when_stopped() {
|
||||||
|
let mut status = running_status();
|
||||||
|
status.state = AgentState::Stopped;
|
||||||
|
status.pid = None;
|
||||||
|
|
||||||
|
let out = render_status(&status);
|
||||||
|
|
||||||
|
assert!(out.contains("state: stopped"));
|
||||||
|
assert!(!out.contains("pid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_status_annotates_path_with_snapshot_date() {
|
||||||
|
let mut status = running_status();
|
||||||
|
status.snapshotted =
|
||||||
|
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_753_920_000));
|
||||||
|
|
||||||
|
let out = render_status(&status);
|
||||||
|
|
||||||
|
assert!(out.contains("path: /opt/homebrew/bin:/usr/bin (snapshotted 2025-07-31)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_status_reports_not_installed_without_program_lines() {
|
||||||
|
let mut status = running_status();
|
||||||
|
status.state = AgentState::NotInstalled;
|
||||||
|
status.program = None;
|
||||||
|
status.path_env = None;
|
||||||
|
status.pid = None;
|
||||||
|
|
||||||
|
let out = render_status(&status);
|
||||||
|
|
||||||
|
assert!(out.contains("state: not installed"));
|
||||||
|
assert!(!out.contains("program:"));
|
||||||
|
assert!(!out.contains("path:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_status_shows_the_daemon_log_path() {
|
||||||
|
let out = render_status(&running_status());
|
||||||
|
|
||||||
|
assert!(out.contains(" log: /Users/me/.local/state/xy/logs/daemon.log\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_status_shows_the_log_path_even_when_not_installed() {
|
||||||
|
let mut status = running_status();
|
||||||
|
status.state = AgentState::NotInstalled;
|
||||||
|
status.program = None;
|
||||||
|
status.path_env = None;
|
||||||
|
status.pid = None;
|
||||||
|
|
||||||
|
let out = render_status(&status);
|
||||||
|
|
||||||
|
assert!(out.contains("log: /Users/me/.local/state/xy/logs/daemon.log"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ use xy_ipc::envelope::{Incoming, Request, Response, err_response, ok_response};
|
|||||||
use xy_protocol::RpcErrorCode;
|
use xy_protocol::RpcErrorCode;
|
||||||
use xy_protocol::rpc::{
|
use xy_protocol::rpc::{
|
||||||
LogEnd, LogLine, LogsCancelParams, LogsParams, LogsSubscribed, NameOrAll, RestartResult,
|
LogEnd, LogLine, LogsCancelParams, LogsParams, LogsSubscribed, NameOrAll, RestartResult,
|
||||||
ServerSummary, StartResult, StatusDetail, StopResult, methods, notifications,
|
ServerSummary, StartResult, StatusDetail, StopResult, WaitInfo, methods, notifications,
|
||||||
};
|
};
|
||||||
use xy_supervisor::supervisor::{StartAck, StopAck, SupervisorCmd};
|
use xy_supervisor::supervisor::{StartAck, StopAck, SupervisorCmd};
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ async fn list(reg: &Registry) -> Result<Vec<ServerSummary>, ApiError> {
|
|||||||
state: s.state,
|
state: s.state,
|
||||||
pid: s.pid,
|
pid: s.pid,
|
||||||
port: s.port,
|
port: s.port,
|
||||||
uptime_secs: s.uptime_secs,
|
uptime_secs: s.started_at.map(|t| t.elapsed().as_secs()),
|
||||||
restart_count: s.restart_count,
|
restart_count: s.restart_count,
|
||||||
last_exit: s.last_exit,
|
last_exit: s.last_exit,
|
||||||
});
|
});
|
||||||
@@ -195,11 +195,16 @@ async fn status(reg: &Registry, name: &str) -> Result<StatusDetail, ApiError> {
|
|||||||
state: s.state,
|
state: s.state,
|
||||||
pid: s.pid,
|
pid: s.pid,
|
||||||
port: s.port,
|
port: s.port,
|
||||||
uptime_secs: s.uptime_secs,
|
uptime_secs: s.started_at.map(|t| t.elapsed().as_secs()),
|
||||||
restart_count: s.restart_count,
|
restart_count: s.restart_count,
|
||||||
last_exit: s.last_exit,
|
last_exit: s.last_exit,
|
||||||
},
|
},
|
||||||
recent_transitions: Vec::new(),
|
recent_transitions: Vec::new(),
|
||||||
|
wait_for: s.waiting.as_ref().map(|w| WaitInfo {
|
||||||
|
description: w.description.clone(),
|
||||||
|
elapsed_secs: w.started_at.elapsed().as_secs(),
|
||||||
|
timeout_secs: w.timeout.as_secs(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ pub fn spawn_supervisor(paths: &Paths, cfg: ServerConfig) -> Result<SupervisorHa
|
|||||||
state: ServerState::Stopped,
|
state: ServerState::Stopped,
|
||||||
pid: None,
|
pid: None,
|
||||||
port: cfg.port,
|
port: cfg.port,
|
||||||
uptime_secs: None,
|
started_at: None,
|
||||||
restart_count: 0,
|
restart_count: 0,
|
||||||
last_exit: None,
|
last_exit: None,
|
||||||
|
waiting: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (status_tx, status_rx) = watch::channel(initial_status);
|
let (status_tx, status_rx) = watch::channel(initial_status);
|
||||||
@@ -66,8 +67,6 @@ pub fn spawn_supervisor(paths: &Paths, cfg: ServerConfig) -> Result<SupervisorHa
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(paths: Paths) -> Result<()> {
|
pub async fn run(paths: Paths) -> Result<()> {
|
||||||
paths.ensure_dirs().context("create state dirs")?;
|
|
||||||
|
|
||||||
let _pid =
|
let _pid =
|
||||||
PidFile::acquire(&paths.pidfile).context("another xy daemon appears to be running")?;
|
PidFile::acquire(&paths.pidfile).context("another xy daemon appears to be running")?;
|
||||||
|
|
||||||
@@ -98,16 +97,12 @@ pub async fn run(paths: Paths) -> Result<()> {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let (ack_tx, ack_rx) = oneshot::channel();
|
let (ack_tx, _ack_rx) = oneshot::channel();
|
||||||
|
|
||||||
if handle
|
// Deliberately not awaiting the ack: a gated start blocks until its
|
||||||
.tx
|
// wait-for condition resolves, and awaiting here would keep the accept
|
||||||
.send(SupervisorCmd::Start { ack: ack_tx })
|
// loop and the signal handler below from ever being installed.
|
||||||
.await
|
let _ = handle.tx.send(SupervisorCmd::Start { ack: ack_tx }).await;
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
let _ = ack_rx.await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let registry_for_shutdown = registry.clone();
|
let registry_for_shutdown = registry.clone();
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use xy_supervisor::logs::RotatingLogWriter;
|
||||||
|
|
||||||
|
const LOG_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||||
|
const LOG_FILE_KEEP: usize = 5;
|
||||||
|
|
||||||
|
/// Distinct from `{server_name}.log` so a server named `xy` cannot collide with
|
||||||
|
/// the daemon's own log and give two writers independent rotation counters.
|
||||||
|
pub(crate) fn daemon_log_path(log_dir: &Path) -> PathBuf {
|
||||||
|
log_dir.join("daemon.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn daemon_writer(log_dir: &Path) -> std::io::Result<Mutex<RotatingLogWriter>> {
|
||||||
|
let writer =
|
||||||
|
RotatingLogWriter::open(&daemon_log_path(log_dir), LOG_FILE_MAX_BYTES, LOG_FILE_KEEP)?;
|
||||||
|
|
||||||
|
Ok(Mutex::new(writer))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daemon_writer_creates_and_appends_to_daemon_log() {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let writer = daemon_writer(tmp.path()).unwrap();
|
||||||
|
|
||||||
|
writer.lock().unwrap().write_all(b"line\n").unwrap();
|
||||||
|
|
||||||
|
let contents = std::fs::read_to_string(tmp.path().join("daemon.log")).unwrap();
|
||||||
|
assert_eq!(contents, "line\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daemon_log_path_does_not_collide_with_a_server_named_xy() {
|
||||||
|
let path = daemon_log_path(Path::new("/state/xy/logs"));
|
||||||
|
|
||||||
|
assert_eq!(path, PathBuf::from("/state/xy/logs/daemon.log"));
|
||||||
|
assert_ne!(path, PathBuf::from("/state/xy/logs/xy.log"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-12
@@ -2,8 +2,10 @@ use clap::{Parser, Subcommand};
|
|||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod daemon;
|
mod daemon;
|
||||||
|
mod logging;
|
||||||
mod paths;
|
mod paths;
|
||||||
mod pidfile;
|
mod pidfile;
|
||||||
|
mod service;
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "xy", version, about = "HTTP MCP server supervisor")]
|
#[command(name = "xy", version, about = "HTTP MCP server supervisor")]
|
||||||
@@ -51,27 +53,75 @@ enum Cmd {
|
|||||||
#[arg(short = 'f', long)]
|
#[arg(short = 'f', long)]
|
||||||
follow: bool,
|
follow: bool,
|
||||||
},
|
},
|
||||||
|
/// Manage the launchd start-on-login agent (macOS).
|
||||||
|
Service {
|
||||||
|
#[command(subcommand)]
|
||||||
|
verb: ServiceCmd,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum ServiceCmd {
|
||||||
|
/// Install the login agent and load it.
|
||||||
|
Install {
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
/// Remove the login agent.
|
||||||
|
Uninstall,
|
||||||
|
/// Load the installed agent.
|
||||||
|
Start,
|
||||||
|
/// Unload the agent until the next login.
|
||||||
|
Stop,
|
||||||
|
/// Show the agent's state.
|
||||||
|
Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> std::process::ExitCode {
|
async fn main() -> std::process::ExitCode {
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(
|
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
||||||
)
|
|
||||||
.with_writer(std::io::stderr)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
let paths = match paths::Paths::resolve() {
|
let paths = match paths::Paths::resolve() {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(err) => {
|
||||||
eprintln!("xy: failed to resolve XDG paths: {e}");
|
eprintln!("xy: failed to resolve XDG paths: {err}");
|
||||||
return std::process::ExitCode::from(3);
|
return std::process::ExitCode::from(3);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||||
|
|
||||||
|
let is_daemon = matches!(cli.cmd, Cmd::Daemon);
|
||||||
|
|
||||||
|
if is_daemon {
|
||||||
|
use tracing_subscriber::fmt::writer::MakeWriterExt;
|
||||||
|
|
||||||
|
if let Err(err) = paths.ensure_dirs() {
|
||||||
|
eprintln!("xy: failed to create state dirs: {err}");
|
||||||
|
return std::process::ExitCode::from(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = match logging::daemon_writer(&paths.log_dir) {
|
||||||
|
Ok(writer) => writer,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xy: failed to open daemon log: {err}");
|
||||||
|
return std::process::ExitCode::from(3);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(std::io::stderr.and(file))
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
let result: anyhow::Result<i32> = match cli.cmd {
|
let result: anyhow::Result<i32> = match cli.cmd {
|
||||||
Cmd::Daemon => daemon::run(paths).await.map(|_| 0),
|
Cmd::Daemon => daemon::run(paths).await.map(|_| 0),
|
||||||
Cmd::List => cli::list(paths).await,
|
Cmd::List => cli::list(paths).await,
|
||||||
@@ -81,12 +131,20 @@ async fn main() -> std::process::ExitCode {
|
|||||||
Cmd::Restart { all, name } => cli::restart(paths, all, name).await,
|
Cmd::Restart { all, name } => cli::restart(paths, all, name).await,
|
||||||
Cmd::Reload => cli::reload(paths).await,
|
Cmd::Reload => cli::reload(paths).await,
|
||||||
Cmd::Logs { name, tail, follow } => cli::logs(paths, name, tail, follow).await,
|
Cmd::Logs { name, tail, follow } => cli::logs(paths, name, tail, follow).await,
|
||||||
|
Cmd::Service { verb } => cli::service::run(paths, verb).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(code) => std::process::ExitCode::from(code as u8),
|
Ok(code) => std::process::ExitCode::from(code as u8),
|
||||||
Err(e) => {
|
Err(err) => {
|
||||||
eprintln!("xy: {e:#}");
|
// Under launchd the daemon's stderr is discarded, so a fatal
|
||||||
|
// startup error is only diagnosable if it reaches the log file.
|
||||||
|
if is_daemon {
|
||||||
|
tracing::error!("{err:#}");
|
||||||
|
} else {
|
||||||
|
eprintln!("xy: {err:#}");
|
||||||
|
}
|
||||||
|
|
||||||
std::process::ExitCode::from(1)
|
std::process::ExitCode::from(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,690 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use service_manager::{
|
||||||
|
LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
|
||||||
|
ServiceStatus, ServiceStatusCtx, ServiceUninstallCtx,
|
||||||
|
};
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
pub(crate) const DEFAULT_LABEL: &str = "se.aceofba.xy";
|
||||||
|
|
||||||
|
pub(crate) struct AgentSpec {
|
||||||
|
pub label: String,
|
||||||
|
pub program: PathBuf,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub path_env: String,
|
||||||
|
pub working_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentSpec {
|
||||||
|
pub fn for_current_exe() -> Result<Self> {
|
||||||
|
let program = std::env::current_exe()
|
||||||
|
.context("resolve current executable")?
|
||||||
|
.canonicalize()
|
||||||
|
.context("canonicalize current executable")?;
|
||||||
|
|
||||||
|
let path_env = std::env::var("PATH").context("read PATH")?;
|
||||||
|
|
||||||
|
let working_dir = etcetera::home_dir().context("locate home directory")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
label: DEFAULT_LABEL.to_string(),
|
||||||
|
program,
|
||||||
|
args: vec!["daemon".to_string()],
|
||||||
|
path_env,
|
||||||
|
working_dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_ctx(&self) -> Result<ServiceInstallCtx> {
|
||||||
|
let label: ServiceLabel = self.label.parse().context("parse service label")?;
|
||||||
|
|
||||||
|
Ok(ServiceInstallCtx {
|
||||||
|
label,
|
||||||
|
program: self.program.clone(),
|
||||||
|
args: self.args.iter().map(std::ffi::OsString::from).collect(),
|
||||||
|
contents: None,
|
||||||
|
username: None,
|
||||||
|
working_directory: Some(self.working_dir.clone()),
|
||||||
|
environment: Some(vec![("PATH".to_string(), self.path_env.clone())]),
|
||||||
|
autostart: true,
|
||||||
|
restart_policy: RestartPolicy::Always { delay_secs: None },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plist_path(&self) -> Result<PathBuf> {
|
||||||
|
plist_path_for(&self.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn plist_path_for(label: &str) -> Result<PathBuf> {
|
||||||
|
let home = etcetera::home_dir().context("locate home directory")?;
|
||||||
|
|
||||||
|
Ok(home
|
||||||
|
.join("Library")
|
||||||
|
.join("LaunchAgents")
|
||||||
|
.join(format!("{label}.plist")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_build_tree_path(path: &Path) -> bool {
|
||||||
|
let mut components = path.components().peekable();
|
||||||
|
|
||||||
|
while let Some(component) = components.next() {
|
||||||
|
if component != Component::Normal("target".as_ref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
components.peek(),
|
||||||
|
Some(Component::Normal(next))
|
||||||
|
if *next == std::ffi::OsStr::new("debug") || *next == std::ffi::OsStr::new("release")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(crate) fn ensure_supported() -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub(crate) fn ensure_supported() -> Result<()> {
|
||||||
|
anyhow::bail!("start-on-login is macOS-only for now")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manager() -> LaunchdServiceManager {
|
||||||
|
LaunchdServiceManager::user()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install(spec: &AgentSpec) -> Result<()> {
|
||||||
|
manager()
|
||||||
|
.install(spec.install_ctx()?)
|
||||||
|
.context("install launchd agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn uninstall(label: &str) -> Result<()> {
|
||||||
|
let label: ServiceLabel = label.parse().context("parse service label")?;
|
||||||
|
|
||||||
|
manager()
|
||||||
|
.uninstall(ServiceUninstallCtx { label })
|
||||||
|
.context("uninstall launchd agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum AgentState {
|
||||||
|
NotInstalled,
|
||||||
|
Stopped,
|
||||||
|
Running,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct AgentStatus {
|
||||||
|
pub label: String,
|
||||||
|
pub plist: PathBuf,
|
||||||
|
pub log: PathBuf,
|
||||||
|
pub state: AgentState,
|
||||||
|
pub program: Option<PathBuf>,
|
||||||
|
pub path_env: Option<String>,
|
||||||
|
pub snapshotted: Option<SystemTime>,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_pid(pidfile: &Path) -> Option<u32> {
|
||||||
|
std::fs::read_to_string(pidfile)
|
||||||
|
.ok()?
|
||||||
|
.trim()
|
||||||
|
.parse::<u32>()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_installed_status(label: &str, plist: PathBuf, log: PathBuf) -> AgentStatus {
|
||||||
|
AgentStatus {
|
||||||
|
label: label.to_string(),
|
||||||
|
plist,
|
||||||
|
log,
|
||||||
|
state: AgentState::NotInstalled,
|
||||||
|
program: None,
|
||||||
|
path_env: None,
|
||||||
|
snapshotted: None,
|
||||||
|
pid: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_status(
|
||||||
|
label: &str,
|
||||||
|
plist: PathBuf,
|
||||||
|
log: PathBuf,
|
||||||
|
pidfile: &Path,
|
||||||
|
state: AgentState,
|
||||||
|
) -> AgentStatus {
|
||||||
|
let snapshotted = std::fs::metadata(&plist)
|
||||||
|
.and_then(|meta| meta.modified())
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let pid = if state == AgentState::Running {
|
||||||
|
read_pid(pidfile)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (program, path_env) = read_plist_fields(&plist);
|
||||||
|
|
||||||
|
AgentStatus {
|
||||||
|
label: label.to_string(),
|
||||||
|
plist,
|
||||||
|
log,
|
||||||
|
state,
|
||||||
|
program,
|
||||||
|
path_env,
|
||||||
|
snapshotted,
|
||||||
|
pid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plist on disk is the authority on "installed"; the crate's verdict only
|
||||||
|
/// distinguishes running from stopped. A plist that exists but is not loaded
|
||||||
|
/// makes `launchctl print` answer `NotInstalled`, which must not erase the
|
||||||
|
/// plist-derived facts a user needs to debug that exact state.
|
||||||
|
fn installed_state(reported: &ServiceStatus) -> AgentState {
|
||||||
|
match reported {
|
||||||
|
ServiceStatus::Running => AgentState::Running,
|
||||||
|
ServiceStatus::Stopped(_) | ServiceStatus::NotInstalled => AgentState::Stopped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn status(label: &str, pidfile: &Path, log: &Path) -> Result<AgentStatus> {
|
||||||
|
let plist = plist_path_for(label)?;
|
||||||
|
|
||||||
|
if !plist.exists() {
|
||||||
|
return Ok(not_installed_status(label, plist, log.to_path_buf()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let reported = query_state(label)?;
|
||||||
|
|
||||||
|
Ok(build_status(
|
||||||
|
label,
|
||||||
|
plist,
|
||||||
|
log.to_path_buf(),
|
||||||
|
pidfile,
|
||||||
|
installed_state(&reported),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_state(label: &str) -> Result<ServiceStatus> {
|
||||||
|
let parsed: ServiceLabel = label.parse().context("parse service label")?;
|
||||||
|
|
||||||
|
manager()
|
||||||
|
.status(ServiceStatusCtx { label: parsed })
|
||||||
|
.context("query launchd for agent status")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_plist_fields(plist: &Path) -> (Option<PathBuf>, Option<String>) {
|
||||||
|
let Ok(value) = plist::Value::from_file(plist) else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let dict = value.as_dictionary();
|
||||||
|
|
||||||
|
let program = dict
|
||||||
|
.and_then(|dict| dict.get("ProgramArguments"))
|
||||||
|
.and_then(plist::Value::as_array)
|
||||||
|
.and_then(|args| args.first())
|
||||||
|
.and_then(plist::Value::as_string)
|
||||||
|
.map(PathBuf::from);
|
||||||
|
|
||||||
|
let path_env = dict
|
||||||
|
.and_then(|dict| dict.get("EnvironmentVariables"))
|
||||||
|
.and_then(plist::Value::as_dictionary)
|
||||||
|
.and_then(|env| env.get("PATH"))
|
||||||
|
.and_then(plist::Value::as_string)
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
(program, path_env)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launchctl(verb: &str, plist: &Path) -> Result<()> {
|
||||||
|
let output = std::process::Command::new("launchctl")
|
||||||
|
.arg(verb)
|
||||||
|
.arg(plist)
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("run launchctl {verb}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(launchctl_error(verb, &output.stdout, &output.stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launchctl_error(verb: &str, stdout: &[u8], stderr: &[u8]) -> anyhow::Error {
|
||||||
|
let stdout = String::from_utf8_lossy(stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(stderr);
|
||||||
|
|
||||||
|
let detail: Vec<&str> = [stdout.trim(), stderr.trim()]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
anyhow::anyhow!("launchctl {verb} failed: {}", detail.join("; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `service-manager` writes `Disabled: true` into every plist carrying
|
||||||
|
/// `KeepAlive`, and `launchctl load` honours it while still exiting 0. Removing
|
||||||
|
/// the key is what actually makes the agent loadable, so do it on every start —
|
||||||
|
/// that also heals a plist left disabled by an earlier install.
|
||||||
|
fn enable_plist(plist: &Path) -> Result<()> {
|
||||||
|
let mut value = plist::Value::from_file(plist)
|
||||||
|
.with_context(|| format!("read plist {}", plist.display()))?;
|
||||||
|
|
||||||
|
let Some(dict) = value.as_dictionary_mut() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if dict.remove("Disabled").is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
value
|
||||||
|
.to_file_xml(plist)
|
||||||
|
.with_context(|| format!("rewrite plist {}", plist.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn start(label: &str) -> Result<()> {
|
||||||
|
let plist = plist_path_for(label)?;
|
||||||
|
|
||||||
|
enable_plist(&plist)?;
|
||||||
|
|
||||||
|
launchctl("load", &plist)?;
|
||||||
|
|
||||||
|
// `launchctl load` reports its own failures only on stderr and still exits
|
||||||
|
// 0, so the load is confirmed by asking launchd whether the job now exists.
|
||||||
|
if query_state(label)? == ServiceStatus::NotInstalled {
|
||||||
|
anyhow::bail!(
|
||||||
|
"launchctl load left {label} unregistered; inspect {} and `launchctl print gui/$UID/{label}`",
|
||||||
|
plist.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stop(label: &str) -> Result<()> {
|
||||||
|
launchctl("unload", &plist_path_for(label)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_spec() -> AgentSpec {
|
||||||
|
AgentSpec {
|
||||||
|
label: "se.aceofba.xy-test".to_string(),
|
||||||
|
program: PathBuf::from("/usr/local/bin/xy"),
|
||||||
|
args: vec!["daemon".to_string()],
|
||||||
|
path_env: "/usr/local/bin:/usr/bin".to_string(),
|
||||||
|
working_dir: PathBuf::from("/Users/someone"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_ctx_maps_every_field() {
|
||||||
|
let ctx = sample_spec().install_ctx().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(ctx.label.to_qualified_name(), "se.aceofba.xy-test");
|
||||||
|
assert_eq!(ctx.program, PathBuf::from("/usr/local/bin/xy"));
|
||||||
|
assert_eq!(ctx.args, vec![std::ffi::OsString::from("daemon")]);
|
||||||
|
assert_eq!(ctx.working_directory, Some(PathBuf::from("/Users/someone")));
|
||||||
|
assert!(ctx.autostart);
|
||||||
|
assert_eq!(
|
||||||
|
ctx.environment,
|
||||||
|
Some(vec![(
|
||||||
|
"PATH".to_string(),
|
||||||
|
"/usr/local/bin:/usr/bin".to_string()
|
||||||
|
)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_ctx_uses_always_restart_without_delay() {
|
||||||
|
let ctx = sample_spec().install_ctx().unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
ctx.restart_policy,
|
||||||
|
RestartPolicy::Always { delay_secs: None }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_ctx_supplies_no_raw_contents() {
|
||||||
|
let ctx = sample_spec().install_ctx().unwrap();
|
||||||
|
|
||||||
|
assert!(ctx.contents.is_none());
|
||||||
|
assert!(ctx.username.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_tree_paths_are_detected() {
|
||||||
|
assert!(is_build_tree_path(Path::new("/home/me/xy/target/debug/xy")));
|
||||||
|
assert!(is_build_tree_path(Path::new(
|
||||||
|
"/home/me/xy/target/release/xy"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installed_paths_are_not_build_tree_paths() {
|
||||||
|
assert!(!is_build_tree_path(Path::new("/Users/me/.cargo/bin/xy")));
|
||||||
|
assert!(!is_build_tree_path(Path::new("/usr/local/bin/xy")));
|
||||||
|
assert!(!is_build_tree_path(Path::new("/opt/targeted/bin/xy")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plist_path_sits_in_user_launch_agents() {
|
||||||
|
let path = sample_spec().plist_path().unwrap();
|
||||||
|
|
||||||
|
assert!(path.ends_with("Library/LaunchAgents/se.aceofba.xy-test.plist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn macos_is_supported() {
|
||||||
|
assert!(ensure_supported().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn other_platforms_are_rejected() {
|
||||||
|
let err = ensure_supported().unwrap_err().to_string();
|
||||||
|
|
||||||
|
assert!(err.contains("macOS-only"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_reports_not_installed_when_plist_is_absent() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let status = status(
|
||||||
|
"se.aceofba.xy-absent",
|
||||||
|
&tmp.path().join("xy.pid"),
|
||||||
|
&tmp.path().join("daemon.log"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(status.state, AgentState::NotInstalled));
|
||||||
|
assert!(status.program.is_none());
|
||||||
|
assert!(status.path_env.is_none());
|
||||||
|
assert!(status.snapshotted.is_none());
|
||||||
|
assert!(status.pid.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_carries_the_log_path_through() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let log = tmp.path().join("daemon.log");
|
||||||
|
|
||||||
|
let status = status("se.aceofba.xy-absent", &tmp.path().join("xy.pid"), &log).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(status.log, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_pid_parses_a_pidfile() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let pidfile = tmp.path().join("xy.pid");
|
||||||
|
|
||||||
|
std::fs::write(&pidfile, "4821\n").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_pid(&pidfile), Some(4821));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_pid_returns_none_for_missing_or_garbage() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let missing = tmp.path().join("nope.pid");
|
||||||
|
let garbage = tmp.path().join("garbage.pid");
|
||||||
|
|
||||||
|
std::fs::write(&garbage, "not-a-pid").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_pid(&missing), None);
|
||||||
|
assert_eq!(read_pid(&garbage), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_agent_plist(path: &Path, program: &str, path_env: Option<&str>) {
|
||||||
|
let mut dict = plist::Dictionary::new();
|
||||||
|
|
||||||
|
dict.insert(
|
||||||
|
"ProgramArguments".to_string(),
|
||||||
|
plist::Value::Array(vec![plist::Value::String(program.to_string())]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(path_env) = path_env {
|
||||||
|
let mut env = plist::Dictionary::new();
|
||||||
|
env.insert(
|
||||||
|
"PATH".to_string(),
|
||||||
|
plist::Value::String(path_env.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
dict.insert(
|
||||||
|
"EnvironmentVariables".to_string(),
|
||||||
|
plist::Value::Dictionary(env),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
plist::Value::Dictionary(dict).to_file_xml(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_plist_fields_unescapes_xml_entities() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("agent.plist");
|
||||||
|
|
||||||
|
write_agent_plist(
|
||||||
|
&plist_path,
|
||||||
|
"/usr/local/bin/xy",
|
||||||
|
Some("/usr/bin:/opt/a&b:/opt/<c>"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (program, path_env) = read_plist_fields(&plist_path);
|
||||||
|
|
||||||
|
assert_eq!(program, Some(PathBuf::from("/usr/local/bin/xy")));
|
||||||
|
assert_eq!(path_env, Some("/usr/bin:/opt/a&b:/opt/<c>".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_plist_fields_on_malformed_plist_returns_none_without_panicking() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("broken.plist");
|
||||||
|
|
||||||
|
std::fs::write(&plist_path, "<plist><dict><key>ProgramArgum").unwrap();
|
||||||
|
|
||||||
|
let (program, path_env) = read_plist_fields(&plist_path);
|
||||||
|
|
||||||
|
assert!(program.is_none());
|
||||||
|
assert!(path_env.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launchctl_error_includes_both_streams_when_populated() {
|
||||||
|
let err = launchctl_error(
|
||||||
|
"unload",
|
||||||
|
b"out text",
|
||||||
|
b"Unload failed: 5: Input/output error",
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = err.to_string();
|
||||||
|
|
||||||
|
assert!(message.contains("out text"));
|
||||||
|
assert!(message.contains("Unload failed: 5: Input/output error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launchctl_error_omits_dangling_separator_when_stdout_empty() {
|
||||||
|
let err = launchctl_error("load", b"", b"boom");
|
||||||
|
|
||||||
|
assert_eq!(err.to_string(), "launchctl load failed: boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unloaded_plist_is_stopped_not_missing_and_keeps_its_facts() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("agent.plist");
|
||||||
|
|
||||||
|
write_agent_plist(&plist_path, "/usr/local/bin/xy", Some("/usr/bin"));
|
||||||
|
|
||||||
|
let pidfile = tmp.path().join("xy.pid");
|
||||||
|
std::fs::write(&pidfile, "4821").unwrap();
|
||||||
|
|
||||||
|
let state = installed_state(&ServiceStatus::NotInstalled);
|
||||||
|
|
||||||
|
assert_eq!(state, AgentState::Stopped);
|
||||||
|
|
||||||
|
let status = build_status(
|
||||||
|
"se.aceofba.xy-ghost",
|
||||||
|
plist_path,
|
||||||
|
tmp.path().join("daemon.log"),
|
||||||
|
&pidfile,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(status.program, Some(PathBuf::from("/usr/local/bin/xy")));
|
||||||
|
assert_eq!(status.path_env, Some("/usr/bin".to_string()));
|
||||||
|
assert!(status.snapshotted.is_some());
|
||||||
|
assert!(status.pid.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installed_state_reports_running_only_for_a_running_job() {
|
||||||
|
assert_eq!(
|
||||||
|
installed_state(&ServiceStatus::Running),
|
||||||
|
AgentState::Running
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
installed_state(&ServiceStatus::Stopped(None)),
|
||||||
|
AgentState::Stopped
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_disabled_agent_plist(path: &Path, disabled: Option<bool>) {
|
||||||
|
let mut env = plist::Dictionary::new();
|
||||||
|
env.insert(
|
||||||
|
"PATH".to_string(),
|
||||||
|
plist::Value::String("/opt/homebrew/bin:/usr/bin".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut dict = plist::Dictionary::new();
|
||||||
|
dict.insert(
|
||||||
|
"Label".to_string(),
|
||||||
|
plist::Value::String("se.x".to_string()),
|
||||||
|
);
|
||||||
|
dict.insert(
|
||||||
|
"ProgramArguments".to_string(),
|
||||||
|
plist::Value::Array(vec![
|
||||||
|
plist::Value::String("/usr/local/bin/xy".to_string()),
|
||||||
|
plist::Value::String("daemon".to_string()),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
dict.insert(
|
||||||
|
"EnvironmentVariables".to_string(),
|
||||||
|
plist::Value::Dictionary(env),
|
||||||
|
);
|
||||||
|
dict.insert("KeepAlive".to_string(), plist::Value::Boolean(true));
|
||||||
|
dict.insert("RunAtLoad".to_string(), plist::Value::Boolean(true));
|
||||||
|
|
||||||
|
if let Some(disabled) = disabled {
|
||||||
|
dict.insert("Disabled".to_string(), plist::Value::Boolean(disabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
plist::Value::Dictionary(dict).to_file_xml(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plist_dict(path: &Path) -> plist::Dictionary {
|
||||||
|
plist::Value::from_file(path)
|
||||||
|
.unwrap()
|
||||||
|
.into_dictionary()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enable_plist_removes_disabled_and_preserves_every_other_key() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("agent.plist");
|
||||||
|
|
||||||
|
write_disabled_agent_plist(&plist_path, Some(true));
|
||||||
|
|
||||||
|
enable_plist(&plist_path).unwrap();
|
||||||
|
|
||||||
|
let dict = plist_dict(&plist_path);
|
||||||
|
|
||||||
|
assert!(!dict.contains_key("Disabled"));
|
||||||
|
assert_eq!(dict.get("Label").unwrap().as_string(), Some("se.x"));
|
||||||
|
assert_eq!(dict.get("KeepAlive").unwrap().as_boolean(), Some(true));
|
||||||
|
assert_eq!(dict.get("RunAtLoad").unwrap().as_boolean(), Some(true));
|
||||||
|
|
||||||
|
let args: Vec<&str> = dict
|
||||||
|
.get("ProgramArguments")
|
||||||
|
.and_then(plist::Value::as_array)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(plist::Value::as_string)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(args, vec!["/usr/local/bin/xy", "daemon"]);
|
||||||
|
|
||||||
|
let path_env = dict
|
||||||
|
.get("EnvironmentVariables")
|
||||||
|
.and_then(plist::Value::as_dictionary)
|
||||||
|
.and_then(|env| env.get("PATH"))
|
||||||
|
.and_then(plist::Value::as_string);
|
||||||
|
|
||||||
|
assert_eq!(path_env, Some("/opt/homebrew/bin:/usr/bin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enable_plist_is_a_no_op_when_disabled_is_absent() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("agent.plist");
|
||||||
|
|
||||||
|
write_disabled_agent_plist(&plist_path, None);
|
||||||
|
|
||||||
|
let before = std::fs::read(&plist_path).unwrap();
|
||||||
|
|
||||||
|
enable_plist(&plist_path).unwrap();
|
||||||
|
|
||||||
|
let after = std::fs::read(&plist_path).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enable_plist_is_idempotent() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("agent.plist");
|
||||||
|
|
||||||
|
write_disabled_agent_plist(&plist_path, Some(true));
|
||||||
|
|
||||||
|
enable_plist(&plist_path).unwrap();
|
||||||
|
|
||||||
|
let once = std::fs::read(&plist_path).unwrap();
|
||||||
|
|
||||||
|
enable_plist(&plist_path).unwrap();
|
||||||
|
|
||||||
|
let twice = std::fs::read(&plist_path).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(once, twice);
|
||||||
|
assert!(!plist_dict(&plist_path).contains_key("Disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enable_plist_errors_on_an_unreadable_plist() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("broken.plist");
|
||||||
|
|
||||||
|
std::fs::write(&plist_path, "<plist><dict><key>Disab").unwrap();
|
||||||
|
|
||||||
|
let err = enable_plist(&plist_path).unwrap_err().to_string();
|
||||||
|
|
||||||
|
assert!(err.contains("read plist"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ impl Harness {
|
|||||||
|
|
||||||
pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) {
|
pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) {
|
||||||
let body = format!(
|
let body = format!(
|
||||||
"command \"{command}\"\nport {port}\nrestart {{ policy \"{restart_policy}\" backoff-initial \"10ms\" backoff-max \"50ms\" max-retries-per-minute 3 }}\nstop {{ grace \"500ms\" }}\n"
|
"command \"{command}\"\nport {port}\nrestart {{\n policy \"{restart_policy}\"\n backoff-initial \"10ms\"\n backoff-max \"50ms\"\n max-retries-per-minute 3\n}}\nstop {{ grace \"500ms\" }}\n"
|
||||||
);
|
);
|
||||||
std::fs::write(self.config_dir.join(format!("{name}.kdl")), body).unwrap();
|
std::fs::write(self.config_dir.join(format!("{name}.kdl")), body).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fatal_startup_error_is_written_to_daemon_log() {
|
||||||
|
let xy = xy_bin();
|
||||||
|
let mut h = Harness::new();
|
||||||
|
|
||||||
|
h.start_daemon(&xy).await;
|
||||||
|
|
||||||
|
let log = h.state_dir.join("logs/daemon.log");
|
||||||
|
|
||||||
|
let before = std::fs::read_to_string(&log).unwrap_or_default();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!before.contains("another xy daemon"),
|
||||||
|
"log already reports contention before the second daemon ran: {before}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (code, _out, _err) = h.run_cli(&xy, &["daemon"]).await;
|
||||||
|
|
||||||
|
assert_eq!(code, 1, "second daemon should exit 1 on pidfile contention");
|
||||||
|
|
||||||
|
let after = std::fs::read_to_string(&log).expect("daemon.log should exist");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
after.contains("another xy daemon"),
|
||||||
|
"daemon.log must record why the daemon refused to start, got: {after:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn uptime_grows_while_a_server_stays_running() {
|
||||||
|
let xy = xy_bin();
|
||||||
|
let sleeper = sleep_server_bin();
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.write_server("alpha", sleeper.to_str().unwrap(), 19_101, "always");
|
||||||
|
h.start_daemon(&xy).await;
|
||||||
|
|
||||||
|
for _ in 0..40 {
|
||||||
|
let (_c, out, _e) = h.run_cli(&xy, &["list"]).await;
|
||||||
|
if out.contains("alpha") && out.contains("running") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1_200)).await;
|
||||||
|
|
||||||
|
let (code, out, _e) = h.run_cli(&xy, &["list"]).await;
|
||||||
|
|
||||||
|
assert_eq!(code, 0);
|
||||||
|
|
||||||
|
let row = out
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("alpha"))
|
||||||
|
.unwrap_or_else(|| panic!("no alpha row in:\n{out}"));
|
||||||
|
|
||||||
|
let uptime = row
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(4)
|
||||||
|
.unwrap_or_else(|| panic!("no uptime column in row: {row:?}"));
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
uptime, "0s",
|
||||||
|
"a server running for over a second must not report 0s uptime; row: {row:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// The shared `write_server` helper emits no `wait-for` block, and six other
|
||||||
|
/// test files depend on its exact output, so this test writes its own config.
|
||||||
|
fn write_gated_server(h: &Harness, name: &str, command: &str, port: u16) {
|
||||||
|
// The keys inside a block need their own lines: KDL reads a run of values
|
||||||
|
// on one line as arguments to the first node, which silently leaves
|
||||||
|
// `timeout` and `interval` at their two-minute defaults.
|
||||||
|
let body = format!(
|
||||||
|
"command \"{command}\"\n\
|
||||||
|
port {port}\n\
|
||||||
|
restart {{\n policy \"never\"\n backoff-initial \"10ms\"\n backoff-max \"50ms\"\n max-retries-per-minute 3\n}}\n\
|
||||||
|
stop {{\n grace \"500ms\"\n}}\n\
|
||||||
|
wait-for {{\n path \"/definitely/not/here.sock\"\n timeout \"5s\"\n interval \"200ms\"\n}}\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(h.config_dir.join(format!("{name}.kdl")), body).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_gated_server_keeps_the_daemon_responsive_then_fails_without_spawning() {
|
||||||
|
let xy = xy_bin();
|
||||||
|
let sleeper = sleep_server_bin();
|
||||||
|
|
||||||
|
let mut h = Harness::new();
|
||||||
|
|
||||||
|
write_gated_server(&h, "gated", sleeper.to_str().unwrap(), 19_201);
|
||||||
|
|
||||||
|
h.start_daemon(&xy).await;
|
||||||
|
|
||||||
|
// The socket is bound before the autostart loop, so `start_daemon`
|
||||||
|
// returning proves nothing; only a completed round trip does.
|
||||||
|
let waiting = tokio::time::timeout(Duration::from_millis(2_000), async {
|
||||||
|
loop {
|
||||||
|
let (code, out, _err) = h.run_cli(&xy, &["list"]).await;
|
||||||
|
|
||||||
|
assert_eq!(code, 0, "stdout: {out}");
|
||||||
|
assert!(
|
||||||
|
!out.contains("running"),
|
||||||
|
"a gated server must not spawn; stdout: {out}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if out.contains("waiting") {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("`xy list` must answer well inside the 5s gate");
|
||||||
|
|
||||||
|
assert!(waiting.contains("gated"), "stdout: {waiting}");
|
||||||
|
|
||||||
|
let mut last = String::new();
|
||||||
|
|
||||||
|
for _ in 0..80 {
|
||||||
|
let (_code, out, _err) = h.run_cli(&xy, &["list"]).await;
|
||||||
|
|
||||||
|
last = out;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!last.contains("running"),
|
||||||
|
"a gated server must reach failed without ever spawning; stdout: {last}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if last.contains("failed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
last.contains("failed"),
|
||||||
|
"the gate must give up after its timeout; stdout: {last}"
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
|||||||
|
# xy — Start on Login (macOS)
|
||||||
|
|
||||||
|
**Date:** 2026-07-31
|
||||||
|
**Status:** Approved — ready for implementation planning
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The daemon must be launched by hand (`xy daemon`) and dies with the terminal
|
||||||
|
that started it. The MVP spec (2026-05-25) listed "auto-start at login via
|
||||||
|
launchd" as an explicit non-goal; this design delivers it.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Install a user-level launchd agent that starts `xy daemon` at login and
|
||||||
|
restarts it if it dies.
|
||||||
|
- Manage that agent from `xy` itself — install, uninstall, start, stop, status.
|
||||||
|
- Survive the environment launchd hands a login agent, which is far poorer than
|
||||||
|
an interactive shell's.
|
||||||
|
- Give the daemon a log file, so "it didn't come up at login" is diagnosable.
|
||||||
|
|
||||||
|
## Non-goals (deferred)
|
||||||
|
|
||||||
|
- Linux (systemd) and Windows support. The CLI shape is chosen so these are a
|
||||||
|
change inside one module, not a change to the command surface.
|
||||||
|
- System-level (root) agents. User agents only.
|
||||||
|
- Managing the agent over IPC. `xy service *` deliberately bypasses the daemon.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### launchd owns the daemon's lifecycle
|
||||||
|
|
||||||
|
Once installed, the LaunchAgent is the sanctioned way to run the daemon on
|
||||||
|
macOS: `RunAtLoad` starts it at login and `KeepAlive` restarts it on crash.
|
||||||
|
`xy daemon` remains available for foreground and development use; the existing
|
||||||
|
pidfile keeps the two from colliding.
|
||||||
|
|
||||||
|
### Environment: snapshot `PATH` at install time
|
||||||
|
|
||||||
|
A LaunchAgent inherits `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and nothing from
|
||||||
|
the user's shell. Supervised MCP servers inherit whatever the daemon has, and
|
||||||
|
servers launched through toolchain shims (`pnpx`, `bunx`) will not find their
|
||||||
|
interpreter under that PATH.
|
||||||
|
|
||||||
|
`xy service install` therefore captures the invoking shell's `PATH` and writes
|
||||||
|
it into the plist's `EnvironmentVariables`. The snapshot is deterministic and
|
||||||
|
visible in the plist, at the cost of going stale when a new toolchain is added
|
||||||
|
— `xy service install --force` re-snapshots.
|
||||||
|
|
||||||
|
Rejected: wrapping the daemon in `/bin/zsh -lc`, which makes login-time startup
|
||||||
|
depend on shell rc being fast and non-interactive-safe, and adds a process to
|
||||||
|
the tree for no gain here.
|
||||||
|
|
||||||
|
Verified 2026-07-31: `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_RUNTIME_DIR`
|
||||||
|
are all unset on this machine, so `etcetera`'s `Xdg` strategy resolves to
|
||||||
|
`~/.config` and `~/.local/state` identically for the CLI and for a
|
||||||
|
launchd-spawned daemon. There is no path-mismatch risk to design around.
|
||||||
|
|
||||||
|
### Third-party crate: `service-manager`
|
||||||
|
|
||||||
|
`service-manager = "0.11"` (469K downloads, 29 reverse deps, updated
|
||||||
|
2026-02-18) provides `LaunchdServiceManager::user()` targeting
|
||||||
|
`~/Library/LaunchAgents`, an install context carrying `environment`,
|
||||||
|
`autostart` and `restart_policy`, and a `status()` returning
|
||||||
|
`NotInstalled` / `Running` / `Stopped(Option<String>)`.
|
||||||
|
|
||||||
|
Rejected alternatives:
|
||||||
|
|
||||||
|
- **`auto-launch`** (4.6M downloads) is built for GUI applications at login. It
|
||||||
|
has no notion of `KeepAlive`, restart policy, or service status — the wrong
|
||||||
|
abstraction for a supervised daemon.
|
||||||
|
- **Hand-rolled plist + `launchctl`** would re-implement the crate and leave us
|
||||||
|
owning modern-vs-legacy `launchctl` compatibility.
|
||||||
|
|
||||||
|
### Use the crate's generated plist; log from inside the daemon
|
||||||
|
|
||||||
|
`LaunchdInstallConfig` exposes only `keep_alive` — there is no way to set
|
||||||
|
`StandardOutPath` or `StandardErrorPath` through the typed API. Rather than
|
||||||
|
bypass it with the `contents: Option<String>` escape hatch and hand-author plist
|
||||||
|
XML, the daemon gains its own log file (see below).
|
||||||
|
|
||||||
|
**Accepted limitation.** Failures occurring before the daemon's logger exists —
|
||||||
|
missing binary, dyld error, malformed plist — appear in neither `daemon.log` nor
|
||||||
|
`xy service status`. This is worse than first assumed: `LaunchdServiceManager::
|
||||||
|
status()` returns `ServiceStatus::Stopped(None)` unconditionally
|
||||||
|
(`launchd.rs:288`), so the `Option<String>` reason carried by the enum is always
|
||||||
|
`None` on macOS and cannot be surfaced. Documented recourse:
|
||||||
|
|
||||||
|
launchctl print gui/$UID/se.aceofba.xy
|
||||||
|
|
||||||
|
### Program path: `current_exe()`, with a warning
|
||||||
|
|
||||||
|
`xy service install` resolves and canonicalizes `std::env::current_exe()`. If
|
||||||
|
the result contains a `target/debug` or `target/release` path component, it
|
||||||
|
prints a warning that the agent will break on `cargo clean` and proceeds.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
`service-manager` is added to `[workspace.dependencies]` and consumed by the
|
||||||
|
`xy` crate alone. `xy-protocol`, `xy-supervisor` and `xy-ipc` are unchanged
|
||||||
|
except for one addition to `logs.rs` (below). Nothing crosses the IPC boundary:
|
||||||
|
`xy service *` manipulates launchd directly, which is what makes it work when
|
||||||
|
the daemon is dead.
|
||||||
|
|
||||||
|
Two new modules in `crates/xy`, Rust-2018 layout (`foo.rs` + `foo/`):
|
||||||
|
|
||||||
|
- **`src/service.rs`** — the OS-facing unit. Owns `AgentSpec` and thin
|
||||||
|
`install` / `uninstall` / `start` / `stop` / `status` functions that translate
|
||||||
|
it into `ServiceInstallCtx` and back out into an `AgentStatus`. Knows about
|
||||||
|
launchd; knows nothing about clap or printing. Returns data, never prints, so
|
||||||
|
it is testable without a terminal.
|
||||||
|
- **`src/cli/service.rs`** — the presentation unit. Parses the verbs, calls into
|
||||||
|
`service.rs`, formats human output, maps outcomes to exit codes. Mirrors the
|
||||||
|
existing `cli/mod.rs` / `cli/format.rs` split.
|
||||||
|
|
||||||
|
`main.rs` gains a `Service { #[command(subcommand)] verb: ServiceCmd }` arm.
|
||||||
|
|
||||||
|
`service.rs` wires `LaunchdServiceManager::user()` under
|
||||||
|
`cfg(target_os = "macos")`. On other targets the verbs return
|
||||||
|
`start-on-login is macOS-only for now` and exit 1.
|
||||||
|
|
||||||
|
## The agent
|
||||||
|
|
||||||
|
| `AgentSpec` field | Value | Plist key |
|
||||||
|
|---|---|---|
|
||||||
|
| `label` | `se.aceofba.xy` | `Label` |
|
||||||
|
| `program` | canonicalized `current_exe()` | `ProgramArguments[0]` |
|
||||||
|
| `args` | `["daemon"]` | `ProgramArguments[1..]` |
|
||||||
|
| `environment` | `[("PATH", <snapshot>)]` | `EnvironmentVariables` |
|
||||||
|
| `working_directory` | `$HOME` | `WorkingDirectory` |
|
||||||
|
| `autostart` | `true` | `RunAtLoad` |
|
||||||
|
| `restart_policy` | `Always { delay_secs: Some(10) }` | `KeepAlive` |
|
||||||
|
| `username` | `None` — runs as the invoking user | — |
|
||||||
|
|
||||||
|
Verified against `service-manager-0.11.0/src/launchd.rs`. `Always` emits
|
||||||
|
`KeepAlive: true` (a plain boolean); `OnFailure` instead emits a `KeepAlive`
|
||||||
|
*dictionary* with `SuccessfulExit: false`; `Never` omits the key. `delay_secs`
|
||||||
|
has no launchd equivalent and is discarded with a `log::warn!`. `Always` is
|
||||||
|
chosen deliberately: the daemon should come back regardless of how it exited.
|
||||||
|
`delay_secs` is set to `None`, since passing a value only produces a warning.
|
||||||
|
|
||||||
|
The label is the reverse-DNS form of the `git.aceofba.se` remote. It lives on
|
||||||
|
`AgentSpec` rather than being a constant so tests can install under a distinct
|
||||||
|
label.
|
||||||
|
|
||||||
|
`working_directory` is `$HOME` rather than launchd's default `/`, so that a
|
||||||
|
server config using a relative `working_dir` resolves somewhere predictable.
|
||||||
|
|
||||||
|
## Daemon logging
|
||||||
|
|
||||||
|
`main.rs` is reordered so `Paths::resolve()` and `ensure_dirs()` run *before*
|
||||||
|
`tracing` is initialised. Today `ensure_dirs()` is called inside `daemon::run`,
|
||||||
|
which is too late to open a log file for the logger itself.
|
||||||
|
|
||||||
|
For the `Cmd::Daemon` arm only, the subscriber writes to `stderr.and(file)` via
|
||||||
|
`MakeWriterExt`, where the file half is `log_dir/daemon.log` backed by the existing
|
||||||
|
`xy_supervisor::logs::RotatingLogWriter` (10 MB × 5, the same rotation used for
|
||||||
|
per-server logs). No adapter type is needed: `tracing-subscriber` 0.3
|
||||||
|
implements `MakeWriter` for `Mutex<W> where W: io::Write`
|
||||||
|
(`fmt/writer.rs:808`), so a plain `Mutex<RotatingLogWriter>` satisfies
|
||||||
|
`with_writer` once the `io::Write` impl lands.
|
||||||
|
|
||||||
|
**Corrected 2026-08-01.** An earlier draft of this section claimed
|
||||||
|
`Arc<Mutex<RotatingLogWriter>>` also satisfied the bound, via
|
||||||
|
`impl MakeWriter for Arc<W>`. That is false: the `Arc` impl
|
||||||
|
(`fmt/writer.rs:694`) requires `&'a W: io::Write`, and `&Mutex<W>` does not
|
||||||
|
implement `io::Write`. The claim survived into the implementation plan and cost
|
||||||
|
a fix round before being caught. The shipped code uses a bare `Mutex`.
|
||||||
|
|
||||||
|
Every other subcommand keeps stderr-only logging; CLI output does not belong in
|
||||||
|
the daemon's log.
|
||||||
|
|
||||||
|
This requires one targeted addition to existing code:
|
||||||
|
`impl std::io::Write for RotatingLogWriter` in `xy-supervisor/src/logs.rs`. The
|
||||||
|
type already tracks `written` and rotates, but today exposes only
|
||||||
|
`write_line(tag, line)`, which prefixes a tag the daemon's own log does not
|
||||||
|
want.
|
||||||
|
|
||||||
|
Result: `~/.local/state/xy/logs/` becomes uniform — `daemon.log` for the daemon,
|
||||||
|
`<server>.log` per supervised server, all rotated by the same code.
|
||||||
|
|
||||||
|
## launchd mechanics
|
||||||
|
|
||||||
|
Verified by reading `service-manager-0.11.0/src/launchd.rs`. Three behaviours
|
||||||
|
constrain the CLI and are not obvious from the crate's public documentation.
|
||||||
|
|
||||||
|
**`install()` deliberately produces a disabled agent.** Whenever `KeepAlive` is
|
||||||
|
present, `make_plist` also writes `Disabled: true` (`launchd.rs:440`) so that
|
||||||
|
`install()` never auto-starts, for cross-platform consistency. A `Disabled`
|
||||||
|
LaunchAgent does not start at login either, so **install alone does not deliver
|
||||||
|
start-on-login**. The crate's `start()` is what removes the `Disabled` key,
|
||||||
|
rewrites the plist, and reloads (`launchd.rs:179-194`). `xy service install`
|
||||||
|
therefore always calls `install()` *then* `start()`; after that the on-disk
|
||||||
|
plist is permanently free of `Disabled` and `RunAtLoad` works at next login.
|
||||||
|
|
||||||
|
There is no way to opt out: setting `LaunchdInstallConfig::keep_alive =
|
||||||
|
Some(true)` still takes the `has_keep_alive` branch that writes `Disabled`.
|
||||||
|
|
||||||
|
**The crate's `stop()` is unusable for our agent.** It runs `launchctl stop
|
||||||
|
<label>` (`launchd.rs:208`), which a `KeepAlive: true` service simply survives —
|
||||||
|
the crate's own doc comment says to call `uninstall` instead. Since uninstalling
|
||||||
|
would discard the `PATH` snapshot, `xy service start` and `xy service stop` are
|
||||||
|
implemented directly against `launchctl` on the plist path:
|
||||||
|
|
||||||
|
- `stop` → `launchctl unload <plist>`
|
||||||
|
- `start` → `launchctl load <plist>`
|
||||||
|
|
||||||
|
This pair is symmetric, and `start` works because `install` already stripped
|
||||||
|
`Disabled`. The crate is used for `install`, `uninstall` and `status` only.
|
||||||
|
|
||||||
|
**`install()`/`uninstall()` use the legacy verbs**, `launchctl load` and
|
||||||
|
`launchctl remove` — not `bootstrap`/`bootout`. CLI output says "loaded" rather
|
||||||
|
than "bootstrapped" to match what actually happens.
|
||||||
|
|
||||||
|
### Risk to verify during implementation
|
||||||
|
|
||||||
|
`status()` calls `launchctl print <bare-label>`, but user agents normally
|
||||||
|
require the `gui/$UID/<label>` form. The crate compensates with a two-pass
|
||||||
|
trick: on exit code 64 it scans stderr for a suggested fully-qualified label and
|
||||||
|
retries (`launchd.rs:235-276`). This is fragile and version-sensitive. Task 3
|
||||||
|
verifies it empirically against a real installed agent; if it proves unreliable,
|
||||||
|
the fallback is to run `launchctl print gui/$UID/<label>` ourselves and parse the
|
||||||
|
`state = running` line, which is what the crate is approximating anyway.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
xy service install [--force]
|
||||||
|
xy service uninstall
|
||||||
|
xy service start
|
||||||
|
xy service stop
|
||||||
|
xy service status
|
||||||
|
|
||||||
|
- **`install`** — exits 1 if the plist already exists at
|
||||||
|
`~/Library/LaunchAgents/se.aceofba.xy.plist`, directing the user to
|
||||||
|
`--force`. Presence of that file is the definition of "installed" throughout;
|
||||||
|
the crate's `ServiceStatus::NotInstalled` is treated as corroborating, not
|
||||||
|
authoritative, because it cannot distinguish a missing plist from an
|
||||||
|
unloadable one. With `--force`, uninstalls first, which re-snapshots `PATH` and
|
||||||
|
re-resolves `current_exe()`; this is also the upgrade path after installing a
|
||||||
|
new binary or adding a toolchain. Warns and proceeds on a build-tree program
|
||||||
|
path. On success it calls `install()` then `start()` — see *launchd
|
||||||
|
mechanics* — leaving the plist free of `Disabled` and the daemon running.
|
||||||
|
- **`uninstall`** — delegates to the crate, which runs `launchctl remove` and
|
||||||
|
deletes the plist. Not-installed is not an error: prints `not installed`,
|
||||||
|
exits 0, matching how `xy stop` already reports `not running`.
|
||||||
|
- **`start`** / **`stop`** — `launchctl load` and `launchctl unload` on the
|
||||||
|
plist path, leaving the plist in place. Both exit 1 if the agent is not
|
||||||
|
installed. `stop` on an already-stopped agent exits 0. Note that `stop` lasts
|
||||||
|
only until the next login, since `RunAtLoad` remains set; to disable
|
||||||
|
start-on-login permanently, use `uninstall`.
|
||||||
|
- **`status`** — reports label, plist path, state, program path, snapshotted
|
||||||
|
`PATH`, and log path. Never fails on state, only on an inability to query.
|
||||||
|
State is `running` / `stopped` / `not installed`, taken directly from
|
||||||
|
`ServiceStatus`. There is deliberately no separate "loaded" line, because the
|
||||||
|
crate's API cannot distinguish a loaded-but-stopped agent from an unloaded
|
||||||
|
one, and no reason string, because macOS always yields `Stopped(None)`. The
|
||||||
|
pid is read from the existing `paths.pidfile`; the crate does not expose one.
|
||||||
|
The snapshot date is the plist's mtime, not a value stored inside it.
|
||||||
|
|
||||||
|
Sample output:
|
||||||
|
|
||||||
|
$ xy service status
|
||||||
|
agent: se.aceofba.xy (user)
|
||||||
|
plist: ~/Library/LaunchAgents/se.aceofba.xy.plist
|
||||||
|
state: running (pid 4821)
|
||||||
|
program: /Users/olsson/.cargo/bin/xy
|
||||||
|
path: /opt/homebrew/bin:… (snapshotted 2026-07-31)
|
||||||
|
log: ~/.local/state/xy/logs/daemon.log
|
||||||
|
|
||||||
|
### Exit codes
|
||||||
|
|
||||||
|
Reuses the established scheme, minus the codes that cannot apply. `0` success,
|
||||||
|
`1` operational error (launchctl failed, agent missing, permission denied).
|
||||||
|
Code `2` (daemon unreachable) is structurally impossible because these commands
|
||||||
|
never open the socket. Code `3` is reachable only before dispatch: `main.rs`
|
||||||
|
returns it when `Paths::resolve()` fails, which happens ahead of every
|
||||||
|
subcommand including `xy service`. No `xy service` code path returns `3` itself.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
TDD throughout — failing test first, then minimal implementation.
|
||||||
|
|
||||||
|
- Unit tests in `service.rs` for the `AgentSpec` → `ServiceInstallCtx` mapping:
|
||||||
|
label parses, args are `["daemon"]`, the `PATH` snapshot is captured, `$HOME`
|
||||||
|
becomes the working directory. No launchd involved.
|
||||||
|
- Unit tests for the dev-build path predicate against a table of sample paths.
|
||||||
|
- Formatting tests in `cli/service.rs` rendering an `AgentStatus` to expected
|
||||||
|
text, mirroring the existing `cli/format.rs` tests.
|
||||||
|
- A test for `impl io::Write for RotatingLogWriter` covering byte accounting and
|
||||||
|
the rotation threshold. The rotation logic is currently exercised only through
|
||||||
|
`write_line`.
|
||||||
|
- `tests/service.rs` — a real install → status → stop → start → uninstall cycle,
|
||||||
|
marked `#[ignore]` and using the label `se.aceofba.xy-test` so that a stray
|
||||||
|
`cargo nextest run` can never install a live agent. Run manually with
|
||||||
|
`--ignored`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
`README.md` gains the five `xy service` verbs, a note that the agent snapshots
|
||||||
|
`PATH` at install time and that `--force` re-snapshots, and the
|
||||||
|
`launchctl print` recourse for pre-logger failures.
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# xy — Readiness Gate (`wait-for`)
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Status:** Approved — ready for implementation planning
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Since start-on-login landed, `xy daemon` runs from a launchd agent at login.
|
||||||
|
Docker-backed servers (`gitea`, `signoz`) race OrbStack and lose.
|
||||||
|
|
||||||
|
Measured on this machine 2026-08-07: OrbStack's process started at 12:47:56
|
||||||
|
and created `~/.orbstack/run/docker.sock` at **12:48:14** — 18 seconds later.
|
||||||
|
OrbStack is a BTM login item, not a LaunchAgent, so it is not even guaranteed
|
||||||
|
to begin before the agent does.
|
||||||
|
|
||||||
|
Against that, `docker run` fails instantly while the socket is absent, and the
|
||||||
|
default restart budget is `backoff-initial 1s`, `backoff-max 30s`,
|
||||||
|
`max-retries-per-minute 5`. Attempts therefore land at t=0, 1, 3, 7, 15s; the
|
||||||
|
fifth trips the cap and `policy::decide` returns `MarkFailed`, which is
|
||||||
|
terminal. **A docker-backed server gives up ~15 seconds after the daemon
|
||||||
|
starts** and stays down until a manual `xy start`.
|
||||||
|
|
||||||
|
The 2026-08-07 log shows this nearly firing during a reload: socket error at
|
||||||
|
10:47:58, recovery at 10:48:15 — 17 seconds, right at the edge.
|
||||||
|
|
||||||
|
Widening the retry budget per-server would paper over it. The real defect is
|
||||||
|
that a server with an unmet *infrastructure dependency* consumes its
|
||||||
|
crash-retry budget while waiting for something that was never its own fault.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Let a server declare a precondition that must hold before it is spawned.
|
||||||
|
- Waiting must never consume the crash-retry budget.
|
||||||
|
- Waiting must be visible and self-explaining, not indistinguishable from a hang.
|
||||||
|
- Waiting must stay responsive to `stop` / `shutdown`.
|
||||||
|
|
||||||
|
## Non-goals (deferred)
|
||||||
|
|
||||||
|
- Ordering between supervised servers (`depends-on <name>`). The conditions
|
||||||
|
here can express it (`tcp 127.0.0.1:3928`) without a dependency graph.
|
||||||
|
- Liveness probes on an already-running server. This gate is start-time only;
|
||||||
|
HTTP health probes remain a non-goal from the MVP spec.
|
||||||
|
- Re-checking the condition while a server runs.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Three condition kinds
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
wait-for {
|
||||||
|
path "~/.orbstack/run/docker.sock"
|
||||||
|
timeout "120s"
|
||||||
|
interval "1s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`path`, `tcp`, and `command` are all supported:
|
||||||
|
|
||||||
|
| Kind | Ready when |
|
||||||
|
|---|---|
|
||||||
|
| `path "<p>"` | the path exists |
|
||||||
|
| `tcp "<host:port>"` | a TCP connection succeeds |
|
||||||
|
| `command "<c>" args "…"` | the process exits 0 |
|
||||||
|
|
||||||
|
Exactly one condition per `wait-for` block. Zero or more than one is a parse
|
||||||
|
error, so `xy reload` exits 3 and changes nothing — matching how invalid
|
||||||
|
config already behaves.
|
||||||
|
|
||||||
|
`path` values get a leading `~/` expanded at parse time from `$HOME`, since the
|
||||||
|
motivating case is literally `~/.orbstack/run/docker.sock`. `$HOME` rather than
|
||||||
|
`etcetera::home_dir()` deliberately: `xy-protocol` is a leaf crate with no
|
||||||
|
directory dependencies, and adding one for a two-line prefix substitution is
|
||||||
|
not worth it. If `$HOME` is unset the literal path is kept, which simply never
|
||||||
|
exists and surfaces as a normal timeout. The expanded path is what `xy status`
|
||||||
|
displays, so there is no ambiguity about what was checked.
|
||||||
|
|
||||||
|
Note the KDL shape of the `command` kind: `command` and `args` are **sibling
|
||||||
|
nodes** inside the block, mirroring the top level, not one node with trailing
|
||||||
|
arguments:
|
||||||
|
|
||||||
|
wait-for {
|
||||||
|
command "docker"
|
||||||
|
args "info"
|
||||||
|
}
|
||||||
|
|
||||||
|
so the parser accumulates them separately and combines them after the loop.
|
||||||
|
`args` without `command` is a parse error.
|
||||||
|
|
||||||
|
The `command` kind is bounded by the poll `interval`: each invocation is run
|
||||||
|
under a timeout of `interval` and a non-exit is treated as not-ready, so a hung
|
||||||
|
`docker info` cannot wedge the poll loop.
|
||||||
|
|
||||||
|
### Timeout marks the server Failed without spawning
|
||||||
|
|
||||||
|
On timeout, the server goes to `Failed` and nothing is spawned. `xy status`
|
||||||
|
names the condition that timed out.
|
||||||
|
|
||||||
|
Rejected: spawning anyway on timeout, which reintroduces exactly the
|
||||||
|
retry-burn this feature removes; and waiting forever, which turns a typo in a
|
||||||
|
path into a server that sits in `waiting` with nothing ever surfacing.
|
||||||
|
|
||||||
|
### Waiting never touches the retry window
|
||||||
|
|
||||||
|
`RetryWindow::record` continues to be called only for real process exits. This
|
||||||
|
is the property that fixes the reported bug: a cold Docker costs patience, not
|
||||||
|
retry budget.
|
||||||
|
|
||||||
|
### Every start is gated
|
||||||
|
|
||||||
|
The gate runs for the initial start, the explicit `restart` command, and the
|
||||||
|
automatic post-crash respawn. If Docker dies and takes the container with it,
|
||||||
|
the respawn should wait for Docker again rather than burn retries.
|
||||||
|
|
||||||
|
### New `Waiting` state, reason on `status` only
|
||||||
|
|
||||||
|
`ServerState` gains `Waiting` — an additive enum variant, backward compatible
|
||||||
|
on the wire. `xy list` shows a plain `waiting` to keep the table narrow;
|
||||||
|
`xy status <name>` renders what it is blocked on and for how long:
|
||||||
|
|
||||||
|
$ xy status gitea
|
||||||
|
state: waiting
|
||||||
|
wait-for: path /Users/olsson/.orbstack/run/docker.sock
|
||||||
|
43s elapsed, timeout 120s
|
||||||
|
|
||||||
|
Rejected: reusing `Starting`, which is indistinguishable from a slow-starting
|
||||||
|
process — the exact ambiguity this feature exists to remove.
|
||||||
|
|
||||||
|
Elapsed time is computed at **read time** from a carried `Instant`, never
|
||||||
|
precomputed at publish time. This is the rule established by the 2026-08-07
|
||||||
|
uptime bug, where a value snapshotted into the watch channel froze at 0.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
`xy-protocol/src/config.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum WaitCondition {
|
||||||
|
Path(PathBuf),
|
||||||
|
Tcp(String),
|
||||||
|
Command { command: PathBuf, args: Vec<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WaitForConfig {
|
||||||
|
pub condition: WaitCondition,
|
||||||
|
pub timeout: Duration, // default 120s
|
||||||
|
pub interval: Duration, // default 1s
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ServerConfig` gains `#[serde(default)] pub wait_for: Option<WaitForConfig>`.
|
||||||
|
Absent `wait-for` means no gate and behaviour identical to today.
|
||||||
|
|
||||||
|
## Supervisor
|
||||||
|
|
||||||
|
The gate lives inside `do_start`, before the spawn. This is the one structural
|
||||||
|
change worth calling out: `do_start` currently returns
|
||||||
|
`std::io::Result<S::Child>`, which cannot express the gate's outcomes. It
|
||||||
|
becomes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
enum StartFailure {
|
||||||
|
Spawn(std::io::Error),
|
||||||
|
WaitTimedOut,
|
||||||
|
Cancelled, // Stop arrived while waiting
|
||||||
|
Shutdown, // Shutdown arrived while waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_start(&mut self, cause: StartCause) -> Result<S::Child, StartFailure>
|
||||||
|
```
|
||||||
|
|
||||||
|
All three call sites are updated. `Shutdown` must propagate a `return` from
|
||||||
|
`run`, exactly as the backoff sleep already does. `Cancelled` settles the
|
||||||
|
server in `Stopped` — a `stop` issued during a wait leaves it stopped, not
|
||||||
|
failed, since the user asked for it. `WaitTimedOut` settles in `Failed`.
|
||||||
|
|
||||||
|
**The poll loop is interruptible.** It selects over the interval sleep and
|
||||||
|
`self.cmd_rx`, the same shape as the existing backoff sleep (commit `b366df0`,
|
||||||
|
"make backoff sleep interruptible by Stop/Shutdown"). Without this, `xy stop`
|
||||||
|
on a waiting server would block for up to the full timeout.
|
||||||
|
|
||||||
|
`Status` carries the wait progress rather than a rendered string:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct WaitProgress {
|
||||||
|
pub description: String,
|
||||||
|
pub started_at: Instant,
|
||||||
|
pub timeout: Duration,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with `Status.waiting: Option<WaitProgress>`. Handlers derive `elapsed_secs`
|
||||||
|
when building the RPC response. `StatusDetail` gains
|
||||||
|
`wait_for: Option<WaitInfo { description, elapsed_secs, timeout_secs }>`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
TDD throughout.
|
||||||
|
|
||||||
|
- Config parse tests per condition kind; defaults applied; `~` expanded; zero
|
||||||
|
conditions and two conditions both rejected.
|
||||||
|
- Condition checks in isolation: path present/absent; TCP against a listener
|
||||||
|
bound to an ephemeral port, and against a closed port; command exiting 0,
|
||||||
|
exiting non-zero, and one that hangs past `interval`.
|
||||||
|
- Supervisor: condition already met spawns immediately; timeout yields `Failed`
|
||||||
|
with no spawn **and an untouched retry window** (the regression assertion for
|
||||||
|
the reported bug); `Stop` during a wait cancels promptly rather than after
|
||||||
|
the timeout; `Shutdown` during a wait returns.
|
||||||
|
- Formatting: `waiting` in the list table; `xy status` renders the description
|
||||||
|
and a *growing* elapsed value.
|
||||||
|
- Integration: a server whose `wait-for` path can never exist reaches `Failed`
|
||||||
|
after a short configured timeout without ever spawning.
|
||||||
|
|
||||||
|
Timeouts and intervals in tests are milliseconds, so no test waits on wall time.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
`README.md` gains a `wait-for` section with the three condition kinds and the
|
||||||
|
OrbStack example, plus a note that waiting does not consume the restart budget.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Once shipped, `gitea.kdl` and `signoz.kdl` each gain:
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
wait-for {
|
||||||
|
path "~/.orbstack/run/docker.sock"
|
||||||
|
timeout "180s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Until then those two servers remain vulnerable at every login. The interim
|
||||||
|
mitigation, if wanted, is `backoff-max "15s"` and `max-retries-per-minute 20`
|
||||||
|
in both files.
|
||||||
Reference in New Issue
Block a user