Compare commits
41
Commits
40ffd9b06c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3fe2e414b | ||
|
|
ace5bd6e87 | ||
|
|
e4eb1cc6a2 | ||
|
|
38e9af1e3d | ||
|
|
b500882257 | ||
|
|
7f605f361e | ||
|
|
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
+347
-330
File diff suppressed because it is too large
Load Diff
@@ -33,3 +33,5 @@ humantime-serde = "1"
|
||||
async-trait = "0.1"
|
||||
tempfile = "3"
|
||||
tokio-test = "0.4"
|
||||
service-manager = "0.11"
|
||||
plist = "1"
|
||||
|
||||
@@ -24,3 +24,66 @@ Commands:
|
||||
xy logs <name> [--tail N] [--follow]
|
||||
|
||||
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.
|
||||
|
||||
## Servers that read stdin
|
||||
|
||||
MCP servers are stdio-first by convention, and several that also speak HTTP
|
||||
still start their stdio transport unconditionally. Under the launchd agent the
|
||||
daemon's stdin is `/dev/null`, so such a server sees EOF on its first read and
|
||||
shuts down seconds after binding its port.
|
||||
|
||||
`stdin` picks what the child gets on fd 0:
|
||||
|
||||
stdin "keep-open"
|
||||
|
||||
- `inherit` (default) — the child inherits the daemon's stdin.
|
||||
- `null` — `/dev/null`; a read sees EOF immediately.
|
||||
- `keep-open` — a pipe that `xy` holds open for the child's lifetime and never
|
||||
writes to, so a read blocks instead of seeing EOF.
|
||||
|
||||
Use `keep-open` for a server you want supervised in HTTP mode that insists on
|
||||
running its stdio transport anyway.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -66,6 +66,19 @@ impl Default for StopConfig {
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum StdinMode {
|
||||
/// The child inherits the daemon's stdin. Today's behaviour.
|
||||
#[default]
|
||||
Inherit,
|
||||
/// The child gets `/dev/null`; a read sees EOF immediately.
|
||||
Null,
|
||||
/// The child gets a pipe that `xy` holds open and never writes to,
|
||||
/// so a read blocks instead of seeing EOF.
|
||||
KeepOpen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub name: String,
|
||||
@@ -81,6 +94,35 @@ pub struct ServerConfig {
|
||||
pub restart: RestartConfig,
|
||||
#[serde(default)]
|
||||
pub stop: StopConfig,
|
||||
#[serde(default)]
|
||||
pub wait_for: Option<WaitForConfig>,
|
||||
#[serde(default)]
|
||||
pub stdin: StdinMode,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::{ConfigError, RestartConfig, RestartPolicy, ServerConfig, StopConfig};
|
||||
use crate::{
|
||||
ConfigError, RestartConfig, RestartPolicy, ServerConfig, StdinMode, StopConfig, WaitCondition,
|
||||
WaitForConfig, default_wait_interval, default_wait_timeout,
|
||||
};
|
||||
use kdl::{KdlDocument, KdlNode};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -27,6 +30,8 @@ pub fn parse_server_config(
|
||||
let working_dir = optional_string_arg(&doc, "working-dir").map(PathBuf::from);
|
||||
let restart = parse_restart(&doc, source_path)?;
|
||||
let stop = parse_stop(&doc, source_path)?;
|
||||
let wait_for = parse_wait_for(&doc, source_path)?;
|
||||
let stdin = parse_stdin(&doc, source_path)?;
|
||||
|
||||
Ok(ServerConfig {
|
||||
name: name.to_string(),
|
||||
@@ -37,6 +42,8 @@ pub fn parse_server_config(
|
||||
working_dir,
|
||||
restart,
|
||||
stop,
|
||||
wait_for,
|
||||
stdin,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,6 +156,8 @@ fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, Config
|
||||
for child in children.nodes() {
|
||||
match child.name().value() {
|
||||
"policy" => {
|
||||
single_arg(child, "restart", path)?;
|
||||
|
||||
let s = string_arg(child, "policy", path)?;
|
||||
|
||||
out.policy = match s.as_str() {
|
||||
@@ -165,12 +174,18 @@ fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, Config
|
||||
};
|
||||
}
|
||||
"backoff-initial" => {
|
||||
single_arg(child, "restart", path)?;
|
||||
|
||||
out.backoff_initial = parse_duration_arg(child, "restart.backoff-initial", path)?;
|
||||
}
|
||||
"backoff-max" => {
|
||||
single_arg(child, "restart", path)?;
|
||||
|
||||
out.backoff_max = parse_duration_arg(child, "restart.backoff-max", path)?;
|
||||
}
|
||||
"max-retries-per-minute" => {
|
||||
single_arg(child, "restart", path)?;
|
||||
|
||||
let v = child
|
||||
.entries()
|
||||
.first()
|
||||
@@ -201,6 +216,27 @@ fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, Config
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_stdin(doc: &KdlDocument, path: &Path) -> Result<StdinMode, ConfigError> {
|
||||
let Some(node) = find_node(doc, "stdin") else {
|
||||
return Ok(StdinMode::default());
|
||||
};
|
||||
|
||||
single_arg(node, "stdin", path)?;
|
||||
|
||||
let s = string_arg(node, "stdin", path)?;
|
||||
|
||||
match s.as_str() {
|
||||
"inherit" => Ok(StdinMode::Inherit),
|
||||
"null" => Ok(StdinMode::Null),
|
||||
"keep-open" => Ok(StdinMode::KeepOpen),
|
||||
other => Err(ConfigError::InvalidValue {
|
||||
path: path.to_path_buf(),
|
||||
field: "stdin",
|
||||
message: format!("unknown stdin mode `{other}`"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError> {
|
||||
let Some(node) = find_node(doc, "stop") else {
|
||||
return Ok(StopConfig::default());
|
||||
@@ -215,6 +251,8 @@ fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError>
|
||||
for child in children.nodes() {
|
||||
match child.name().value() {
|
||||
"grace" => {
|
||||
single_arg(child, "stop", path)?;
|
||||
|
||||
out.grace = parse_duration_arg(child, "stop.grace", path)?;
|
||||
}
|
||||
other => {
|
||||
@@ -230,6 +268,135 @@ fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError>
|
||||
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> {
|
||||
node.entries()
|
||||
.first()
|
||||
@@ -444,4 +611,280 @@ stop {
|
||||
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"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_defaults_to_inherit() {
|
||||
let text = "command \"/bin/x\"\nport 1";
|
||||
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||
|
||||
assert_eq!(cfg.stdin, StdinMode::Inherit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_stdin_inherit() {
|
||||
let text = "command \"/bin/x\"\nport 1\nstdin \"inherit\"";
|
||||
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||
|
||||
assert_eq!(cfg.stdin, StdinMode::Inherit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_stdin_null() {
|
||||
let text = "command \"/bin/x\"\nport 1\nstdin \"null\"";
|
||||
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||
|
||||
assert_eq!(cfg.stdin, StdinMode::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_stdin_keep_open() {
|
||||
let text = "command \"/bin/x\"\nport 1\nstdin \"keep-open\"";
|
||||
let cfg = parse_server_config("foo", text, p()).unwrap();
|
||||
|
||||
assert_eq!(cfg.stdin, StdinMode::KeepOpen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stdin_mode_fails() {
|
||||
let text = "command \"/bin/x\"\nport 1\nstdin \"maybe\"";
|
||||
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ConfigError::InvalidValue { field: "stdin", .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_with_two_args_fails() {
|
||||
let text = "command \"/bin/x\"\nport 1\nstdin \"null\" \"keep-open\"";
|
||||
let err = parse_server_config("foo", text, p()).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ConfigError::InvalidValue { field: "stdin", .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ pub mod kdl_parse;
|
||||
pub mod rpc;
|
||||
pub mod state;
|
||||
|
||||
pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig};
|
||||
pub use config::{
|
||||
RestartConfig, RestartPolicy, ServerConfig, StdinMode, StopConfig, WaitCondition,
|
||||
WaitForConfig, default_wait_interval, default_wait_timeout,
|
||||
};
|
||||
pub use error::{ConfigError, RpcErrorCode};
|
||||
pub use kdl_parse::{load_all_configs, parse_server_config};
|
||||
pub use state::ServerState;
|
||||
|
||||
@@ -20,6 +20,15 @@ pub struct StatusDetail {
|
||||
#[serde(flatten)]
|
||||
pub summary: ServerSummary,
|
||||
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)]
|
||||
|
||||
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ServerState {
|
||||
Stopped,
|
||||
Waiting,
|
||||
Starting,
|
||||
Running,
|
||||
Restarting,
|
||||
@@ -28,4 +29,15 @@ mod tests {
|
||||
let s: ServerState = serde_json::from_str("\"failed\"").unwrap();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,13 +76,18 @@ use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child as TokioChild, Command};
|
||||
use xy_protocol::{ServerConfig, rpc::LogStream};
|
||||
use tokio::process::{Child as TokioChild, ChildStdin, Command};
|
||||
use xy_protocol::{ServerConfig, StdinMode, rpc::LogStream};
|
||||
|
||||
pub struct RealChild {
|
||||
pid: u32,
|
||||
pgid: Pid,
|
||||
child: Option<TokioChild>,
|
||||
/// Write end of the child's stdin pipe under [`StdinMode::KeepOpen`].
|
||||
/// Never written to; held so the child blocks on read rather than
|
||||
/// seeing EOF. `TokioChild::wait()` drops the handle it owns, so the
|
||||
/// pipe must live out here to outlast supervision.
|
||||
_stdin: Option<ChildStdin>,
|
||||
}
|
||||
|
||||
impl RealChild {
|
||||
@@ -132,6 +137,16 @@ pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result<Rea
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
|
||||
match cfg.stdin {
|
||||
StdinMode::Inherit => {}
|
||||
StdinMode::Null => {
|
||||
cmd.stdin(Stdio::null());
|
||||
}
|
||||
StdinMode::KeepOpen => {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
}
|
||||
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -149,6 +164,8 @@ pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result<Rea
|
||||
let pid = child.id().ok_or_else(|| std::io::Error::other("no pid"))?;
|
||||
let pgid = Pid::from_raw(pid as i32);
|
||||
|
||||
let stdin = child.stdin.take();
|
||||
|
||||
if let Some(out) = child.stdout.take() {
|
||||
spawn_pump(out, sink.clone(), LogStream::Stdout);
|
||||
}
|
||||
@@ -161,6 +178,7 @@ pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result<Rea
|
||||
pid,
|
||||
pgid,
|
||||
child: Some(child),
|
||||
_stdin: stdin,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,4 +226,65 @@ mod tests {
|
||||
child.terminate().unwrap();
|
||||
ctl.terminate_rx.try_recv().unwrap();
|
||||
}
|
||||
|
||||
use crate::logs::RotatingLogWriter;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
use xy_protocol::{RestartConfig, StdinMode, StopConfig};
|
||||
|
||||
fn reader_cfg(stdin: StdinMode, script: &str) -> ServerConfig {
|
||||
ServerConfig {
|
||||
name: "reader".to_string(),
|
||||
command: "/bin/sh".into(),
|
||||
args: vec!["-c".into(), script.to_string()],
|
||||
port: 1,
|
||||
env: Default::default(),
|
||||
working_dir: None,
|
||||
restart: RestartConfig::default(),
|
||||
stop: StopConfig::default(),
|
||||
wait_for: None,
|
||||
stdin,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_sink() -> LogSink {
|
||||
let dir = tempdir().unwrap();
|
||||
let writer = RotatingLogWriter::open(&dir.path().join("s.log"), 1024, 3).unwrap();
|
||||
std::mem::forget(dir);
|
||||
LogSink::new("reader".to_string(), writer, 1024)
|
||||
}
|
||||
|
||||
/// Exits 9 straight away unless fd 0 is a FIFO, so the assertion below
|
||||
/// cannot be satisfied by whatever stdin the test runner happened to have.
|
||||
const REQUIRE_PIPE_THEN_READ: &str = "[ -p /dev/stdin ] || exit 9; read line; exit 7";
|
||||
|
||||
#[tokio::test]
|
||||
async fn keep_open_gives_the_child_a_pipe_that_survives_wait() {
|
||||
let cfg = reader_cfg(StdinMode::KeepOpen, REQUIRE_PIPE_THEN_READ);
|
||||
let mut child = spawn_with_logs(&cfg, test_sink()).unwrap();
|
||||
|
||||
// `TokioChild::wait()` drops its own stdin handle on entry, so this
|
||||
// blocks only if `RealChild` took the handle and is still holding it.
|
||||
let outcome = tokio::time::timeout(Duration::from_millis(500), child.wait()).await;
|
||||
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"child should still be blocked on read, got {outcome:?}"
|
||||
);
|
||||
|
||||
child.kill().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn null_stdin_child_sees_eof_immediately() {
|
||||
let cfg = reader_cfg(StdinMode::Null, "read line; exit 7");
|
||||
let mut child = spawn_with_logs(&cfg, test_sink()).unwrap();
|
||||
|
||||
let code = tokio::time::timeout(Duration::from_millis(2000), child.wait())
|
||||
.await
|
||||
.expect("child should exit promptly on EOF")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(code, Some(7));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod backoff;
|
||||
pub mod child;
|
||||
pub mod logs;
|
||||
pub mod policy;
|
||||
mod ready;
|
||||
pub mod retry_window;
|
||||
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)]
|
||||
pub struct RingBuffer {
|
||||
inner: Arc<Mutex<RingBufferInner>>,
|
||||
@@ -260,4 +278,39 @@ mod tests {
|
||||
assert_eq!(got.stream, LogStream::Stdout);
|
||||
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<()>,
|
||||
},
|
||||
Reconfigure {
|
||||
new: ServerConfig,
|
||||
new: Box<ServerConfig>,
|
||||
ack: oneshot::Sender<()>,
|
||||
},
|
||||
Shutdown {
|
||||
@@ -43,14 +43,22 @@ pub enum StopAck {
|
||||
NotRunning,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WaitProgress {
|
||||
pub description: String,
|
||||
pub started_at: Instant,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Status {
|
||||
pub state: ServerState,
|
||||
pub pid: Option<u32>,
|
||||
pub port: u16,
|
||||
pub uptime_secs: Option<u64>,
|
||||
pub started_at: Option<Instant>,
|
||||
pub restart_count: u32,
|
||||
pub last_exit: Option<i32>,
|
||||
pub waiting: Option<WaitProgress>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -79,6 +87,7 @@ pub struct SupervisorTask<S: Spawner> {
|
||||
last_exit: Option<i32>,
|
||||
started_at: Option<Instant>,
|
||||
current_pid: Option<u32>,
|
||||
waiting: Option<WaitProgress>,
|
||||
}
|
||||
|
||||
impl<S: Spawner> SupervisorTask<S> {
|
||||
@@ -105,19 +114,19 @@ impl<S: Spawner> SupervisorTask<S> {
|
||||
last_exit: None,
|
||||
started_at: None,
|
||||
current_pid: None,
|
||||
waiting: None,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
state: s,
|
||||
pid: self.current_pid,
|
||||
port: self.cfg.port,
|
||||
uptime_secs,
|
||||
started_at: self.started_at,
|
||||
restart_count: self.restart_count,
|
||||
last_exit: self.last_exit,
|
||||
waiting: self.waiting.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,16 +143,30 @@ impl<S: Spawner> SupervisorTask<S> {
|
||||
if child.is_some() {
|
||||
let _ = ack.send(StartAck::AlreadyRunning);
|
||||
} else {
|
||||
match self.do_start().await {
|
||||
match self.do_start(StartCause::Initial).await {
|
||||
Ok(c) => {
|
||||
child = Some(c);
|
||||
let _ = ack.send(StartAck::Started);
|
||||
}
|
||||
Err(err) => {
|
||||
Err(StartFailure::Spawn(err)) => {
|
||||
warn!(name = %self.cfg.name, error = %err, "spawn failed");
|
||||
self.started_at = None;
|
||||
self.set_state(ServerState::Failed);
|
||||
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;
|
||||
}
|
||||
|
||||
match self.do_start().await {
|
||||
match self.do_start(StartCause::Restart).await {
|
||||
Ok(c) => child = Some(c),
|
||||
Err(err) => {
|
||||
Err(StartFailure::Spawn(err)) => {
|
||||
warn!(name = %self.cfg.name, error = %err, "restart spawn failed");
|
||||
self.started_at = None;
|
||||
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(());
|
||||
}
|
||||
SupervisorCmd::Reconfigure { new, ack } => {
|
||||
self.cfg = new;
|
||||
self.cfg = *new;
|
||||
self.backoff =
|
||||
Backoff::new(self.cfg.restart.backoff_initial, self.cfg.restart.backoff_max);
|
||||
self.retry_window = RetryWindow::new(
|
||||
@@ -250,7 +284,7 @@ impl<S: Spawner> SupervisorTask<S> {
|
||||
Action::RetryNow
|
||||
}
|
||||
Some(SupervisorCmd::Reconfigure { new, ack }) => {
|
||||
self.cfg = new;
|
||||
self.cfg = *new;
|
||||
self.backoff = Backoff::new(
|
||||
self.cfg.restart.backoff_initial,
|
||||
self.cfg.restart.backoff_max,
|
||||
@@ -267,12 +301,23 @@ impl<S: Spawner> SupervisorTask<S> {
|
||||
|
||||
match action {
|
||||
Action::RetryNow => {
|
||||
match self.do_start().await {
|
||||
match self.do_start(StartCause::Restart).await {
|
||||
Ok(c) => child = Some(c),
|
||||
Err(err) => {
|
||||
Err(StartFailure::Spawn(err)) => {
|
||||
warn!(name = %self.cfg.name, error = %err, "restart spawn failed");
|
||||
self.started_at = None;
|
||||
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 => {
|
||||
@@ -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);
|
||||
|
||||
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.current_pid = Some(c.pid());
|
||||
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;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -348,9 +499,10 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::child::MockChild;
|
||||
use crate::logs::{LogSink, RotatingLogWriter};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::tempdir;
|
||||
use xy_protocol::{RestartConfig, RestartPolicy, StopConfig};
|
||||
use xy_protocol::{RestartConfig, RestartPolicy, StopConfig, WaitCondition, WaitForConfig};
|
||||
|
||||
struct QueueSpawner {
|
||||
queue: Arc<Mutex<Vec<MockChild>>>,
|
||||
@@ -383,9 +535,17 @@ mod tests {
|
||||
stop: StopConfig {
|
||||
grace: Duration::from_millis(50),
|
||||
},
|
||||
wait_for: None,
|
||||
stdin: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let dir = tempdir().unwrap();
|
||||
let writer = RotatingLogWriter::open(&dir.path().join("s.log"), 1024, 3).unwrap();
|
||||
@@ -398,9 +558,23 @@ mod tests {
|
||||
state: ServerState::Stopped,
|
||||
pid: None,
|
||||
port: cfg.port,
|
||||
uptime_secs: None,
|
||||
started_at: None,
|
||||
restart_count: 0,
|
||||
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 +622,389 @@ mod tests {
|
||||
ack_rx.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ path = "src/bin/xy_test_sleep_server.rs"
|
||||
name = "xy-test-exit-failure"
|
||||
path = "src/bin/xy_test_exit_failure.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "xy-test-stdin-reader"
|
||||
path = "src/bin/xy_test_stdin_reader.rs"
|
||||
|
||||
[dependencies]
|
||||
xy-protocol.workspace = true
|
||||
xy-supervisor.workspace = true
|
||||
@@ -30,6 +34,8 @@ anyhow.workspace = true
|
||||
etcetera.workspace = true
|
||||
nix.workspace = true
|
||||
humantime.workspace = true
|
||||
service-manager = { workspace = true }
|
||||
plist = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Stands in for a stdio-first MCP server: it comes up, announces itself, then
|
||||
/// blocks reading stdin and exits the moment it sees EOF.
|
||||
fn main() {
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
println!("ready");
|
||||
std::io::stdout().flush().ok();
|
||||
|
||||
let mut line = String::new();
|
||||
match std::io::stdin().lock().read_line(&mut line) {
|
||||
Ok(0) => {
|
||||
eprintln!("stdin closed (EOF), shutting down");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Ok(_) => std::process::exit(1),
|
||||
Err(err) => {
|
||||
eprintln!("stdin read error: {err}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
-5
@@ -1,4 +1,4 @@
|
||||
use xy_protocol::rpc::ServerSummary;
|
||||
use xy_protocol::rpc::{ServerSummary, WaitInfo};
|
||||
|
||||
pub fn list_table(rows: &[ServerSummary]) -> String {
|
||||
let mut out = String::new();
|
||||
@@ -7,10 +7,7 @@ pub fn list_table(rows: &[ServerSummary]) -> String {
|
||||
|
||||
for r in rows {
|
||||
let pid = r.pid.map(|p| p.to_string()).unwrap_or_else(|| "-".into());
|
||||
let up = r
|
||||
.uptime_secs
|
||||
.map(|s| format!("{}s", s))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
let up = r.uptime_secs.map(uptime).unwrap_or_else(|| "-".into());
|
||||
|
||||
out.push_str(&format!(
|
||||
"{:<20}{:<12}{:<8}{:<8}{:<10}{}\n",
|
||||
@@ -25,3 +22,127 @@ pub fn list_table(rows: &[ServerSummary]) -> String {
|
||||
|
||||
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;
|
||||
pub mod service;
|
||||
|
||||
async fn connect(paths: &Paths) -> Result<Client> {
|
||||
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);
|
||||
|
||||
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::rpc::{
|
||||
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};
|
||||
|
||||
@@ -170,7 +170,7 @@ async fn list(reg: &Registry) -> Result<Vec<ServerSummary>, ApiError> {
|
||||
state: s.state,
|
||||
pid: s.pid,
|
||||
port: s.port,
|
||||
uptime_secs: s.uptime_secs,
|
||||
uptime_secs: s.started_at.map(|t| t.elapsed().as_secs()),
|
||||
restart_count: s.restart_count,
|
||||
last_exit: s.last_exit,
|
||||
});
|
||||
@@ -195,11 +195,16 @@ async fn status(reg: &Registry, name: &str) -> Result<StatusDetail, ApiError> {
|
||||
state: s.state,
|
||||
pid: s.pid,
|
||||
port: s.port,
|
||||
uptime_secs: s.uptime_secs,
|
||||
uptime_secs: s.started_at.map(|t| t.elapsed().as_secs()),
|
||||
restart_count: s.restart_count,
|
||||
last_exit: s.last_exit,
|
||||
},
|
||||
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,
|
||||
pid: None,
|
||||
port: cfg.port,
|
||||
uptime_secs: None,
|
||||
started_at: None,
|
||||
restart_count: 0,
|
||||
last_exit: None,
|
||||
waiting: None,
|
||||
};
|
||||
|
||||
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<()> {
|
||||
paths.ensure_dirs().context("create state dirs")?;
|
||||
|
||||
let _pid =
|
||||
PidFile::acquire(&paths.pidfile).context("another xy daemon appears to be running")?;
|
||||
|
||||
@@ -98,16 +97,12 @@ pub async fn run(paths: Paths) -> Result<()> {
|
||||
)
|
||||
.await;
|
||||
|
||||
let (ack_tx, ack_rx) = oneshot::channel();
|
||||
let (ack_tx, _ack_rx) = oneshot::channel();
|
||||
|
||||
if handle
|
||||
.tx
|
||||
.send(SupervisorCmd::Start { ack: ack_tx })
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let _ = ack_rx.await;
|
||||
}
|
||||
// Deliberately not awaiting the ack: a gated start blocks until its
|
||||
// wait-for condition resolves, and awaiting here would keep the accept
|
||||
// loop and the signal handler below from ever being installed.
|
||||
let _ = handle.tx.send(SupervisorCmd::Start { ack: ack_tx }).await;
|
||||
}
|
||||
|
||||
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 daemon;
|
||||
mod logging;
|
||||
mod paths;
|
||||
mod pidfile;
|
||||
mod service;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "xy", version, about = "HTTP MCP server supervisor")]
|
||||
@@ -51,27 +53,75 @@ enum Cmd {
|
||||
#[arg(short = 'f', long)]
|
||||
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]
|
||||
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 paths = match paths::Paths::resolve() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("xy: failed to resolve XDG paths: {e}");
|
||||
Err(err) => {
|
||||
eprintln!("xy: failed to resolve XDG paths: {err}");
|
||||
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 {
|
||||
Cmd::Daemon => daemon::run(paths).await.map(|_| 0),
|
||||
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::Reload => cli::reload(paths).await,
|
||||
Cmd::Logs { name, tail, follow } => cli::logs(paths, name, tail, follow).await,
|
||||
Cmd::Service { verb } => cli::service::run(paths, verb).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(code) => std::process::ExitCode::from(code as u8),
|
||||
Err(e) => {
|
||||
eprintln!("xy: {e:#}");
|
||||
Err(err) => {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::process::{Child, Command};
|
||||
use xy_ipc::Client;
|
||||
use xy_protocol::rpc::{ServerSummary, methods};
|
||||
|
||||
pub struct Harness {
|
||||
pub tmp: TempDir,
|
||||
@@ -35,8 +37,20 @@ impl Harness {
|
||||
}
|
||||
|
||||
pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) {
|
||||
self.write_server_with(name, command, port, restart_policy, "");
|
||||
}
|
||||
|
||||
/// `extra` is appended verbatim as additional top-level KDL.
|
||||
pub fn write_server_with(
|
||||
&self,
|
||||
name: &str,
|
||||
command: &str,
|
||||
port: u16,
|
||||
restart_policy: &str,
|
||||
extra: &str,
|
||||
) {
|
||||
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{extra}"
|
||||
);
|
||||
std::fs::write(self.config_dir.join(format!("{name}.kdl")), body).unwrap();
|
||||
}
|
||||
@@ -47,6 +61,7 @@ impl Harness {
|
||||
.env("XDG_CONFIG_HOME", self.tmp.path().join("config"))
|
||||
.env("XDG_STATE_HOME", self.tmp.path().join("state"))
|
||||
.env("XDG_RUNTIME_DIR", self.tmp.path().join("run"))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
@@ -62,6 +77,16 @@ impl Harness {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `list` RPC result straight off the wire, before any CLI rendering.
|
||||
pub async fn list_rpc(&self) -> Vec<ServerSummary> {
|
||||
let mut client = Client::connect(&self.socket).await.expect("connect daemon");
|
||||
|
||||
client
|
||||
.call_no_params(methods::LIST)
|
||||
.await
|
||||
.expect("list rpc")
|
||||
}
|
||||
|
||||
pub async fn run_cli(&self, xy_bin: &PathBuf, args: &[&str]) -> (i32, String, String) {
|
||||
let out = Command::new(xy_bin)
|
||||
.args(args)
|
||||
@@ -87,6 +112,9 @@ pub fn sleep_server_bin() -> PathBuf {
|
||||
pub fn exit_failure_bin() -> PathBuf {
|
||||
artifact("xy-test-exit-failure")
|
||||
}
|
||||
pub fn stdin_reader_bin() -> PathBuf {
|
||||
artifact("xy-test-stdin-reader")
|
||||
}
|
||||
|
||||
fn artifact(name: &str) -> PathBuf {
|
||||
let mut p = std::env::current_exe().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,68 @@
|
||||
mod common;
|
||||
use common::*;
|
||||
use std::time::Duration;
|
||||
use xy_protocol::ServerState;
|
||||
|
||||
async fn state_of(h: &Harness, name: &str) -> ServerState {
|
||||
h.list_rpc()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|s| s.name == name)
|
||||
.unwrap_or_else(|| panic!("no server named {name}"))
|
||||
.state
|
||||
}
|
||||
|
||||
async fn settle(h: &Harness, name: &str) -> ServerState {
|
||||
let mut last = ServerState::Stopped;
|
||||
for _ in 0..40 {
|
||||
last = state_of(h, name).await;
|
||||
if last == ServerState::Running {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keep_open_lets_a_stdin_reader_stay_running() {
|
||||
let xy = xy_bin();
|
||||
let reader = stdin_reader_bin();
|
||||
let mut h = Harness::new();
|
||||
h.write_server_with(
|
||||
"reader",
|
||||
reader.to_str().unwrap(),
|
||||
19_010,
|
||||
"never",
|
||||
"stdin \"keep-open\"\n",
|
||||
);
|
||||
h.start_daemon(&xy).await;
|
||||
|
||||
assert_eq!(settle(&h, "reader").await, ServerState::Running);
|
||||
|
||||
// Still up a beat later: it is blocked on read, not merely slow to die.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
assert_eq!(state_of(&h, "reader").await, ServerState::Running);
|
||||
}
|
||||
|
||||
/// Characterizes the reported bug: the daemon's stdin is `/dev/null` under
|
||||
/// launchd, so by default a stdio-first server sees EOF and exits at once.
|
||||
#[tokio::test]
|
||||
async fn without_keep_open_a_stdin_reader_exits_on_eof() {
|
||||
let xy = xy_bin();
|
||||
let reader = stdin_reader_bin();
|
||||
let mut h = Harness::new();
|
||||
h.write_server("reader", reader.to_str().unwrap(), 19_011, "never");
|
||||
h.start_daemon(&xy).await;
|
||||
|
||||
let mut last = ServerState::Running;
|
||||
for _ in 0..40 {
|
||||
last = state_of(&h, "reader").await;
|
||||
if last == ServerState::Stopped {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
assert_eq!(last, ServerState::Stopped);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
mod common;
|
||||
use common::*;
|
||||
use xy_protocol::ServerState;
|
||||
|
||||
#[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 rows = h.list_rpc().await;
|
||||
if rows
|
||||
.iter()
|
||||
.any(|r| r.name == "alpha" && r.state == ServerState::Running)
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_200)).await;
|
||||
|
||||
let rows = h.list_rpc().await;
|
||||
|
||||
let alpha = rows
|
||||
.iter()
|
||||
.find(|r| r.name == "alpha")
|
||||
.unwrap_or_else(|| panic!("no alpha row in: {rows:?}"));
|
||||
|
||||
assert!(
|
||||
alpha.uptime_secs.is_some_and(|secs| secs >= 1),
|
||||
"a server running for over a second must report at least 1s of uptime; row: {alpha:?}"
|
||||
);
|
||||
}
|
||||
@@ -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