Skip to content

Commit 840a78d

Browse files
authored
Provide More Intuitive Behaviour for Launchd Services (#40)
* fix: use correct logic for launchd restart policies For the `OnSuccess` case, the logic was actually inverted, so the service was restarting when the exit was *not* successful. This has now been corrected. The logic we were previously using for the `OnSuccess` case has now been applied to the `OnFailure` case, so we now support that scenario rather than emitting a warning saying we don't support it. A comment clarifies that when the `SuccessfulExit` construct is *not* used, this is effectively the `Always` policy. * feat: prevent launchd services starting when keepalive is used When the service definition includes the `KeepAlive` setting it causes services to automatically start when `launchctl load` is used. This is quite unintuitive and is not how services behave on other platforms. We now make use of the `Disabled` setting to prevent the service starting automatically and make it behave like service managers on other platforms. This is desirable when you want to add many services first and not have them all start at the same time, which is the case for our application. It also seems to be the behaviour most of our users expect and I personally think makes most sense. * chore(release): bump to version 0.10.0
1 parent 6eba67e commit 840a78d

File tree

5 files changed

+94
-18
lines changed

5 files changed

+94
-18
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.10.0] - 2025-12-14
11+
12+
### Changed
13+
14+
- **BREAKING CHANGE**: Launchd services with restart policies (`RestartPolicy::Always`, `OnFailure`, or `OnSuccess`) no longer auto-start when `install()` is called. Services must now be explicitly started using `start()`. This provides cross-platform consistency where `install()` registers the service definition without starting it, matching the behavior of systemd and other service managers.
15+
- Services with `KeepAlive` configured are now installed with `Disabled=true` in the plist
16+
- The `start()` function removes the `Disabled` key and reloads the service
17+
- The `autostart` parameter continues to control only `RunAtLoad` (whether service starts on OS boot), not initial install behavior
18+
- Migration: Add explicit `manager.start(ctx)?` call after `manager.install(ctx)?` if you need the service to start immediately
19+
20+
### Fixed
21+
22+
- Fixed incorrect Launchd restart policy implementation for `RestartPolicy::OnFailure` and `RestartPolicy::OnSuccess`:
23+
- `OnFailure` now correctly uses `KeepAlive` dictionary with `SuccessfulExit=false` (restart on non-zero exit) instead of `KeepAlive=true` (always restart)
24+
- `OnSuccess` now correctly uses `SuccessfulExit=true` (restart on zero exit) instead of `SuccessfulExit=false`
25+
1026
## [0.9.0] - 2025-11-22
1127

1228
### Changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "service-manager"
33
description = "Provides adapters to communicate with various operating system service managers"
44
categories = ["config"]
55
keywords = ["generator"]
6-
version = "0.9.0"
6+
version = "0.10.0"
77
authors = ["Chip Senkbeil <chip@senkbeil.org>"]
88
edition = "2021"
99
homepage = "https://github.com/chipsenkbeil/service-manager-rs"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Add the following to your `Cargo.toml`:
3131

3232
```toml
3333
[dependencies]
34-
service-manager = "0.8"
34+
service-manager = "0.10"
3535
```
3636

3737
## Examples

src/launchd.rs

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,9 @@ impl ServiceManager for LaunchdServiceManager {
138138
)?;
139139

140140
// Load the service.
141-
// If "KeepAlive" is set to true, the service will immediately start.
141+
// Services with KeepAlive configured will have Disabled=true set, preventing auto-start
142+
// until explicitly started via start(). This provides cross-platform consistency where
143+
// install() never auto-starts services.
142144
wrap_output(launchctl("load", plist_path.to_string_lossy().as_ref())?)?;
143145

144146
Ok(())
@@ -153,8 +155,49 @@ impl ServiceManager for LaunchdServiceManager {
153155
}
154156

155157
fn start(&self, ctx: ServiceStartCtx) -> io::Result<()> {
156-
// To start services that do not have "KeepAlive" set to true
157-
wrap_output(launchctl("start", ctx.label.to_qualified_name().as_str())?)?;
158+
let qualified_name = ctx.label.to_qualified_name();
159+
let plist_path = self.get_plist_path(qualified_name.clone());
160+
161+
if !plist_path.exists() {
162+
return Err(io::Error::new(
163+
io::ErrorKind::NotFound,
164+
format!("Service {} is not installed", qualified_name),
165+
));
166+
}
167+
168+
let plist_data = std::fs::read(&plist_path)?;
169+
let mut plist: Value = plist::from_reader(std::io::Cursor::new(plist_data))
170+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
171+
let is_disabled = if let Value::Dictionary(ref dict) = plist {
172+
dict.get("Disabled")
173+
.and_then(|v| v.as_boolean())
174+
.unwrap_or(false)
175+
} else {
176+
false
177+
};
178+
179+
if is_disabled {
180+
// Service was disable to prevent automatic start when KeepAlive is used. Now the
181+
// disabled key will be removed. This makes the services behave in a more sane way like
182+
// service managers on other platforms.
183+
if let Value::Dictionary(ref mut dict) = plist {
184+
dict.remove("Disabled");
185+
}
186+
187+
let mut buffer = Vec::new();
188+
plist
189+
.to_writer_xml(&mut buffer)
190+
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
191+
utils::write_file(plist_path.as_path(), &buffer, PLIST_FILE_PERMISSIONS)?;
192+
193+
let _ = launchctl("unload", plist_path.to_string_lossy().as_ref());
194+
wrap_output(launchctl("load", plist_path.to_string_lossy().as_ref())?)?;
195+
} else {
196+
// Service is not disabled, use regular start command
197+
// This works for non-KeepAlive services
198+
wrap_output(launchctl("start", qualified_name.as_str())?)?;
199+
}
200+
158201
Ok(())
159202
}
160203

