Skip to content

Commit ced9e8c

Browse files
MoonBoi9001claude
andauthored
fix: resolve daemon startup race conditions (#45)
Three race conditions were causing the daemon to silently fail during startup, leaving the battery charging uncontrolled: - launchd bootout followed immediately by bootstrap returns I/O error because launchd hasn't finished cleanup. Added 0.5s delay between the two calls and retry-on-failure for bootstrap. - createSafetyDaemon() kickstarted the safety watchdog before the main daemon was running, causing the watchdog to detect a missing daemon and attempt a conflicting restart. Removed the kickstart -- the watchdog now relies on StartCalendarInterval (:00/:30). - Maintain sent SIGTERM to the old daemon and immediately proceeded with startup. The old daemon's cleanup (re-enable charging, exit) could race with the new daemon's bootout. Now waits up to 5s for the old process to exit. Bumps version to 2.1.2. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 05dfc12 commit ced9e8c

3 files changed

Lines changed: 21 additions & 10 deletions

File tree

Sources/AppleJuice/AppleJuice.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ArgumentParser
22
import Foundation
33

4-
let appVersion = "2.1.1"
4+
let appVersion = "2.1.2"
55

66
@main
77
struct AppleJuice: ParsableCommand {

Sources/AppleJuice/Commands/Maintain.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ struct Maintain: ParsableCommand {
100100
if let pid {
101101
if kill(pid, 0) == 0 {
102102
kill(pid, SIGTERM)
103+
// Wait for old daemon to exit before starting new one
104+
for _ in 0..<50 {
105+
if kill(pid, 0) != 0 { break }
106+
Thread.sleep(forTimeInterval: 0.1)
107+
}
103108
}
104109
}
105110

Sources/AppleJuice/Daemon/DaemonManager.swift

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,16 @@ enum DaemonManager {
7777

7878
/// Start the maintain daemon via launchctl bootstrap + kickstart.
7979
static func startDaemon() {
80-
// Bootout any existing instance (silent — service may not be loaded)
8180
launchctl("launchctl bootout gui/\(uid)/com.apple-juice.app", silent: true)
82-
// Ensure the service is enabled. `maintain stop` disables it, and
83-
// createDaemon() skips the enable call when the plist hasn't changed.
81+
Thread.sleep(forTimeInterval: 0.5)
8482
launchctl("launchctl enable gui/\(uid)/com.apple-juice.app")
85-
// Bootstrap the plist so launchd manages the process
8683
if FileManager.default.fileExists(atPath: Paths.daemonPath) {
87-
launchctl("launchctl bootstrap gui/\(uid) '\(Paths.daemonPath)'")
84+
let result = launchctl("launchctl bootstrap gui/\(uid) '\(Paths.daemonPath)'")
85+
if !result.succeeded {
86+
Thread.sleep(forTimeInterval: 1)
87+
launchctl("launchctl bootstrap gui/\(uid) '\(Paths.daemonPath)'")
88+
}
8889
}
89-
// RunAtLoad only fires once per login session. After a bootout+bootstrap
90-
// cycle, kickstart is needed to actually launch the process.
9190
launchctl("launchctl kickstart gui/\(uid)/com.apple-juice.app")
9291
}
9392

@@ -166,8 +165,15 @@ enum DaemonManager {
166165
// the service is loaded in launchd.
167166
launchctl("launchctl enable gui/\(uid)/com.apple-juice.safety")
168167
launchctl("launchctl bootout gui/\(uid)/com.apple-juice.safety", silent: true)
169-
launchctl("launchctl bootstrap gui/\(uid) '\(path)'")
170-
launchctl("launchctl kickstart gui/\(uid)/com.apple-juice.safety")
168+
Thread.sleep(forTimeInterval: 0.5)
169+
let result = launchctl("launchctl bootstrap gui/\(uid) '\(path)'")
170+
if !result.succeeded {
171+
Thread.sleep(forTimeInterval: 1)
172+
launchctl("launchctl bootstrap gui/\(uid) '\(path)'")
173+
}
174+
// No kickstart -- let StartCalendarInterval handle timing.
175+
// Kickstarting here races with daemon startup since createSafetyDaemon()
176+
// is called from createDaemon() just before startDaemon().
171177
}
172178

173179
/// Remove the safety watchdog plist and unload it. Only called during uninstall.

0 commit comments

Comments
 (0)