launchd-doctor 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- launchd_doctor-0.1.0/LICENSE +21 -0
- launchd_doctor-0.1.0/PKG-INFO +105 -0
- launchd_doctor-0.1.0/README.md +86 -0
- launchd_doctor-0.1.0/launchd_doctor.egg-info/PKG-INFO +105 -0
- launchd_doctor-0.1.0/launchd_doctor.egg-info/SOURCES.txt +11 -0
- launchd_doctor-0.1.0/launchd_doctor.egg-info/dependency_links.txt +1 -0
- launchd_doctor-0.1.0/launchd_doctor.egg-info/entry_points.txt +2 -0
- launchd_doctor-0.1.0/launchd_doctor.egg-info/requires.txt +4 -0
- launchd_doctor-0.1.0/launchd_doctor.egg-info/top_level.txt +1 -0
- launchd_doctor-0.1.0/launchd_doctor.py +485 -0
- launchd_doctor-0.1.0/pyproject.toml +43 -0
- launchd_doctor-0.1.0/setup.cfg +4 -0
- launchd_doctor-0.1.0/tests/test_doctor.py +107 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Victor Navejar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: launchd-doctor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find and fix the macOS LaunchAgent bugs that fail silently, plus a circuit breaker for agents that keep failing.
|
|
5
|
+
Author: Victor Navejar (Navii)
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/naviidaviimusic/launchd-doctor
|
|
8
|
+
Keywords: macos,launchd,launchagent,launchctl,tcc,automation
|
|
9
|
+
Classifier: Operating System :: MacOS
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: System :: Monitoring
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# launchd-doctor
|
|
21
|
+
|
|
22
|
+
**Your LaunchAgent is "loaded" but never runs. Or it runs 84 times a day. Or it
|
|
23
|
+
exits 78 and writes nothing at all.** `launchd-doctor` finds the macOS launchd
|
|
24
|
+
bugs that fail silently, explains them in plain English, and fixes them.
|
|
25
|
+
|
|
26
|
+
```console
|
|
27
|
+
$ launchd-doctor
|
|
28
|
+
✗ com.me.nightly-report
|
|
29
|
+
StandardOutPath is inside ~/Documents. launchd can't open files there, so the job
|
|
30
|
+
fails with exit 78 and no output at all. Move logs to ~/Library/Logs. [fixable]
|
|
31
|
+
✗ com.me.backup
|
|
32
|
+
StartCalendarInterval has Hour and Minute in separate dicts: fires 84x/day instead of 1x. [fixable]
|
|
33
|
+
✗ com.me.ollama
|
|
34
|
+
last run exited 1: the program exited with an error — check its log
|
|
35
|
+
KeepAlive job has started 159 times and isn't running — crash loop?
|
|
36
|
+
|
|
37
|
+
137 agents scanned, 3 with problems. Run `launchd-doctor fix` to see automatic fixes.
|
|
38
|
+
|
|
39
|
+
$ launchd-doctor fix --apply # backs up each plist, rewrites it, reloads the job
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Single file, Python stdlib only, macOS 12+.
|
|
43
|
+
|
|
44
|
+
## What it catches
|
|
45
|
+
|
|
46
|
+
| Problem | What you see | |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| **Log path in a privacy-protected folder** (`~/Documents`, `~/Desktop`, `~/Downloads`, iCloud Drive…) | exit **78** (EX_CONFIG), **no output at all**. Jobs loaded before the change keep working until the next reboot, then break | auto-fix → `~/Library/Logs` |
|
|
49
|
+
| **Split `StartCalendarInterval`**: `[{Minute: 55}, {Hour: 5}]` instead of `[{Hour: 5, Minute: 55}]` | runs every hour at :55 **and** every minute from 5:00–5:59: 84 runs/day | auto-fix (merge) |
|
|
50
|
+
| **Apple's Python on a protected folder** (`/usr/bin/python3`, CommandLineTools) | `Operation not permitted`, works in Terminal but never from launchd | explains |
|
|
51
|
+
| Program / script missing or not executable | exit **127** / **126** | explains |
|
|
52
|
+
| `KeepAlive` crash loop | hundreds of restarts, job never stays up | explains |
|
|
53
|
+
| Non-zero last exit | decoded exit code | explains |
|
|
54
|
+
| Huge log files (>50 MB) | disk fills slowly | explains |
|
|
55
|
+
| Empty or unparseable plists | job silently ignored | explains |
|
|
56
|
+
|
|
57
|
+
Read the full story of the first bug: **[Why your LaunchAgent exits 78 with no output](docs/tcc-log-paths.md)**.
|
|
58
|
+
|
|
59
|
+
## Circuit breaker for your own agents
|
|
60
|
+
|
|
61
|
+
A job that fails on every run wastes CPU and buries the real error in its log.
|
|
62
|
+
`watch` pauses it and retries later:
|
|
63
|
+
|
|
64
|
+
- **3 failed runs in a row** (exit ≠ 0, not killed by a signal) → `launchctl bootout` + macOS notification
|
|
65
|
+
- **Half-open retry** after 24 h, doubling every time it trips again (max 7 days); one good run closes the breaker
|
|
66
|
+
- exit **126/127** → notifies right away, since every run will fail the same way
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
launchd-doctor install-watch --prefix com.myname. # runs `watch` every 30 min
|
|
70
|
+
launchd-doctor breakers # what's paused, and when it retries
|
|
71
|
+
launchd-doctor resume com.myname.job # un-pause now
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`watch` only touches agents matching `--prefix`, never Apple's or other vendors' agents.
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install git+https://github.com/naviidaviimusic/launchd-doctor
|
|
80
|
+
# or just grab the one file:
|
|
81
|
+
curl -O https://raw.githubusercontent.com/naviidaviimusic/launchd-doctor/main/launchd_doctor.py
|
|
82
|
+
python3 launchd_doctor.py
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Commands
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
launchd-doctor [scan] [--prefix P] [-v] find problems (exit 1 if any)
|
|
89
|
+
launchd-doctor fix [--apply] [--prefix P] fix what can be fixed (dry run by default)
|
|
90
|
+
launchd-doctor watch --prefix P [--trip N] [--dry-run]
|
|
91
|
+
launchd-doctor install-watch --prefix P
|
|
92
|
+
launchd-doctor breakers
|
|
93
|
+
launchd-doctor resume LABEL
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Why this exists
|
|
97
|
+
|
|
98
|
+
I run 100+ LaunchAgents for a local AI system on one Mac. Each of these bugs took
|
|
99
|
+
it down at some point. The split-calendar bug sent 97 agents firing 83× a day and
|
|
100
|
+
produced 49,000 duplicate events. The privacy-folder log path broke every agent
|
|
101
|
+
that got reloaded, and none of them logged anything. This tool is what I wish I'd had.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT © Victor Navejar ([Navii](https://github.com/naviidaviimusic))
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# launchd-doctor
|
|
2
|
+
|
|
3
|
+
**Your LaunchAgent is "loaded" but never runs. Or it runs 84 times a day. Or it
|
|
4
|
+
exits 78 and writes nothing at all.** `launchd-doctor` finds the macOS launchd
|
|
5
|
+
bugs that fail silently, explains them in plain English, and fixes them.
|
|
6
|
+
|
|
7
|
+
```console
|
|
8
|
+
$ launchd-doctor
|
|
9
|
+
✗ com.me.nightly-report
|
|
10
|
+
StandardOutPath is inside ~/Documents. launchd can't open files there, so the job
|
|
11
|
+
fails with exit 78 and no output at all. Move logs to ~/Library/Logs. [fixable]
|
|
12
|
+
✗ com.me.backup
|
|
13
|
+
StartCalendarInterval has Hour and Minute in separate dicts: fires 84x/day instead of 1x. [fixable]
|
|
14
|
+
✗ com.me.ollama
|
|
15
|
+
last run exited 1: the program exited with an error — check its log
|
|
16
|
+
KeepAlive job has started 159 times and isn't running — crash loop?
|
|
17
|
+
|
|
18
|
+
137 agents scanned, 3 with problems. Run `launchd-doctor fix` to see automatic fixes.
|
|
19
|
+
|
|
20
|
+
$ launchd-doctor fix --apply # backs up each plist, rewrites it, reloads the job
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Single file, Python stdlib only, macOS 12+.
|
|
24
|
+
|
|
25
|
+
## What it catches
|
|
26
|
+
|
|
27
|
+
| Problem | What you see | |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| **Log path in a privacy-protected folder** (`~/Documents`, `~/Desktop`, `~/Downloads`, iCloud Drive…) | exit **78** (EX_CONFIG), **no output at all**. Jobs loaded before the change keep working until the next reboot, then break | auto-fix → `~/Library/Logs` |
|
|
30
|
+
| **Split `StartCalendarInterval`**: `[{Minute: 55}, {Hour: 5}]` instead of `[{Hour: 5, Minute: 55}]` | runs every hour at :55 **and** every minute from 5:00–5:59: 84 runs/day | auto-fix (merge) |
|
|
31
|
+
| **Apple's Python on a protected folder** (`/usr/bin/python3`, CommandLineTools) | `Operation not permitted`, works in Terminal but never from launchd | explains |
|
|
32
|
+
| Program / script missing or not executable | exit **127** / **126** | explains |
|
|
33
|
+
| `KeepAlive` crash loop | hundreds of restarts, job never stays up | explains |
|
|
34
|
+
| Non-zero last exit | decoded exit code | explains |
|
|
35
|
+
| Huge log files (>50 MB) | disk fills slowly | explains |
|
|
36
|
+
| Empty or unparseable plists | job silently ignored | explains |
|
|
37
|
+
|
|
38
|
+
Read the full story of the first bug: **[Why your LaunchAgent exits 78 with no output](docs/tcc-log-paths.md)**.
|
|
39
|
+
|
|
40
|
+
## Circuit breaker for your own agents
|
|
41
|
+
|
|
42
|
+
A job that fails on every run wastes CPU and buries the real error in its log.
|
|
43
|
+
`watch` pauses it and retries later:
|
|
44
|
+
|
|
45
|
+
- **3 failed runs in a row** (exit ≠ 0, not killed by a signal) → `launchctl bootout` + macOS notification
|
|
46
|
+
- **Half-open retry** after 24 h, doubling every time it trips again (max 7 days); one good run closes the breaker
|
|
47
|
+
- exit **126/127** → notifies right away, since every run will fail the same way
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
launchd-doctor install-watch --prefix com.myname. # runs `watch` every 30 min
|
|
51
|
+
launchd-doctor breakers # what's paused, and when it retries
|
|
52
|
+
launchd-doctor resume com.myname.job # un-pause now
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`watch` only touches agents matching `--prefix`, never Apple's or other vendors' agents.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install git+https://github.com/naviidaviimusic/launchd-doctor
|
|
61
|
+
# or just grab the one file:
|
|
62
|
+
curl -O https://raw.githubusercontent.com/naviidaviimusic/launchd-doctor/main/launchd_doctor.py
|
|
63
|
+
python3 launchd_doctor.py
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Commands
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
launchd-doctor [scan] [--prefix P] [-v] find problems (exit 1 if any)
|
|
70
|
+
launchd-doctor fix [--apply] [--prefix P] fix what can be fixed (dry run by default)
|
|
71
|
+
launchd-doctor watch --prefix P [--trip N] [--dry-run]
|
|
72
|
+
launchd-doctor install-watch --prefix P
|
|
73
|
+
launchd-doctor breakers
|
|
74
|
+
launchd-doctor resume LABEL
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Why this exists
|
|
78
|
+
|
|
79
|
+
I run 100+ LaunchAgents for a local AI system on one Mac. Each of these bugs took
|
|
80
|
+
it down at some point. The split-calendar bug sent 97 agents firing 83× a day and
|
|
81
|
+
produced 49,000 duplicate events. The privacy-folder log path broke every agent
|
|
82
|
+
that got reloaded, and none of them logged anything. This tool is what I wish I'd had.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT © Victor Navejar ([Navii](https://github.com/naviidaviimusic))
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: launchd-doctor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find and fix the macOS LaunchAgent bugs that fail silently, plus a circuit breaker for agents that keep failing.
|
|
5
|
+
Author: Victor Navejar (Navii)
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/naviidaviimusic/launchd-doctor
|
|
8
|
+
Keywords: macos,launchd,launchagent,launchctl,tcc,automation
|
|
9
|
+
Classifier: Operating System :: MacOS
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: System :: Monitoring
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# launchd-doctor
|
|
21
|
+
|
|
22
|
+
**Your LaunchAgent is "loaded" but never runs. Or it runs 84 times a day. Or it
|
|
23
|
+
exits 78 and writes nothing at all.** `launchd-doctor` finds the macOS launchd
|
|
24
|
+
bugs that fail silently, explains them in plain English, and fixes them.
|
|
25
|
+
|
|
26
|
+
```console
|
|
27
|
+
$ launchd-doctor
|
|
28
|
+
✗ com.me.nightly-report
|
|
29
|
+
StandardOutPath is inside ~/Documents. launchd can't open files there, so the job
|
|
30
|
+
fails with exit 78 and no output at all. Move logs to ~/Library/Logs. [fixable]
|
|
31
|
+
✗ com.me.backup
|
|
32
|
+
StartCalendarInterval has Hour and Minute in separate dicts: fires 84x/day instead of 1x. [fixable]
|
|
33
|
+
✗ com.me.ollama
|
|
34
|
+
last run exited 1: the program exited with an error — check its log
|
|
35
|
+
KeepAlive job has started 159 times and isn't running — crash loop?
|
|
36
|
+
|
|
37
|
+
137 agents scanned, 3 with problems. Run `launchd-doctor fix` to see automatic fixes.
|
|
38
|
+
|
|
39
|
+
$ launchd-doctor fix --apply # backs up each plist, rewrites it, reloads the job
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Single file, Python stdlib only, macOS 12+.
|
|
43
|
+
|
|
44
|
+
## What it catches
|
|
45
|
+
|
|
46
|
+
| Problem | What you see | |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| **Log path in a privacy-protected folder** (`~/Documents`, `~/Desktop`, `~/Downloads`, iCloud Drive…) | exit **78** (EX_CONFIG), **no output at all**. Jobs loaded before the change keep working until the next reboot, then break | auto-fix → `~/Library/Logs` |
|
|
49
|
+
| **Split `StartCalendarInterval`**: `[{Minute: 55}, {Hour: 5}]` instead of `[{Hour: 5, Minute: 55}]` | runs every hour at :55 **and** every minute from 5:00–5:59: 84 runs/day | auto-fix (merge) |
|
|
50
|
+
| **Apple's Python on a protected folder** (`/usr/bin/python3`, CommandLineTools) | `Operation not permitted`, works in Terminal but never from launchd | explains |
|
|
51
|
+
| Program / script missing or not executable | exit **127** / **126** | explains |
|
|
52
|
+
| `KeepAlive` crash loop | hundreds of restarts, job never stays up | explains |
|
|
53
|
+
| Non-zero last exit | decoded exit code | explains |
|
|
54
|
+
| Huge log files (>50 MB) | disk fills slowly | explains |
|
|
55
|
+
| Empty or unparseable plists | job silently ignored | explains |
|
|
56
|
+
|
|
57
|
+
Read the full story of the first bug: **[Why your LaunchAgent exits 78 with no output](docs/tcc-log-paths.md)**.
|
|
58
|
+
|
|
59
|
+
## Circuit breaker for your own agents
|
|
60
|
+
|
|
61
|
+
A job that fails on every run wastes CPU and buries the real error in its log.
|
|
62
|
+
`watch` pauses it and retries later:
|
|
63
|
+
|
|
64
|
+
- **3 failed runs in a row** (exit ≠ 0, not killed by a signal) → `launchctl bootout` + macOS notification
|
|
65
|
+
- **Half-open retry** after 24 h, doubling every time it trips again (max 7 days); one good run closes the breaker
|
|
66
|
+
- exit **126/127** → notifies right away, since every run will fail the same way
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
launchd-doctor install-watch --prefix com.myname. # runs `watch` every 30 min
|
|
70
|
+
launchd-doctor breakers # what's paused, and when it retries
|
|
71
|
+
launchd-doctor resume com.myname.job # un-pause now
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`watch` only touches agents matching `--prefix`, never Apple's or other vendors' agents.
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install git+https://github.com/naviidaviimusic/launchd-doctor
|
|
80
|
+
# or just grab the one file:
|
|
81
|
+
curl -O https://raw.githubusercontent.com/naviidaviimusic/launchd-doctor/main/launchd_doctor.py
|
|
82
|
+
python3 launchd_doctor.py
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Commands
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
launchd-doctor [scan] [--prefix P] [-v] find problems (exit 1 if any)
|
|
89
|
+
launchd-doctor fix [--apply] [--prefix P] fix what can be fixed (dry run by default)
|
|
90
|
+
launchd-doctor watch --prefix P [--trip N] [--dry-run]
|
|
91
|
+
launchd-doctor install-watch --prefix P
|
|
92
|
+
launchd-doctor breakers
|
|
93
|
+
launchd-doctor resume LABEL
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Why this exists
|
|
97
|
+
|
|
98
|
+
I run 100+ LaunchAgents for a local AI system on one Mac. Each of these bugs took
|
|
99
|
+
it down at some point. The split-calendar bug sent 97 agents firing 83× a day and
|
|
100
|
+
produced 49,000 duplicate events. The privacy-folder log path broke every agent
|
|
101
|
+
that got reloaded, and none of them logged anything. This tool is what I wish I'd had.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT © Victor Navejar ([Navii](https://github.com/naviidaviimusic))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
launchd_doctor.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
launchd_doctor.egg-info/PKG-INFO
|
|
6
|
+
launchd_doctor.egg-info/SOURCES.txt
|
|
7
|
+
launchd_doctor.egg-info/dependency_links.txt
|
|
8
|
+
launchd_doctor.egg-info/entry_points.txt
|
|
9
|
+
launchd_doctor.egg-info/requires.txt
|
|
10
|
+
launchd_doctor.egg-info/top_level.txt
|
|
11
|
+
tests/test_doctor.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
launchd_doctor
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""launchd-doctor — find and fix the macOS LaunchAgent bugs that fail silently.
|
|
2
|
+
|
|
3
|
+
launchd-doctor scan ~/Library/LaunchAgents, explain every problem
|
|
4
|
+
launchd-doctor fix show the fixes it would make (dry run)
|
|
5
|
+
launchd-doctor fix --apply make them (each plist backed up first)
|
|
6
|
+
launchd-doctor watch circuit breaker: pause agents that keep failing
|
|
7
|
+
launchd-doctor install-watch run `watch` every 30 min as its own LaunchAgent
|
|
8
|
+
launchd-doctor breakers show paused agents
|
|
9
|
+
launchd-doctor resume LABEL un-pause an agent now
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import plistlib
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
HOME = Path.home()
|
|
27
|
+
AGENTS_DIR = HOME / "Library" / "LaunchAgents"
|
|
28
|
+
LOG_DIR = HOME / "Library" / "Logs" / "launchd-doctor"
|
|
29
|
+
STATE_FILE = HOME / ".local" / "state" / "launchd-doctor" / "state.json"
|
|
30
|
+
UID = os.getuid()
|
|
31
|
+
|
|
32
|
+
# Folders macOS privacy protection (TCC) guards. launchd itself opens a job's
|
|
33
|
+
# StandardOutPath / StandardErrorPath before the program starts, and launchd has
|
|
34
|
+
# no permission there: the job dies with EX_CONFIG (78) and writes nothing at all.
|
|
35
|
+
PROTECTED = ["Documents", "Desktop", "Downloads", "Library/Mobile Documents", "Pictures", "Movies", "Music"]
|
|
36
|
+
# Interpreters macOS blocks from reading protected folders unless granted Full Disk Access.
|
|
37
|
+
TCC_BLOCKED_INTERPRETERS = ["/Library/Developer/CommandLineTools/usr/bin/python3", "/usr/bin/python3"]
|
|
38
|
+
|
|
39
|
+
EXIT_CODES = {
|
|
40
|
+
"1": "the program exited with an error — check its log",
|
|
41
|
+
"2": "usage error — wrong arguments in ProgramArguments",
|
|
42
|
+
"78": "EX_CONFIG — launchd couldn't set the job up (often a log path it may not open, see above)",
|
|
43
|
+
"126": "found but not executable (permissions, or macOS privacy blocked it)",
|
|
44
|
+
"127": "command not found — the path in ProgramArguments doesn't exist",
|
|
45
|
+
}
|
|
46
|
+
SIGNAL_EXITS = {"-15", "-9", "-2"} # killed by a user or by us: not a failure
|
|
47
|
+
|
|
48
|
+
BREAKER_TRIP = 3 # consecutive failed runs before pausing
|
|
49
|
+
BREAKER_COOL_BASE = 24 * 3600 # first retry after 24 h, doubling per trip …
|
|
50
|
+
BREAKER_COOL_MAX = 7 * 24 * 3600 # … up to a week
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Finding:
|
|
55
|
+
label: str
|
|
56
|
+
severity: str # error | warn
|
|
57
|
+
code: str
|
|
58
|
+
message: str
|
|
59
|
+
fixable: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── analysis (pure: plist dict in, findings out) ──────────────────────────────
|
|
63
|
+
|
|
64
|
+
def _protected(path: str, home: Path = None):
|
|
65
|
+
home = home or HOME
|
|
66
|
+
p = Path(os.path.expanduser(path))
|
|
67
|
+
for d in PROTECTED:
|
|
68
|
+
root = home / d
|
|
69
|
+
if p == root or root in p.parents:
|
|
70
|
+
return d
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def split_calendar(sci):
|
|
75
|
+
"""Return the merged schedule if StartCalendarInterval is the split-dict bug,
|
|
76
|
+
else None. Bug: [{Minute: 55}, {Hour: 5}] means "every hour at :55" PLUS
|
|
77
|
+
"every minute from 5:00 to 5:59" — 84 runs a day instead of one."""
|
|
78
|
+
if not isinstance(sci, list):
|
|
79
|
+
return None
|
|
80
|
+
singles = [d for d in sci if isinstance(d, dict) and len(d) == 1]
|
|
81
|
+
if not any("Hour" in d for d in singles) or not any("Minute" in d for d in singles):
|
|
82
|
+
return None
|
|
83
|
+
merged, pending = [], None
|
|
84
|
+
for d in sci:
|
|
85
|
+
if isinstance(d, dict) and len(d) == 1 and set(d) <= {"Hour", "Minute"}:
|
|
86
|
+
if pending is None:
|
|
87
|
+
pending = dict(d)
|
|
88
|
+
continue
|
|
89
|
+
if set(pending) != set(d):
|
|
90
|
+
pending.update(d)
|
|
91
|
+
merged.append(pending)
|
|
92
|
+
pending = None
|
|
93
|
+
continue
|
|
94
|
+
return None # two Hours (or Minutes) in a row: can't pair safely
|
|
95
|
+
if pending is not None:
|
|
96
|
+
return None
|
|
97
|
+
merged.append(d)
|
|
98
|
+
return merged if pending is None else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def runs_per_day(sci) -> int:
|
|
102
|
+
"""How many times a day a StartCalendarInterval fires (ignores Weekday/Day/Month)."""
|
|
103
|
+
if isinstance(sci, dict):
|
|
104
|
+
sci = [sci]
|
|
105
|
+
total = 0
|
|
106
|
+
for d in sci or []:
|
|
107
|
+
if not isinstance(d, dict):
|
|
108
|
+
continue
|
|
109
|
+
total += (1 if "Hour" in d else 24) * (1 if "Minute" in d else 60)
|
|
110
|
+
return total
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def program_args(d: dict) -> list:
|
|
114
|
+
args = list(d.get("ProgramArguments") or [])
|
|
115
|
+
if d.get("Program"):
|
|
116
|
+
args = [d["Program"]] + args[1:]
|
|
117
|
+
return args
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def analyze(label: str, d: dict, home: Path = None) -> list:
|
|
121
|
+
if not d:
|
|
122
|
+
return [Finding(label, "warn", "empty-plist", "plist is empty — launchd ignores it")]
|
|
123
|
+
out = []
|
|
124
|
+
for key in ("StandardOutPath", "StandardErrorPath"):
|
|
125
|
+
v = d.get(key)
|
|
126
|
+
where = v and _protected(v, home)
|
|
127
|
+
if where:
|
|
128
|
+
out.append(Finding(label, "error", "tcc-log-path",
|
|
129
|
+
f"{key} is inside ~/{where}. launchd can't open files there, so the "
|
|
130
|
+
f"job fails with exit 78 and no output at all. Move logs to ~/Library/Logs.",
|
|
131
|
+
fixable=True))
|
|
132
|
+
|
|
133
|
+
sci = d.get("StartCalendarInterval")
|
|
134
|
+
merged = split_calendar(sci)
|
|
135
|
+
if merged is not None:
|
|
136
|
+
out.append(Finding(label, "error", "split-calendar",
|
|
137
|
+
f"StartCalendarInterval has Hour and Minute in separate dicts: fires "
|
|
138
|
+
f"{runs_per_day(sci)}x/day instead of {runs_per_day(merged)}x.",
|
|
139
|
+
fixable=True))
|
|
140
|
+
elif sci and runs_per_day(sci) >= 24 * 60 / 2:
|
|
141
|
+
out.append(Finding(label, "warn", "frequent-calendar",
|
|
142
|
+
f"StartCalendarInterval fires {runs_per_day(sci)}x/day — is a dict missing 'Minute'?"))
|
|
143
|
+
|
|
144
|
+
args = program_args(d)
|
|
145
|
+
if not args:
|
|
146
|
+
out.append(Finding(label, "error", "no-program", "no Program or ProgramArguments"))
|
|
147
|
+
else:
|
|
148
|
+
exe = os.path.expanduser(args[0])
|
|
149
|
+
if not os.path.isabs(exe):
|
|
150
|
+
out.append(Finding(label, "warn", "relative-program",
|
|
151
|
+
f"'{args[0]}' isn't an absolute path; launchd's PATH is minimal "
|
|
152
|
+
f"(/usr/bin:/bin:/usr/sbin:/sbin)."))
|
|
153
|
+
elif not os.path.exists(exe):
|
|
154
|
+
out.append(Finding(label, "error", "missing-program", f"{exe} does not exist (exit 127)"))
|
|
155
|
+
elif not os.access(exe, os.X_OK):
|
|
156
|
+
out.append(Finding(label, "error", "not-executable", f"{exe} is not executable (exit 126)"))
|
|
157
|
+
touched = args[1:] + [d.get("WorkingDirectory") or ""]
|
|
158
|
+
if exe in TCC_BLOCKED_INTERPRETERS and any(a and _protected(a, home) for a in touched):
|
|
159
|
+
out.append(Finding(label, "error", "tcc-interpreter",
|
|
160
|
+
f"{exe} runs a script in a protected folder; macOS blocks it without "
|
|
161
|
+
f"Full Disk Access. Use a Homebrew/python.org interpreter or move the script."))
|
|
162
|
+
for a in args[1:]:
|
|
163
|
+
if a.startswith(("/", "~")) and a.endswith((".py", ".sh", ".rb", ".js")) \
|
|
164
|
+
and not os.path.exists(os.path.expanduser(a)):
|
|
165
|
+
out.append(Finding(label, "error", "missing-script", f"{a} does not exist"))
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── system ────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def sh(cmd, timeout=30):
|
|
172
|
+
try:
|
|
173
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
174
|
+
return p.returncode, p.stdout + p.stderr
|
|
175
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
176
|
+
return 1, str(e)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def job_info(label):
|
|
180
|
+
"""{'runs': int, 'exit': str|None, 'pid': int|None} from launchctl, or None if not loaded."""
|
|
181
|
+
rc, out = sh(["launchctl", "print", f"gui/{UID}/{label}"])
|
|
182
|
+
if rc != 0:
|
|
183
|
+
return None
|
|
184
|
+
runs = re.search(r"^\s+runs = (\d+)", out, re.M)
|
|
185
|
+
code = re.search(r"^\s+last exit code = (-?\d+)", out, re.M)
|
|
186
|
+
pid = re.search(r"^\s+pid = (\d+)", out, re.M)
|
|
187
|
+
return {"runs": int(runs.group(1)) if runs else 0,
|
|
188
|
+
"exit": code.group(1) if code else None,
|
|
189
|
+
"pid": int(pid.group(1)) if pid else None}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def plists(prefix=None):
|
|
193
|
+
for p in sorted(AGENTS_DIR.glob("*.plist")):
|
|
194
|
+
if prefix and not p.stem.startswith(tuple(prefix)):
|
|
195
|
+
continue
|
|
196
|
+
yield p
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load(p: Path):
|
|
200
|
+
try:
|
|
201
|
+
with open(p, "rb") as f:
|
|
202
|
+
return plistlib.load(f)
|
|
203
|
+
except Exception as e: # malformed XML/binary plist
|
|
204
|
+
return e
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def runtime_findings(label, d, info):
|
|
208
|
+
out = []
|
|
209
|
+
if info is None:
|
|
210
|
+
return out
|
|
211
|
+
code = info["exit"]
|
|
212
|
+
if code and code != "0" and code not in SIGNAL_EXITS:
|
|
213
|
+
why = EXIT_CODES.get(code, "signal" if code.startswith("-") else "see the job's log")
|
|
214
|
+
out.append(Finding(label, "error", f"exit-{code}", f"last run exited {code}: {why}"))
|
|
215
|
+
if d.get("KeepAlive") and info["runs"] > 50 and not info["pid"]:
|
|
216
|
+
out.append(Finding(label, "warn", "crash-loop",
|
|
217
|
+
f"KeepAlive job has started {info['runs']} times and isn't running — crash loop?"))
|
|
218
|
+
for key in ("StandardOutPath", "StandardErrorPath"):
|
|
219
|
+
v = d.get(key)
|
|
220
|
+
try:
|
|
221
|
+
size = os.path.getsize(os.path.expanduser(v)) if v else 0
|
|
222
|
+
except OSError:
|
|
223
|
+
size = 0
|
|
224
|
+
if size > 50 * 1024 * 1024:
|
|
225
|
+
out.append(Finding(label, "warn", "big-log", f"{v} is {size // 2**20} MB"))
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def notify(title, text):
|
|
230
|
+
script = f'display notification {json.dumps(text)} with title {json.dumps(title)}'
|
|
231
|
+
sh(["osascript", "-e", script], timeout=10)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── commands ──────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
C = {"error": "\033[31m", "warn": "\033[33m", "ok": "\033[32m", "dim": "\033[2m", "": "\033[0m"}
|
|
237
|
+
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
|
|
238
|
+
C = dict.fromkeys(C, "")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def cmd_scan(a):
|
|
242
|
+
total, bad = 0, 0
|
|
243
|
+
for p in plists(a.prefix):
|
|
244
|
+
total += 1
|
|
245
|
+
d = load(p)
|
|
246
|
+
if isinstance(d, Exception):
|
|
247
|
+
print(f"{C['error']}✗ {p.stem}{C['']}\n plist won't parse: {d}")
|
|
248
|
+
bad += 1
|
|
249
|
+
continue
|
|
250
|
+
label = d.get("Label", p.stem)
|
|
251
|
+
found = analyze(label, d) + runtime_findings(label, d, job_info(label))
|
|
252
|
+
if found:
|
|
253
|
+
bad += 1
|
|
254
|
+
worst = "error" if any(f.severity == "error" for f in found) else "warn"
|
|
255
|
+
print(f"{C[worst]}{'✗' if worst == 'error' else '!'} {label}{C['']}")
|
|
256
|
+
for f in found:
|
|
257
|
+
tag = f" {C['dim']}[fixable]{C['']}" if f.fixable else ""
|
|
258
|
+
print(f" {f.message}{tag}")
|
|
259
|
+
elif a.verbose:
|
|
260
|
+
print(f"{C['ok']}✓ {label}{C['']}")
|
|
261
|
+
print(f"\n{total} agents scanned, {bad} with problems."
|
|
262
|
+
+ (" Run `launchd-doctor fix` to see automatic fixes." if bad else ""))
|
|
263
|
+
return 1 if bad else 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def fixed_plist(label, d):
|
|
267
|
+
"""(new_dict, [descriptions]) for the fixable findings."""
|
|
268
|
+
new, notes = dict(d), []
|
|
269
|
+
for key in ("StandardOutPath", "StandardErrorPath"):
|
|
270
|
+
v = d.get(key)
|
|
271
|
+
if v and _protected(v):
|
|
272
|
+
dest = HOME / "Library" / "Logs" / f"{label}.log"
|
|
273
|
+
if key == "StandardErrorPath" and d.get("StandardOutPath") != v:
|
|
274
|
+
dest = HOME / "Library" / "Logs" / f"{label}.err.log"
|
|
275
|
+
new[key] = str(dest)
|
|
276
|
+
notes.append(f"{key}: {v} → {dest}")
|
|
277
|
+
merged = split_calendar(d.get("StartCalendarInterval"))
|
|
278
|
+
if merged is not None:
|
|
279
|
+
new["StartCalendarInterval"] = merged
|
|
280
|
+
notes.append(f"StartCalendarInterval: {runs_per_day(d['StartCalendarInterval'])}x/day → "
|
|
281
|
+
f"{runs_per_day(merged)}x/day {merged}")
|
|
282
|
+
return new, notes
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def cmd_fix(a):
|
|
286
|
+
changes = 0
|
|
287
|
+
for p in plists(a.prefix):
|
|
288
|
+
d = load(p)
|
|
289
|
+
if isinstance(d, Exception):
|
|
290
|
+
continue
|
|
291
|
+
label = d.get("Label", p.stem)
|
|
292
|
+
new, notes = fixed_plist(label, d)
|
|
293
|
+
if not notes:
|
|
294
|
+
continue
|
|
295
|
+
changes += 1
|
|
296
|
+
print(f"{label}")
|
|
297
|
+
for n in notes:
|
|
298
|
+
print(f" {n}")
|
|
299
|
+
if not a.apply:
|
|
300
|
+
continue
|
|
301
|
+
backup = p.with_suffix(f".plist.bak-{time.strftime('%Y%m%d%H%M%S')}")
|
|
302
|
+
shutil.copy2(p, backup)
|
|
303
|
+
(HOME / "Library" / "Logs").mkdir(parents=True, exist_ok=True)
|
|
304
|
+
with open(p, "wb") as f:
|
|
305
|
+
plistlib.dump(new, f)
|
|
306
|
+
if job_info(label) is not None: # reload so launchd uses the fixed definition
|
|
307
|
+
sh(["launchctl", "bootout", f"gui/{UID}/{label}"])
|
|
308
|
+
rc, out = sh(["launchctl", "bootstrap", f"gui/{UID}", str(p)])
|
|
309
|
+
print(f" {'reloaded' if rc == 0 else 'RELOAD FAILED: ' + out.strip()}")
|
|
310
|
+
print(f" {C['dim']}backup: {backup}{C['']}")
|
|
311
|
+
if not changes:
|
|
312
|
+
print("Nothing to fix automatically.")
|
|
313
|
+
elif not a.apply:
|
|
314
|
+
print("\nDry run. Re-run with --apply to make these changes (each plist is backed up first).")
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _state():
|
|
319
|
+
try:
|
|
320
|
+
return json.loads(STATE_FILE.read_text())
|
|
321
|
+
except (OSError, ValueError):
|
|
322
|
+
return {}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _save(st):
|
|
326
|
+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
tmp = STATE_FILE.with_suffix(".tmp")
|
|
328
|
+
tmp.write_text(json.dumps(st, indent=1))
|
|
329
|
+
tmp.replace(STATE_FILE)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def cooldown(trips: int) -> float:
|
|
333
|
+
return min(BREAKER_COOL_BASE * 2 ** max(0, trips - 1), BREAKER_COOL_MAX)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def breaker_step(b: dict, info, now: float, trip_at: int = BREAKER_TRIP):
|
|
337
|
+
"""Advance one agent's breaker. Returns an action: None | 'trip' | 'trial' | 'closed' | 'broken'."""
|
|
338
|
+
if b["state"] == "open":
|
|
339
|
+
if now - b["opened_at"] >= cooldown(b["trips"]):
|
|
340
|
+
b.update(state="half-open", streak=trip_at - 1, runs=None)
|
|
341
|
+
return "trial"
|
|
342
|
+
return None
|
|
343
|
+
if info is None:
|
|
344
|
+
return None
|
|
345
|
+
runs, code = info["runs"], info["exit"]
|
|
346
|
+
if b.get("runs") is None or runs < b["runs"]: # first sight, or reset by a reload
|
|
347
|
+
b["runs"] = runs
|
|
348
|
+
return None
|
|
349
|
+
if runs == b["runs"] or code is None:
|
|
350
|
+
return None # no new run since last check
|
|
351
|
+
b["runs"] = runs
|
|
352
|
+
if code == "0":
|
|
353
|
+
was = b["state"]
|
|
354
|
+
b.update(streak=0, state="closed")
|
|
355
|
+
if was == "half-open":
|
|
356
|
+
b["trips"] = 0
|
|
357
|
+
return "closed"
|
|
358
|
+
return None
|
|
359
|
+
if code in SIGNAL_EXITS:
|
|
360
|
+
return None
|
|
361
|
+
b["streak"] = b.get("streak", 0) + 1
|
|
362
|
+
b["last_exit"] = code
|
|
363
|
+
if b["streak"] >= trip_at:
|
|
364
|
+
b.update(state="open", opened_at=now, trips=b.get("trips", 0) + 1)
|
|
365
|
+
return "trip"
|
|
366
|
+
if code in ("126", "127"):
|
|
367
|
+
return "broken" # every run will fail the same way: say so now
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def cmd_watch(a):
|
|
372
|
+
if not a.prefix:
|
|
373
|
+
sys.exit("watch needs --prefix (e.g. --prefix com.myname.) so it only manages your own agents.")
|
|
374
|
+
st = _state()
|
|
375
|
+
br = st.setdefault("breaker", {})
|
|
376
|
+
now = time.time()
|
|
377
|
+
for p in plists(a.prefix):
|
|
378
|
+
label = p.stem
|
|
379
|
+
b = br.setdefault(label, {"state": "closed", "streak": 0, "runs": None, "trips": 0})
|
|
380
|
+
info = None if b["state"] == "open" else job_info(label)
|
|
381
|
+
act = breaker_step(b, info, now, a.trip)
|
|
382
|
+
name = label.rsplit(".", 1)[-1]
|
|
383
|
+
if act == "trip":
|
|
384
|
+
msg = f"{name} failed {b['streak']} runs in a row (exit {b['last_exit']}) — paused, " \
|
|
385
|
+
f"retry in {cooldown(b['trips']) / 3600:.0f}h"
|
|
386
|
+
print(msg)
|
|
387
|
+
if not a.dry_run:
|
|
388
|
+
sh(["launchctl", "bootout", f"gui/{UID}/{label}"])
|
|
389
|
+
notify("launchd-doctor: agent paused", msg)
|
|
390
|
+
elif act == "trial":
|
|
391
|
+
print(f"{name}: cooled down — re-enabled for a trial run")
|
|
392
|
+
if not a.dry_run:
|
|
393
|
+
sh(["launchctl", "bootstrap", f"gui/{UID}", str(p)])
|
|
394
|
+
elif act == "closed":
|
|
395
|
+
print(f"{name}: trial run succeeded — breaker closed")
|
|
396
|
+
elif act == "broken":
|
|
397
|
+
msg = f"{name} exit {b['last_exit']}: {EXIT_CODES[b['last_exit']]}"
|
|
398
|
+
print(msg)
|
|
399
|
+
notify("launchd-doctor: launch config broken", msg)
|
|
400
|
+
if not a.dry_run:
|
|
401
|
+
_save(st)
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def cmd_breakers(a):
|
|
406
|
+
br = _state().get("breaker", {})
|
|
407
|
+
rows = [(k, v) for k, v in br.items() if v["state"] != "closed" or v.get("streak")]
|
|
408
|
+
if not rows:
|
|
409
|
+
print("All breakers closed.")
|
|
410
|
+
for k, v in rows:
|
|
411
|
+
extra = ""
|
|
412
|
+
if v["state"] == "open":
|
|
413
|
+
left = cooldown(v["trips"]) - (time.time() - v["opened_at"])
|
|
414
|
+
extra = f", trial in {max(0, left) / 3600:.1f}h"
|
|
415
|
+
print(f"{k}: {v['state']} (streak {v.get('streak', 0)}, last exit {v.get('last_exit')}, "
|
|
416
|
+
f"trips {v.get('trips', 0)}{extra})")
|
|
417
|
+
return 0
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def cmd_resume(a):
|
|
421
|
+
st = _state()
|
|
422
|
+
b = st.get("breaker", {}).get(a.label)
|
|
423
|
+
p = AGENTS_DIR / f"{a.label}.plist"
|
|
424
|
+
if not p.exists():
|
|
425
|
+
sys.exit(f"no such agent: {p}")
|
|
426
|
+
rc, out = sh(["launchctl", "bootstrap", f"gui/{UID}", str(p)])
|
|
427
|
+
if b:
|
|
428
|
+
b.update(state="closed", streak=0, trips=0, runs=None)
|
|
429
|
+
_save(st)
|
|
430
|
+
print("resumed" if rc == 0 else f"bootstrap: {out.strip()} (may already be loaded)")
|
|
431
|
+
return 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def cmd_install_watch(a):
|
|
435
|
+
if not a.prefix:
|
|
436
|
+
sys.exit("install-watch needs --prefix (e.g. --prefix com.myname.)")
|
|
437
|
+
label = "io.github.launchd-doctor.watch"
|
|
438
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
args = [sys.executable, os.path.abspath(__file__), "watch"]
|
|
440
|
+
for pre in a.prefix:
|
|
441
|
+
args += ["--prefix", pre]
|
|
442
|
+
d = {"Label": label, "ProgramArguments": args, "StartInterval": 1800, "RunAtLoad": True,
|
|
443
|
+
"ProcessType": "Background", "LowPriorityIO": True,
|
|
444
|
+
"StandardOutPath": str(LOG_DIR / "watch.log"), "StandardErrorPath": str(LOG_DIR / "watch.log")}
|
|
445
|
+
p = AGENTS_DIR / f"{label}.plist"
|
|
446
|
+
with open(p, "wb") as f:
|
|
447
|
+
plistlib.dump(d, f)
|
|
448
|
+
sh(["launchctl", "bootout", f"gui/{UID}/{label}"])
|
|
449
|
+
rc, out = sh(["launchctl", "bootstrap", f"gui/{UID}", str(p)])
|
|
450
|
+
print(f"installed {p} (every 30 min)" if rc == 0 else f"bootstrap failed: {out}")
|
|
451
|
+
return rc
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def main(argv=None):
|
|
455
|
+
ap = argparse.ArgumentParser(prog="launchd-doctor", description=__doc__,
|
|
456
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
457
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
458
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
459
|
+
|
|
460
|
+
def common(p):
|
|
461
|
+
p.add_argument("--prefix", action="append", help="only agents whose label starts with this")
|
|
462
|
+
return p
|
|
463
|
+
s = common(sub.add_parser("scan", help="find problems (default)"))
|
|
464
|
+
s.add_argument("-v", "--verbose", action="store_true", help="also list healthy agents")
|
|
465
|
+
common(sub.add_parser("fix", help="fix what can be fixed")).add_argument(
|
|
466
|
+
"--apply", action="store_true", help="actually write changes")
|
|
467
|
+
w = common(sub.add_parser("watch", help="circuit breaker pass"))
|
|
468
|
+
w.add_argument("--trip", type=int, default=BREAKER_TRIP, help="failures in a row before pausing")
|
|
469
|
+
w.add_argument("--dry-run", action="store_true")
|
|
470
|
+
common(sub.add_parser("install-watch", help="run watch every 30 min"))
|
|
471
|
+
sub.add_parser("breakers", help="show breaker state")
|
|
472
|
+
sub.add_parser("resume", help="un-pause an agent").add_argument("label")
|
|
473
|
+
|
|
474
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
475
|
+
if not argv or (argv[0] not in sub.choices and argv[0] not in ("-h", "--help", "--version")):
|
|
476
|
+
argv.insert(0, "scan")
|
|
477
|
+
a = ap.parse_args(argv)
|
|
478
|
+
if sys.platform != "darwin":
|
|
479
|
+
sys.exit("launchd-doctor only runs on macOS.")
|
|
480
|
+
return {"scan": cmd_scan, "fix": cmd_fix, "watch": cmd_watch, "install-watch": cmd_install_watch,
|
|
481
|
+
"breakers": cmd_breakers, "resume": cmd_resume}[a.cmd](a)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
if __name__ == "__main__":
|
|
485
|
+
sys.exit(main())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "launchd-doctor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find and fix the macOS LaunchAgent bugs that fail silently, plus a circuit breaker for agents that keep failing."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Victor Navejar (Navii)" }]
|
|
14
|
+
keywords = ["macos", "launchd", "launchagent", "launchctl", "tcc", "automation"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Operating System :: MacOS",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: System :: Monitoring",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest", "ruff"]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
launchd-doctor = "launchd_doctor:main"
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/naviidaviimusic/launchd-doctor"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
py-modules = ["launchd_doctor"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
pythonpath = ["."]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 115
|
|
39
|
+
target-version = "py39"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
|
43
|
+
ignore = ["UP006", "UP035", "B023"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import plistlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import launchd_doctor as ld
|
|
5
|
+
|
|
6
|
+
HOME = Path("/Users/test")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def codes(findings):
|
|
10
|
+
return {f.code for f in findings}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_split_calendar_detected_and_merged():
|
|
14
|
+
bug = [{"Minute": 55}, {"Hour": 5}, {"Minute": 0}, {"Hour": 12}]
|
|
15
|
+
assert ld.runs_per_day(bug) == 24 + 60 + 24 + 60
|
|
16
|
+
merged = ld.split_calendar(bug)
|
|
17
|
+
assert merged == [{"Minute": 55, "Hour": 5}, {"Minute": 0, "Hour": 12}]
|
|
18
|
+
assert ld.runs_per_day(merged) == 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_correct_calendar_untouched():
|
|
22
|
+
assert ld.split_calendar([{"Hour": 5, "Minute": 55}]) is None
|
|
23
|
+
assert ld.split_calendar({"Hour": 5, "Minute": 55}) is None
|
|
24
|
+
assert ld.split_calendar([{"Minute": 0}, {"Minute": 30}]) is None # intentional: twice an hour
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_ambiguous_split_not_merged():
|
|
28
|
+
assert ld.split_calendar([{"Hour": 5}, {"Hour": 6}, {"Minute": 0}]) is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_tcc_log_path():
|
|
32
|
+
d = {"Label": "x", "ProgramArguments": ["/bin/echo"],
|
|
33
|
+
"StandardOutPath": "/Users/test/Documents/proj/x.log"}
|
|
34
|
+
f = ld.analyze("x", d, HOME)
|
|
35
|
+
assert "tcc-log-path" in codes(f)
|
|
36
|
+
assert all(x.fixable for x in f if x.code == "tcc-log-path")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_logs_outside_protected_ok():
|
|
40
|
+
d = {"ProgramArguments": ["/bin/echo"], "StandardOutPath": "/Users/test/Library/Logs/x.log"}
|
|
41
|
+
assert ld.analyze("x", d, HOME) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_missing_program_and_script():
|
|
45
|
+
d = {"ProgramArguments": ["/nope/python3", "/nope/job.py"]}
|
|
46
|
+
assert {"missing-program", "missing-script"} <= codes(ld.analyze("x", d, HOME))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_tcc_interpreter():
|
|
50
|
+
d = {"ProgramArguments": ["/usr/bin/python3", "/Users/test/Documents/job.py"]}
|
|
51
|
+
assert "tcc-interpreter" in codes(ld.analyze("x", d, HOME))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_empty_plist():
|
|
55
|
+
assert codes(ld.analyze("x", {}, HOME)) == {"empty-plist"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_fix_dry_run_and_apply(tmp_path, monkeypatch):
|
|
59
|
+
agents = tmp_path / "Library" / "LaunchAgents"
|
|
60
|
+
agents.mkdir(parents=True)
|
|
61
|
+
monkeypatch.setattr(ld, "HOME", tmp_path)
|
|
62
|
+
monkeypatch.setattr(ld, "AGENTS_DIR", agents)
|
|
63
|
+
monkeypatch.setattr(ld, "job_info", lambda label: None)
|
|
64
|
+
p = agents / "com.t.job.plist"
|
|
65
|
+
d = {"Label": "com.t.job", "ProgramArguments": ["/bin/echo"],
|
|
66
|
+
"StandardOutPath": str(tmp_path / "Documents" / "o.log"),
|
|
67
|
+
"StandardErrorPath": str(tmp_path / "Documents" / "o.log"),
|
|
68
|
+
"StartCalendarInterval": [{"Minute": 5}, {"Hour": 3}]}
|
|
69
|
+
p.write_bytes(plistlib.dumps(d))
|
|
70
|
+
|
|
71
|
+
ld.main(["fix"])
|
|
72
|
+
assert plistlib.loads(p.read_bytes()) == d # dry run changes nothing
|
|
73
|
+
|
|
74
|
+
ld.main(["fix", "--apply"])
|
|
75
|
+
new = plistlib.loads(p.read_bytes())
|
|
76
|
+
assert new["StartCalendarInterval"] == [{"Minute": 5, "Hour": 3}]
|
|
77
|
+
assert new["StandardOutPath"] == new["StandardErrorPath"] == str(tmp_path / "Library/Logs/com.t.job.log")
|
|
78
|
+
assert list(agents.glob("*.bak-*")), "backup written"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_breaker_trips_after_three_failures_and_recovers():
|
|
82
|
+
b = {"state": "closed", "streak": 0, "runs": None, "trips": 0}
|
|
83
|
+
now = 1_000_000.0
|
|
84
|
+
assert ld.breaker_step(b, {"runs": 10, "exit": "1"}, now) is None # baseline
|
|
85
|
+
assert ld.breaker_step(b, {"runs": 11, "exit": "1"}, now) is None
|
|
86
|
+
assert ld.breaker_step(b, {"runs": 11, "exit": "1"}, now) is None # no new run
|
|
87
|
+
assert ld.breaker_step(b, {"runs": 12, "exit": "1"}, now) is None
|
|
88
|
+
assert ld.breaker_step(b, {"runs": 13, "exit": "1"}, now) == "trip"
|
|
89
|
+
assert b["state"] == "open" and b["trips"] == 1
|
|
90
|
+
assert ld.breaker_step(b, None, now + 3600) is None # still cooling
|
|
91
|
+
assert ld.breaker_step(b, None, now + 24 * 3600) == "trial"
|
|
92
|
+
assert ld.breaker_step(b, {"runs": 0, "exit": None}, now) is None # reloaded: new baseline
|
|
93
|
+
assert ld.breaker_step(b, {"runs": 1, "exit": "0"}, now) == "closed"
|
|
94
|
+
assert b["trips"] == 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_breaker_ignores_signals_and_doubles_cooldown():
|
|
98
|
+
b = {"state": "closed", "streak": 0, "runs": 0, "trips": 0}
|
|
99
|
+
assert ld.breaker_step(b, {"runs": 1, "exit": "-15"}, 0) is None
|
|
100
|
+
assert b["streak"] == 0
|
|
101
|
+
assert ld.cooldown(1) == 24 * 3600 and ld.cooldown(2) == 48 * 3600
|
|
102
|
+
assert ld.cooldown(10) == ld.BREAKER_COOL_MAX
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_breaker_flags_broken_launch_config():
|
|
106
|
+
b = {"state": "closed", "streak": 0, "runs": 0, "trips": 0}
|
|
107
|
+
assert ld.breaker_step(b, {"runs": 1, "exit": "127"}, 0) == "broken"
|