@@ -312,6 +355,8 @@ fn make_plist<'a>(
312355
// Don't set KeepAlive
313356
}
314357
RestartPolicy::Always { delay_secs } => {
358+
// KeepAlive *without* the SuccessfulExit construct will keep the service alive
359+
// whether the process exits successfully or not.
315360
dict.insert("KeepAlive".to_string(), Value::Boolean(true));
316361
if delay_secs.is_some() {
317362
log::warn!(
@@ -321,11 +366,12 @@ fn make_plist<'a>(
321366
}
322367
}
323368
RestartPolicy::OnFailure { delay_secs } => {
324-
dict.insert("KeepAlive".to_string(), Value::Boolean(true));
325-
log::warn!(
326-
"Right now we don't have more granular restart support for Launchd so the service will always restart; using KeepAlive=true for service '{}'",
327-
label
328-
);
369+
// Create KeepAlive dictionary with SuccessfulExit=false
370+
// This means: restart when exit is NOT successful
371+
let mut keep_alive_dict = Dictionary::new();
372+
keep_alive_dict.insert("SuccessfulExit".to_string(), Value::Boolean(false));
373+
dict.insert("KeepAlive".to_string(), Value::Dictionary(keep_alive_dict));
374+
329375
if delay_secs.is_some() {
330376
log::warn!(
331377
"Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
@@ -334,10 +380,10 @@ fn make_plist<'a>(
334380
}
335381
}
336382
RestartPolicy::OnSuccess { delay_secs } => {
337-
// Create KeepAlive dictionary with SuccessfulExit=false
338-
// This means: restart when exit is successful (exit code 0)
383+
// Create KeepAlive dictionary with SuccessfulExit=true
384+
// This means: restart when exit is successful
339385
let mut keep_alive_dict = Dictionary::new();
340-
keep_alive_dict.insert("SuccessfulExit".to_string(), Value::Boolean(false));
386+
keep_alive_dict.insert("SuccessfulExit".to_string(), Value::Boolean(true));
341387
dict.insert("KeepAlive".to_string(), Value::Dictionary(keep_alive_dict));
342388

343389
if delay_secs.is_some() {
@@ -378,6 +424,19 @@ fn make_plist<'a>(
378424
dict.insert("RunAtLoad".to_string(), Value::Boolean(false));
379425
}
380426

427+
let has_keep_alive = if let Some(keep_alive) = config.keep_alive {
428+
keep_alive
429+
} else {
430+
!matches!(restart_policy, RestartPolicy::Never)
431+
};
432+
433+
// Set Disabled key to prevent the service automatically starting on load when KeepAlive is present.
434+
// This provides consistent cross-platform behaviour which is much more intuitive.
435+
// The service must be explicitly started via start().
436+
if has_keep_alive {
437+
dict.insert("Disabled".to_string(), Value::Boolean(true));
438+
}
439+
381440
let plist = Value::Dictionary(dict);
382441

383442
let mut buffer = Vec::new();

system-tests/tests/runner.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ pub fn run_test(manager: &TypedServiceManager, username: Option<String>) -> Opti
108108
wait();
109109
}
110110

111-
eprintln!("Checking status of service");
112111
assert!(
113112
matches!(
114113
manager
@@ -133,24 +132,26 @@ pub fn run_test(manager: &TypedServiceManager, username: Option<String>) -> Opti
133132
working_directory: None,
134133
environment: None,
135134
autostart: false,
136-
restart_policy: RestartPolicy::Never,
135+
restart_policy: RestartPolicy::OnFailure { delay_secs: None },
137136
})
138137
.unwrap();
139138

140139
// Wait for service to be installed
141140
wait();
142141

143-
eprintln!("Checking status of service");
142+
// On Launchd the status will still be reported as `NotInstalled` because the `Disabled` key
143+
// was used when a restart policy was applied.
144+
eprintln!("Checking status of service after install with autostart=false");
144145
assert!(
145146
matches!(
146147
manager
147148
.status(ServiceStatusCtx {
148149
label: service_label.clone(),
149150
})
150151
.unwrap(),
151-
ServiceStatus::Stopped(_)
152+
ServiceStatus::Stopped(_) | ServiceStatus::NotInstalled
152153
),
153-
"service should be stopped"
154+
"service should be stopped or not installed after install with autostart=false and restart policy"
154155
);
155156

156157
let is_user_specified =

0 commit comments

Comments
 (0)