python-dmon 0.3.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
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.
- dmon/cli.py +44 -19
- dmon/config.py +28 -3
- dmon/control.py +344 -190
- dmon/runner.py +74 -27
- dmon/types.py +40 -3
- {python_dmon-0.3.0.dist-info → python_dmon-0.3.1.dist-info}/METADATA +30 -4
- python_dmon-0.3.1.dist-info/RECORD +14 -0
- python_dmon-0.3.0.dist-info/RECORD +0 -14
- {python_dmon-0.3.0.dist-info → python_dmon-0.3.1.dist-info}/WHEEL +0 -0
- {python_dmon-0.3.0.dist-info → python_dmon-0.3.1.dist-info}/entry_points.txt +0 -0
dmon/cli.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import os
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
import shlex
|
|
4
|
-
import sys
|
|
5
5
|
|
|
6
6
|
from colorama import just_fix_windows_console
|
|
7
7
|
|
|
8
|
-
from .config import check_name_in_config, get_task_config
|
|
8
|
+
from .config import check_name_in_config, get_task_config, load_config
|
|
9
9
|
from .control import (
|
|
10
10
|
execute,
|
|
11
11
|
get_meta_paths,
|
|
@@ -193,9 +193,9 @@ def main():
|
|
|
193
193
|
)
|
|
194
194
|
sp_run.add_argument(
|
|
195
195
|
"command_list",
|
|
196
|
-
metavar="
|
|
197
|
-
nargs=argparse.
|
|
198
|
-
help="Command
|
|
196
|
+
metavar="COMMAND",
|
|
197
|
+
nargs=argparse.REMAINDER,
|
|
198
|
+
help="Command and arguments to run; '--' is an optional separator",
|
|
199
199
|
)
|
|
200
200
|
|
|
201
201
|
sp_exec = subparsers.add_parser(
|
|
@@ -212,7 +212,9 @@ def main():
|
|
|
212
212
|
# add custom config file option
|
|
213
213
|
for sp in [sp_start, sp_stop, sp_restart, sp_status, sp_exec]:
|
|
214
214
|
sp.add_argument(
|
|
215
|
+
"-c",
|
|
215
216
|
"--config",
|
|
217
|
+
metavar="PATH",
|
|
216
218
|
help="Path to config file or the directory containing it (default: search from current directory upwards)",
|
|
217
219
|
)
|
|
218
220
|
|
|
@@ -221,7 +223,10 @@ def main():
|
|
|
221
223
|
if args.command in ["start", "restart"]:
|
|
222
224
|
sp = sp_start if args.command == "start" else sp_restart
|
|
223
225
|
try:
|
|
224
|
-
tasks, task_cfgs = get_task_config(
|
|
226
|
+
tasks, task_cfgs, cfg_path = get_task_config(
|
|
227
|
+
args.task, args.config, args.all
|
|
228
|
+
)
|
|
229
|
+
os.chdir(cfg_path.parent)
|
|
225
230
|
except Exception as e:
|
|
226
231
|
sp.error(str(e))
|
|
227
232
|
|
|
@@ -245,19 +250,34 @@ def main():
|
|
|
245
250
|
task_cfg.rotate_log_path or ROTATE_LOG_PATH_TEMPLATE.format(task=task)
|
|
246
251
|
)
|
|
247
252
|
if args.command == "start":
|
|
248
|
-
|
|
253
|
+
sp.exit(start(task_cfgs))
|
|
249
254
|
else:
|
|
250
|
-
|
|
255
|
+
sp.exit(restart(task_cfgs))
|
|
251
256
|
elif args.command == "exec":
|
|
252
257
|
try:
|
|
253
|
-
_, task_cfgs = get_task_config(args.task, args.config)
|
|
258
|
+
_, task_cfgs, cfg_path = get_task_config(args.task, args.config)
|
|
259
|
+
os.chdir(cfg_path.parent)
|
|
254
260
|
except Exception as e:
|
|
255
261
|
sp_exec.error(str(e))
|
|
256
|
-
|
|
262
|
+
sp_exec.exit(execute(task_cfgs[0]))
|
|
257
263
|
elif args.command in ["stop", "status"]:
|
|
258
264
|
sp = sp_stop if args.command == "stop" else sp_status
|
|
259
265
|
meta_paths = []
|
|
260
266
|
|
|
267
|
+
tasks = args.task
|
|
268
|
+
if args.task:
|
|
269
|
+
try:
|
|
270
|
+
tasks, _, cfg_path = get_task_config(args.task, args.config)
|
|
271
|
+
os.chdir(cfg_path.parent)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
sp.error(str(e))
|
|
274
|
+
elif args.config:
|
|
275
|
+
try:
|
|
276
|
+
_, cfg_path = load_config(args.config)
|
|
277
|
+
os.chdir(cfg_path.parent)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
sp.error(str(e))
|
|
280
|
+
|
|
261
281
|
# Collect meta paths from --all
|
|
262
282
|
if args.all:
|
|
263
283
|
meta_paths.extend(get_meta_paths(DEFAULT_META_DIR))
|
|
@@ -267,14 +287,14 @@ def main():
|
|
|
267
287
|
meta_paths.append(args.meta_file)
|
|
268
288
|
|
|
269
289
|
# Collect meta paths from task names
|
|
270
|
-
if len(
|
|
271
|
-
tasks = args.task
|
|
290
|
+
if len(tasks) > 0:
|
|
272
291
|
meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
|
|
273
292
|
|
|
274
293
|
# If no meta paths collected, use default task
|
|
275
294
|
if len(meta_paths) == 0:
|
|
276
295
|
try:
|
|
277
|
-
tasks, _ = get_task_config(args.task, args.config)
|
|
296
|
+
tasks, _, cfg_path = get_task_config(args.task, args.config)
|
|
297
|
+
os.chdir(cfg_path.parent)
|
|
278
298
|
except Exception as e:
|
|
279
299
|
sp.error(str(e))
|
|
280
300
|
meta_paths.extend([META_PATH_TEMPLATE.format(task=task) for task in tasks])
|
|
@@ -283,13 +303,18 @@ def main():
|
|
|
283
303
|
unique_meta_paths = sorted(set(Path(p).resolve() for p in meta_paths))
|
|
284
304
|
|
|
285
305
|
if args.command == "stop":
|
|
286
|
-
|
|
306
|
+
sp.exit(stop(unique_meta_paths))
|
|
287
307
|
else:
|
|
288
|
-
|
|
308
|
+
sp.exit(status(unique_meta_paths))
|
|
289
309
|
elif args.command == "list":
|
|
290
310
|
dir = args.dir or DEFAULT_META_DIR
|
|
291
|
-
|
|
311
|
+
sp_list.exit(list_processes(dir, args.full))
|
|
292
312
|
elif args.command == "run":
|
|
313
|
+
command_list = args.command_list
|
|
314
|
+
if command_list and command_list[0] == "--":
|
|
315
|
+
command_list = command_list[1:]
|
|
316
|
+
if not command_list:
|
|
317
|
+
sp_run.error("Please provide a command to run.")
|
|
293
318
|
if not args.name:
|
|
294
319
|
sp_run.error("Please provide a non-empty name for the task.")
|
|
295
320
|
elif check_name_in_config(args.name):
|
|
@@ -299,7 +324,7 @@ def main():
|
|
|
299
324
|
|
|
300
325
|
task_cfg = DmonTaskConfig(
|
|
301
326
|
task=args.name,
|
|
302
|
-
cmd=shlex.join(
|
|
327
|
+
cmd=shlex.join(command_list) if args.shell else command_list,
|
|
303
328
|
cwd=args.cwd,
|
|
304
329
|
meta_path=args.meta_file or META_PATH_TEMPLATE.format(task=args.name),
|
|
305
330
|
log_path=args.log_file or LOG_PATH_TEMPLATE.format(task=args.name),
|
|
@@ -307,10 +332,10 @@ def main():
|
|
|
307
332
|
rotate_log_path=args.rotate_log_path
|
|
308
333
|
or ROTATE_LOG_PATH_TEMPLATE.format(task=args.name),
|
|
309
334
|
)
|
|
310
|
-
|
|
335
|
+
sp_run.exit(start([task_cfg]))
|
|
311
336
|
else:
|
|
312
337
|
parser.print_help()
|
|
313
|
-
|
|
338
|
+
parser.exit(1)
|
|
314
339
|
|
|
315
340
|
|
|
316
341
|
if __name__ == "__main__":
|
dmon/config.py
CHANGED
|
@@ -64,6 +64,10 @@ def load_config(cfg_path: Optional[str] = None):
|
|
|
64
64
|
cfg = cfg.get("tool", {}).get("dmon", {})
|
|
65
65
|
else:
|
|
66
66
|
raise ValueError("Config file must be YAML (.yaml/.yml) or TOML (.toml)")
|
|
67
|
+
if cfg is None:
|
|
68
|
+
cfg = {}
|
|
69
|
+
if not isinstance(cfg, dict):
|
|
70
|
+
raise TypeError(f"Config in '{path}' must be a table")
|
|
67
71
|
return cfg, path
|
|
68
72
|
|
|
69
73
|
|
|
@@ -130,6 +134,14 @@ def validate_task(task, name: str) -> DmonTaskConfig:
|
|
|
130
134
|
)
|
|
131
135
|
ret.log_max_size = task["log_max_size"]
|
|
132
136
|
|
|
137
|
+
if "log_backup_count" in task:
|
|
138
|
+
value = task["log_backup_count"]
|
|
139
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
140
|
+
raise TypeError(
|
|
141
|
+
f"Task '{name}' 'log_backup_count' field must be a positive integer"
|
|
142
|
+
)
|
|
143
|
+
ret.log_backup_count = value
|
|
144
|
+
|
|
133
145
|
if "rotate_log_path" in task:
|
|
134
146
|
if not isinstance(task["rotate_log_path"], str):
|
|
135
147
|
raise TypeError(
|
|
@@ -147,6 +159,14 @@ def validate_task(task, name: str) -> DmonTaskConfig:
|
|
|
147
159
|
)
|
|
148
160
|
ret.rotate_log_max_size = task["rotate_log_max_size"]
|
|
149
161
|
|
|
162
|
+
if "rotate_log_backup_count" in task:
|
|
163
|
+
value = task["rotate_log_backup_count"]
|
|
164
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
165
|
+
raise TypeError(
|
|
166
|
+
f"Task '{name}' 'rotate_log_backup_count' field must be a positive integer"
|
|
167
|
+
)
|
|
168
|
+
ret.rotate_log_backup_count = value
|
|
169
|
+
|
|
150
170
|
if "meta_path" in task:
|
|
151
171
|
if not isinstance(task["meta_path"], str):
|
|
152
172
|
raise TypeError(f"Task '{name}' 'meta_path' field must be a string")
|
|
@@ -160,7 +180,7 @@ def validate_task(task, name: str) -> DmonTaskConfig:
|
|
|
160
180
|
|
|
161
181
|
def get_task_config(
|
|
162
182
|
names: Union[Sequence[str], str, None], cfg_path: Optional[str], all: bool = False
|
|
163
|
-
) -> Tuple[Sequence[str], List[DmonTaskConfig]]:
|
|
183
|
+
) -> Tuple[Sequence[str], List[DmonTaskConfig], Path]:
|
|
164
184
|
"""
|
|
165
185
|
Get the validated task configurations for the given task names.
|
|
166
186
|
If 'all' is True, return all tasks.
|
|
@@ -195,6 +215,7 @@ def get_task_config(
|
|
|
195
215
|
else:
|
|
196
216
|
raise ValueError(f"Multiple tasks found in {path}; please specify one.")
|
|
197
217
|
|
|
218
|
+
ret_names = []
|
|
198
219
|
ret_tasks = []
|
|
199
220
|
for name in names:
|
|
200
221
|
name = name.lower()
|
|
@@ -202,8 +223,9 @@ def get_task_config(
|
|
|
202
223
|
raise ValueError(f"Task '{name}' not found in {path}")
|
|
203
224
|
|
|
204
225
|
task = validate_task(tasks[name], name)
|
|
226
|
+
ret_names.append(name)
|
|
205
227
|
ret_tasks.append(task)
|
|
206
|
-
return
|
|
228
|
+
return ret_names, ret_tasks, path
|
|
207
229
|
|
|
208
230
|
|
|
209
231
|
def check_name_in_config(name: str) -> bool:
|
|
@@ -211,7 +233,10 @@ def check_name_in_config(name: str) -> bool:
|
|
|
211
233
|
Check if the given task name exists in the tasks.
|
|
212
234
|
Return True if found, False otherwise.
|
|
213
235
|
"""
|
|
214
|
-
|
|
236
|
+
try:
|
|
237
|
+
cfg, _ = load_config()
|
|
238
|
+
except FileNotFoundError:
|
|
239
|
+
return False
|
|
215
240
|
tasks = cfg.get("tasks", {})
|
|
216
241
|
|
|
217
242
|
if not isinstance(tasks, dict):
|
dmon/control.py
CHANGED
|
@@ -35,11 +35,24 @@ def ensure_log_dir(log_path: Path):
|
|
|
35
35
|
|
|
36
36
|
def start(cfgs: Sequence[DmonTaskConfig]):
|
|
37
37
|
ret = 0
|
|
38
|
+
failed = []
|
|
38
39
|
for idx, cfg in enumerate(cfgs):
|
|
39
|
-
|
|
40
|
-
ret |=
|
|
40
|
+
result = start_single(cfg)
|
|
41
|
+
ret |= result
|
|
42
|
+
if result:
|
|
43
|
+
failed.append(cfg.task)
|
|
41
44
|
if idx < len(cfgs) - 1:
|
|
42
45
|
print("---", file=sys.stderr) # print a blank line between tasks
|
|
46
|
+
if failed and len(cfgs) > 1:
|
|
47
|
+
succeeded = len(cfgs) - len(failed)
|
|
48
|
+
failed_tasks = ", ".join(
|
|
49
|
+
colored(f"'{task}'", color="cyan", attrs=["bold"]) for task in failed
|
|
50
|
+
)
|
|
51
|
+
print(
|
|
52
|
+
f"\nStarted {succeeded} task(s); failed to start: {failed_tasks}. "
|
|
53
|
+
"Multi-task start is best-effort, so successful tasks were left running.",
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
43
56
|
return ret
|
|
44
57
|
|
|
45
58
|
|
|
@@ -49,47 +62,76 @@ def start_single(cfg: DmonTaskConfig):
|
|
|
49
62
|
cwd = Path(cfg.cwd).resolve()
|
|
50
63
|
|
|
51
64
|
try:
|
|
52
|
-
|
|
53
|
-
except
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
existing_meta = DmonMeta.load(meta_path)
|
|
66
|
+
except (OSError, ValueError, TypeError) as error:
|
|
67
|
+
print(
|
|
68
|
+
colored(
|
|
69
|
+
f"Start failed: cannot read metadata {meta_path}: {error}",
|
|
70
|
+
color="red",
|
|
71
|
+
attrs=["bold"],
|
|
72
|
+
),
|
|
73
|
+
file=sys.stderr,
|
|
74
|
+
)
|
|
75
|
+
return 1
|
|
76
|
+
if existing_meta and existing_meta.state == "starting":
|
|
77
|
+
print(
|
|
78
|
+
colored(
|
|
79
|
+
f"Start failed: task '{cfg.task}' is already being started by another dmon process.",
|
|
80
|
+
color="red",
|
|
81
|
+
attrs=["bold"],
|
|
82
|
+
),
|
|
83
|
+
file=sys.stderr,
|
|
84
|
+
)
|
|
85
|
+
print(
|
|
86
|
+
"If the earlier start was interrupted, run 'dmon stop' to remove its reservation.",
|
|
87
|
+
file=sys.stderr,
|
|
88
|
+
)
|
|
89
|
+
return 1
|
|
90
|
+
if existing_meta and check_running(existing_meta.pid, existing_meta.create_time):
|
|
56
91
|
print(
|
|
57
92
|
f"{colored('Start failed: meta file already exists', color='red', attrs=['bold'])}",
|
|
58
93
|
file=sys.stderr,
|
|
59
94
|
)
|
|
60
|
-
print_status(
|
|
95
|
+
print_status(existing_meta)
|
|
61
96
|
print(
|
|
62
97
|
"\nRun 'dmon status' / 'dmon list' to check, or 'dmon stop' to stop it.",
|
|
63
98
|
file=sys.stderr,
|
|
64
99
|
)
|
|
65
100
|
return 1
|
|
101
|
+
if existing_meta:
|
|
102
|
+
print(
|
|
103
|
+
colored(
|
|
104
|
+
f"Removing stale metadata for exited task '{cfg.task}'.",
|
|
105
|
+
color="yellow",
|
|
106
|
+
),
|
|
107
|
+
file=sys.stderr,
|
|
108
|
+
)
|
|
109
|
+
meta_path.unlink(missing_ok=True)
|
|
66
110
|
|
|
67
111
|
ensure_meta_dir(meta_path)
|
|
68
112
|
ensure_log_dir(log_path)
|
|
69
113
|
|
|
70
|
-
env =
|
|
71
|
-
if cfg.override_env:
|
|
72
|
-
env = cfg.env
|
|
73
|
-
elif cfg.env:
|
|
74
|
-
env = {**os.environ, **cfg.env}
|
|
114
|
+
env = task_environment(cfg)
|
|
75
115
|
|
|
76
116
|
shell = isinstance(cfg.cmd, str)
|
|
77
117
|
|
|
78
118
|
# Platform-specific parameters to run the process in background detached from parent
|
|
79
119
|
kwargs = {}
|
|
120
|
+
command = cfg.cmd
|
|
80
121
|
if ON_WINDOWS:
|
|
81
122
|
# DETACHED_PROCESS = 0x00000008
|
|
82
123
|
CREATE_NO_WINDOW = 0x08000000
|
|
83
124
|
kwargs["creationflags"] = CREATE_NO_WINDOW
|
|
84
|
-
if isinstance(
|
|
125
|
+
if isinstance(command, list) and len(command) > 0:
|
|
85
126
|
# On Windows, use full path for the executable when shell=False
|
|
86
|
-
|
|
127
|
+
command = [shutil.which(command[0]) or command[0], *command[1:]]
|
|
87
128
|
else:
|
|
88
129
|
# Make the child process independent of the parent process in Unix-like systems
|
|
89
130
|
kwargs["start_new_session"] = True
|
|
90
131
|
|
|
91
132
|
meta = DmonMeta(
|
|
92
133
|
task=cfg.task,
|
|
134
|
+
state="starting",
|
|
93
135
|
meta_path=str(meta_path),
|
|
94
136
|
log_path=str(log_path),
|
|
95
137
|
log_rotate=cfg.log_rotate,
|
|
@@ -101,63 +143,97 @@ def start_single(cfg: DmonTaskConfig):
|
|
|
101
143
|
popen_kwargs=kwargs,
|
|
102
144
|
)
|
|
103
145
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if isinstance(cfg.cmd, str):
|
|
115
|
-
# when cmd is str, we need to split it into list for subprocess
|
|
116
|
-
cmd = shlex.split(cfg.cmd)
|
|
117
|
-
else:
|
|
118
|
-
cmd = cfg.cmd
|
|
119
|
-
args = [
|
|
120
|
-
sys.executable,
|
|
121
|
-
"-m",
|
|
122
|
-
"dmon.runner",
|
|
123
|
-
"--log-path",
|
|
124
|
-
str(log_path),
|
|
125
|
-
"--max-log-size",
|
|
126
|
-
str(cfg.log_max_size),
|
|
127
|
-
"--rotate-log-path",
|
|
128
|
-
str(rotate_log_path),
|
|
129
|
-
"--max-rotate-log-size",
|
|
130
|
-
str(cfg.rotate_log_max_size),
|
|
131
|
-
]
|
|
132
|
-
if shell:
|
|
133
|
-
args.append("--shell")
|
|
134
|
-
args.append("--")
|
|
135
|
-
args.extend(cmd)
|
|
136
|
-
proc = subprocess.Popen(
|
|
137
|
-
args,
|
|
138
|
-
cwd=cwd,
|
|
139
|
-
env=env,
|
|
140
|
-
stdout=subprocess.DEVNULL,
|
|
141
|
-
stderr=subprocess.STDOUT,
|
|
142
|
-
**kwargs,
|
|
146
|
+
try:
|
|
147
|
+
meta.dump(meta_path, exclusive=True)
|
|
148
|
+
except FileExistsError:
|
|
149
|
+
print(
|
|
150
|
+
colored(
|
|
151
|
+
f"Start failed: another dmon process is starting task '{cfg.task}'.",
|
|
152
|
+
color="red",
|
|
153
|
+
attrs=["bold"],
|
|
154
|
+
),
|
|
155
|
+
file=sys.stderr,
|
|
143
156
|
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
157
|
+
return 1
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
if cfg.log_rotate:
|
|
161
|
+
rotate_log_path = Path(cfg.rotate_log_path).resolve()
|
|
162
|
+
|
|
163
|
+
meta.rotate_log_path = str(rotate_log_path)
|
|
164
|
+
meta.log_max_size = cfg.log_max_size
|
|
165
|
+
meta.log_backup_count = cfg.log_backup_count
|
|
166
|
+
meta.rotate_log_max_size = cfg.rotate_log_max_size
|
|
167
|
+
meta.rotate_log_backup_count = cfg.rotate_log_backup_count
|
|
168
|
+
|
|
169
|
+
ensure_log_dir(rotate_log_path)
|
|
170
|
+
|
|
171
|
+
# use runner to start user process and handle log rotation
|
|
172
|
+
if isinstance(command, str):
|
|
173
|
+
# when cmd is str, we need to split it into list for subprocess
|
|
174
|
+
cmd = shlex.split(command)
|
|
175
|
+
else:
|
|
176
|
+
cmd = command
|
|
177
|
+
args = [
|
|
178
|
+
sys.executable,
|
|
179
|
+
"-m",
|
|
180
|
+
"dmon.runner",
|
|
181
|
+
"--log-path",
|
|
182
|
+
str(log_path),
|
|
183
|
+
"--max-log-size",
|
|
184
|
+
str(cfg.log_max_size),
|
|
185
|
+
"--rotate-log-path",
|
|
186
|
+
str(rotate_log_path),
|
|
187
|
+
"--max-rotate-log-size",
|
|
188
|
+
str(cfg.rotate_log_max_size),
|
|
189
|
+
]
|
|
190
|
+
if cfg.log_backup_count is not None:
|
|
191
|
+
args.extend(["--log-backup-count", str(cfg.log_backup_count)])
|
|
192
|
+
if cfg.rotate_log_backup_count is not None:
|
|
193
|
+
args.extend(
|
|
194
|
+
["--rotate-log-backup-count", str(cfg.rotate_log_backup_count)]
|
|
195
|
+
)
|
|
196
|
+
if shell:
|
|
197
|
+
args.append("--shell")
|
|
198
|
+
args.append("--")
|
|
199
|
+
args.extend(cmd)
|
|
148
200
|
proc = subprocess.Popen(
|
|
149
|
-
|
|
150
|
-
stdout=lof,
|
|
151
|
-
stderr=subprocess.STDOUT,
|
|
201
|
+
args,
|
|
152
202
|
cwd=cwd,
|
|
153
203
|
env=env,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
bufsize=0, # unbuffered
|
|
204
|
+
stdout=subprocess.DEVNULL,
|
|
205
|
+
stderr=subprocess.STDOUT,
|
|
157
206
|
**kwargs,
|
|
158
207
|
)
|
|
208
|
+
else:
|
|
209
|
+
# Open the log file (append binary mode)
|
|
210
|
+
with open(log_path, "ab", buffering=0) as lof:
|
|
211
|
+
# Start the child process with stdout/stderr redirected to the log
|
|
212
|
+
proc = subprocess.Popen(
|
|
213
|
+
command,
|
|
214
|
+
stdout=lof,
|
|
215
|
+
stderr=subprocess.STDOUT,
|
|
216
|
+
cwd=cwd,
|
|
217
|
+
env=env,
|
|
218
|
+
shell=shell,
|
|
219
|
+
text=False, # binary mode
|
|
220
|
+
bufsize=0, # unbuffered
|
|
221
|
+
**kwargs,
|
|
222
|
+
)
|
|
223
|
+
except OSError as error:
|
|
224
|
+
meta_path.unlink(missing_ok=True)
|
|
225
|
+
print(
|
|
226
|
+
colored(
|
|
227
|
+
f"Start failed for task '{cfg.task}': {error}",
|
|
228
|
+
color="red",
|
|
229
|
+
attrs=["bold"],
|
|
230
|
+
),
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
return 1
|
|
159
234
|
|
|
160
235
|
meta.pid = proc.pid
|
|
236
|
+
meta.state = "running"
|
|
161
237
|
try:
|
|
162
238
|
p = psutil.Process(proc.pid)
|
|
163
239
|
create_time = p.create_time()
|
|
@@ -166,16 +242,42 @@ def start_single(cfg: DmonTaskConfig):
|
|
|
166
242
|
)
|
|
167
243
|
meta.create_time = create_time
|
|
168
244
|
meta.create_time_human = create_time_human
|
|
169
|
-
except psutil.NoSuchProcess:
|
|
245
|
+
except (psutil.NoSuchProcess, ValueError):
|
|
170
246
|
# process already exited?
|
|
171
247
|
pass
|
|
172
248
|
|
|
173
|
-
|
|
249
|
+
try:
|
|
250
|
+
meta.dump(meta_path)
|
|
251
|
+
except OSError as error:
|
|
252
|
+
try:
|
|
253
|
+
managed_process = psutil.Process(proc.pid)
|
|
254
|
+
except psutil.NoSuchProcess:
|
|
255
|
+
pass
|
|
256
|
+
else:
|
|
257
|
+
terminate_process(managed_process, timeout=2.0)
|
|
258
|
+
meta_path.unlink(missing_ok=True)
|
|
259
|
+
print(
|
|
260
|
+
colored(
|
|
261
|
+
f"Start failed: cannot save metadata for task '{cfg.task}': {error}",
|
|
262
|
+
color="red",
|
|
263
|
+
attrs=["bold"],
|
|
264
|
+
),
|
|
265
|
+
file=sys.stderr,
|
|
266
|
+
)
|
|
267
|
+
return 1
|
|
174
268
|
|
|
175
269
|
print_status(meta)
|
|
176
270
|
return 0
|
|
177
271
|
|
|
178
272
|
|
|
273
|
+
def task_environment(cfg: DmonTaskConfig):
|
|
274
|
+
if cfg.override_env:
|
|
275
|
+
return cfg.env
|
|
276
|
+
if cfg.env:
|
|
277
|
+
return {**os.environ, **cfg.env}
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
179
281
|
def stop(meta_paths: Sequence[PathType], timeout=5.0):
|
|
180
282
|
ret = 0
|
|
181
283
|
for idx, meta_path in enumerate(meta_paths):
|
|
@@ -191,7 +293,18 @@ def stop_single(
|
|
|
191
293
|
timeout=5.0,
|
|
192
294
|
):
|
|
193
295
|
meta_path = Path(meta_path).resolve()
|
|
194
|
-
|
|
296
|
+
try:
|
|
297
|
+
meta = DmonMeta.load(meta_path)
|
|
298
|
+
except (OSError, ValueError, TypeError) as error:
|
|
299
|
+
print(
|
|
300
|
+
colored(
|
|
301
|
+
f"Stop failed: cannot read metadata {meta_path}: {error}",
|
|
302
|
+
color="red",
|
|
303
|
+
attrs=["bold"],
|
|
304
|
+
),
|
|
305
|
+
file=sys.stderr,
|
|
306
|
+
)
|
|
307
|
+
return 1
|
|
195
308
|
if meta is None:
|
|
196
309
|
print(
|
|
197
310
|
colored(
|
|
@@ -207,7 +320,7 @@ def stop_single(
|
|
|
207
320
|
pid = meta.pid
|
|
208
321
|
try:
|
|
209
322
|
proc = psutil.Process(pid)
|
|
210
|
-
except psutil.NoSuchProcess:
|
|
323
|
+
except (psutil.NoSuchProcess, ValueError):
|
|
211
324
|
print(
|
|
212
325
|
colored(
|
|
213
326
|
f"Process {pid} not found (already exited); removing stale meta file",
|
|
@@ -218,7 +331,7 @@ def stop_single(
|
|
|
218
331
|
)
|
|
219
332
|
print_status(meta)
|
|
220
333
|
meta_path.unlink(missing_ok=True)
|
|
221
|
-
return
|
|
334
|
+
return 0
|
|
222
335
|
|
|
223
336
|
# check if it's the same process by comparing create_time
|
|
224
337
|
if not check_same_process(proc, meta.create_time):
|
|
@@ -232,135 +345,132 @@ def stop_single(
|
|
|
232
345
|
)
|
|
233
346
|
print_status(meta)
|
|
234
347
|
meta_path.unlink(missing_ok=True)
|
|
235
|
-
return
|
|
348
|
+
return 0
|
|
349
|
+
if not process_is_alive(proc):
|
|
350
|
+
print(
|
|
351
|
+
colored(
|
|
352
|
+
f"Process {pid} already exited; removing stale meta file",
|
|
353
|
+
color="yellow",
|
|
354
|
+
attrs=["bold"],
|
|
355
|
+
),
|
|
356
|
+
file=sys.stderr,
|
|
357
|
+
)
|
|
358
|
+
print_status(meta)
|
|
359
|
+
meta_path.unlink(missing_ok=True)
|
|
360
|
+
return 0
|
|
236
361
|
|
|
237
|
-
|
|
238
|
-
ret = terminate_win(proc, timeout)
|
|
239
|
-
else:
|
|
240
|
-
ret = terminate_posix(proc, timeout)
|
|
362
|
+
ret = terminate_process(proc, timeout)
|
|
241
363
|
print_status(meta)
|
|
242
364
|
if ret == 0:
|
|
243
365
|
meta_path.unlink(missing_ok=True)
|
|
244
366
|
return ret
|
|
245
367
|
|
|
246
368
|
|
|
247
|
-
def
|
|
248
|
-
|
|
249
|
-
|
|
369
|
+
def terminate_process(proc: psutil.Process, timeout: float) -> int:
|
|
370
|
+
if not ON_WINDOWS:
|
|
371
|
+
try:
|
|
372
|
+
process_group = os.getpgid(proc.pid)
|
|
373
|
+
except (ProcessLookupError, psutil.NoSuchProcess):
|
|
374
|
+
return 0
|
|
375
|
+
if process_group == proc.pid:
|
|
376
|
+
return terminate_posix_group(proc, process_group, timeout)
|
|
377
|
+
return terminate_process_tree(proc, timeout)
|
|
378
|
+
|
|
250
379
|
|
|
251
|
-
|
|
380
|
+
def terminate_posix_group(
|
|
381
|
+
proc: psutil.Process, process_group: int, timeout: float
|
|
382
|
+
) -> int:
|
|
252
383
|
try:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
)
|
|
262
|
-
|
|
384
|
+
os.killpg(process_group, signal.SIGTERM)
|
|
385
|
+
except ProcessLookupError:
|
|
386
|
+
return 0
|
|
387
|
+
|
|
388
|
+
deadline = time.monotonic() + timeout
|
|
389
|
+
while time.monotonic() < deadline and process_group_exists(process_group):
|
|
390
|
+
try:
|
|
391
|
+
proc.wait(timeout=0)
|
|
392
|
+
except (psutil.NoSuchProcess, psutil.TimeoutExpired):
|
|
393
|
+
pass
|
|
394
|
+
time.sleep(0.05)
|
|
395
|
+
if process_group_exists(process_group):
|
|
263
396
|
print(
|
|
264
397
|
colored(
|
|
265
|
-
f"Process {
|
|
398
|
+
f"Process group {process_group} did not exit in time; killing it",
|
|
266
399
|
color="yellow",
|
|
267
400
|
attrs=["bold"],
|
|
268
401
|
),
|
|
269
402
|
file=sys.stderr,
|
|
270
403
|
)
|
|
271
|
-
|
|
272
|
-
# first shut down child processes (leaf nodes first)
|
|
273
|
-
for child in reversed(proc.children(recursive=True)):
|
|
274
|
-
try:
|
|
275
|
-
child.terminate()
|
|
276
|
-
child.wait(timeout=2)
|
|
277
|
-
except Exception:
|
|
278
|
-
try:
|
|
279
|
-
child.kill()
|
|
280
|
-
except Exception:
|
|
281
|
-
pass
|
|
282
|
-
# then shut down the parent process
|
|
283
404
|
try:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
color="green",
|
|
290
|
-
attrs=["bold"],
|
|
291
|
-
),
|
|
292
|
-
file=sys.stderr,
|
|
293
|
-
)
|
|
294
|
-
except psutil.TimeoutExpired:
|
|
295
|
-
print(
|
|
296
|
-
colored(
|
|
297
|
-
f"Process {proc.pid} did not exit in time after terminating children; killing it",
|
|
298
|
-
color="yellow",
|
|
299
|
-
attrs=["bold"],
|
|
300
|
-
),
|
|
301
|
-
file=sys.stderr,
|
|
302
|
-
)
|
|
303
|
-
try:
|
|
304
|
-
proc.kill()
|
|
305
|
-
print(
|
|
306
|
-
colored(
|
|
307
|
-
f"Killed process {proc.pid}; removing meta file",
|
|
308
|
-
color="green",
|
|
309
|
-
attrs=["bold"],
|
|
310
|
-
),
|
|
311
|
-
file=sys.stderr,
|
|
312
|
-
)
|
|
313
|
-
except Exception as e:
|
|
314
|
-
print(
|
|
315
|
-
colored(
|
|
316
|
-
f"Failed to kill process {proc.pid}: {e}",
|
|
317
|
-
color="red",
|
|
318
|
-
attrs=["bold"],
|
|
319
|
-
),
|
|
320
|
-
file=sys.stderr,
|
|
321
|
-
)
|
|
405
|
+
os.killpg(process_group, signal.SIGKILL)
|
|
406
|
+
except ProcessLookupError:
|
|
407
|
+
return 0
|
|
408
|
+
except PermissionError:
|
|
409
|
+
if process_group_exists(process_group):
|
|
322
410
|
return 1
|
|
411
|
+
try:
|
|
412
|
+
proc.wait(timeout=1)
|
|
413
|
+
except (psutil.NoSuchProcess, psutil.TimeoutExpired):
|
|
414
|
+
pass
|
|
415
|
+
print(
|
|
416
|
+
colored(
|
|
417
|
+
f"Process group {process_group} stopped; removing meta file",
|
|
418
|
+
color="green",
|
|
419
|
+
attrs=["bold"],
|
|
420
|
+
),
|
|
421
|
+
file=sys.stderr,
|
|
422
|
+
)
|
|
323
423
|
return 0
|
|
324
424
|
|
|
325
425
|
|
|
326
|
-
def
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
for child in reversed(children):
|
|
426
|
+
def process_group_exists(process_group: int) -> bool:
|
|
427
|
+
for process in psutil.process_iter(["pid", "status"]):
|
|
428
|
+
if process.info["status"] == psutil.STATUS_ZOMBIE:
|
|
429
|
+
continue
|
|
331
430
|
try:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
except
|
|
335
|
-
|
|
431
|
+
if os.getpgid(process.info["pid"]) == process_group:
|
|
432
|
+
return True
|
|
433
|
+
except (OSError, psutil.NoSuchProcess):
|
|
434
|
+
continue
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def terminate_process_tree(proc: psutil.Process, timeout: float) -> int:
|
|
336
439
|
try:
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
file=sys.stderr,
|
|
354
|
-
)
|
|
355
|
-
proc.kill()
|
|
440
|
+
processes = [*proc.children(recursive=True), proc]
|
|
441
|
+
except psutil.NoSuchProcess:
|
|
442
|
+
return 0
|
|
443
|
+
for process in reversed(processes):
|
|
444
|
+
try:
|
|
445
|
+
process.terminate()
|
|
446
|
+
except psutil.NoSuchProcess:
|
|
447
|
+
pass
|
|
448
|
+
_, alive = psutil.wait_procs(processes, timeout=timeout)
|
|
449
|
+
for process in alive:
|
|
450
|
+
try:
|
|
451
|
+
process.kill()
|
|
452
|
+
except psutil.NoSuchProcess:
|
|
453
|
+
pass
|
|
454
|
+
_, alive = psutil.wait_procs(alive, timeout=1)
|
|
455
|
+
if alive:
|
|
356
456
|
print(
|
|
357
457
|
colored(
|
|
358
|
-
|
|
359
|
-
|
|
458
|
+
"Failed to stop process tree: "
|
|
459
|
+
+ ", ".join(str(process.pid) for process in alive),
|
|
460
|
+
color="red",
|
|
360
461
|
attrs=["bold"],
|
|
361
462
|
),
|
|
362
463
|
file=sys.stderr,
|
|
363
464
|
)
|
|
465
|
+
return 1
|
|
466
|
+
print(
|
|
467
|
+
colored(
|
|
468
|
+
f"Process tree {proc.pid} stopped; removing meta file",
|
|
469
|
+
color="green",
|
|
470
|
+
attrs=["bold"],
|
|
471
|
+
),
|
|
472
|
+
file=sys.stderr,
|
|
473
|
+
)
|
|
364
474
|
return 0
|
|
365
475
|
|
|
366
476
|
|
|
@@ -379,7 +489,21 @@ def status(meta_paths: Sequence[PathType]):
|
|
|
379
489
|
metas = []
|
|
380
490
|
for idx, meta_path in enumerate(meta_paths):
|
|
381
491
|
meta_path = Path(meta_path).resolve()
|
|
382
|
-
|
|
492
|
+
try:
|
|
493
|
+
meta = DmonMeta.load(meta_path)
|
|
494
|
+
except (OSError, ValueError, TypeError) as error:
|
|
495
|
+
print(
|
|
496
|
+
colored(
|
|
497
|
+
f"Status failed: cannot read metadata {meta_path}: {error}",
|
|
498
|
+
color="red",
|
|
499
|
+
attrs=["bold"],
|
|
500
|
+
),
|
|
501
|
+
file=sys.stderr,
|
|
502
|
+
)
|
|
503
|
+
ret |= 1
|
|
504
|
+
if idx < len(meta_paths) - 1:
|
|
505
|
+
print("---", file=sys.stderr)
|
|
506
|
+
continue
|
|
383
507
|
if meta is None:
|
|
384
508
|
print(
|
|
385
509
|
colored(
|
|
@@ -394,6 +518,8 @@ def status(meta_paths: Sequence[PathType]):
|
|
|
394
518
|
else:
|
|
395
519
|
print_status(meta)
|
|
396
520
|
metas.append(meta)
|
|
521
|
+
if not check_running(meta.pid, meta.create_time):
|
|
522
|
+
ret |= 1
|
|
397
523
|
if idx < len(meta_paths) - 1:
|
|
398
524
|
print("---", file=sys.stderr)
|
|
399
525
|
if metas:
|
|
@@ -408,6 +534,13 @@ def check_same_process(proc: psutil.Process, create_time: float) -> bool:
|
|
|
408
534
|
return abs(proc.create_time() - create_time) < 1e-3
|
|
409
535
|
|
|
410
536
|
|
|
537
|
+
def process_is_alive(proc: psutil.Process) -> bool:
|
|
538
|
+
try:
|
|
539
|
+
return proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE
|
|
540
|
+
except psutil.NoSuchProcess:
|
|
541
|
+
return False
|
|
542
|
+
|
|
543
|
+
|
|
411
544
|
def get_unique_process(pid: int, create_time: float):
|
|
412
545
|
"""
|
|
413
546
|
Get the process with given PID and create_time if it is still the same process.
|
|
@@ -415,7 +548,7 @@ def get_unique_process(pid: int, create_time: float):
|
|
|
415
548
|
if create_time >= 0:
|
|
416
549
|
try:
|
|
417
550
|
p = psutil.Process(pid)
|
|
418
|
-
if check_same_process(p, create_time):
|
|
551
|
+
if check_same_process(p, create_time) and process_is_alive(p):
|
|
419
552
|
return p
|
|
420
553
|
except psutil.NoSuchProcess:
|
|
421
554
|
pass
|
|
@@ -431,7 +564,7 @@ def check_running(pid: int, create_time: float) -> bool:
|
|
|
431
564
|
return False
|
|
432
565
|
try:
|
|
433
566
|
p = psutil.Process(pid)
|
|
434
|
-
return
|
|
567
|
+
return check_same_process(p, create_time) and process_is_alive(p)
|
|
435
568
|
except psutil.NoSuchProcess:
|
|
436
569
|
return False
|
|
437
570
|
|
|
@@ -458,7 +591,11 @@ def print_status(meta: DmonMeta):
|
|
|
458
591
|
if meta.log_rotate:
|
|
459
592
|
rows.append(("ROTATE LOG PATH", meta.rotate_log_path))
|
|
460
593
|
rows.append(("LOG MAX SIZE", f"{meta.log_max_size} MB"))
|
|
594
|
+
if meta.log_backup_count is not None:
|
|
595
|
+
rows.append(("LOG BACKUP COUNT", meta.log_backup_count))
|
|
461
596
|
rows.append(("ROTATE LOG MAX SIZE", f"{meta.rotate_log_max_size} MB"))
|
|
597
|
+
if meta.rotate_log_backup_count is not None:
|
|
598
|
+
rows.append(("ROTATE LOG BACKUP COUNT", meta.rotate_log_backup_count))
|
|
462
599
|
|
|
463
600
|
# calculate the max width of the keys
|
|
464
601
|
key_width = max(len(key) for key, _ in rows)
|
|
@@ -564,8 +701,21 @@ def list_processes(dir: PathType, full_width: bool):
|
|
|
564
701
|
target_dmon_dir = Path(dir).resolve()
|
|
565
702
|
meta_paths = get_meta_paths(target_dmon_dir)
|
|
566
703
|
metas = []
|
|
704
|
+
errors = 0
|
|
567
705
|
for meta_path in meta_paths:
|
|
568
|
-
|
|
706
|
+
try:
|
|
707
|
+
meta = DmonMeta.load(meta_path)
|
|
708
|
+
except (OSError, ValueError, TypeError) as error:
|
|
709
|
+
print(
|
|
710
|
+
colored(
|
|
711
|
+
f"Cannot read metadata {meta_path}: {error}",
|
|
712
|
+
color="red",
|
|
713
|
+
attrs=["bold"],
|
|
714
|
+
),
|
|
715
|
+
file=sys.stderr,
|
|
716
|
+
)
|
|
717
|
+
errors += 1
|
|
718
|
+
continue
|
|
569
719
|
if meta is not None:
|
|
570
720
|
metas.append(meta)
|
|
571
721
|
# sort by name (case-insensitive)
|
|
@@ -577,7 +727,7 @@ def list_processes(dir: PathType, full_width: bool):
|
|
|
577
727
|
f"\nFound {n_task} task{'s' if n_task > 1 else ''} ({n_proc} process{'es' if n_proc > 1 else ''}) in {target_dmon_dir}",
|
|
578
728
|
file=sys.stderr,
|
|
579
729
|
)
|
|
580
|
-
return 0
|
|
730
|
+
return 1 if errors else 0
|
|
581
731
|
|
|
582
732
|
|
|
583
733
|
def execute(cfg: DmonTaskConfig):
|
|
@@ -587,25 +737,13 @@ def execute(cfg: DmonTaskConfig):
|
|
|
587
737
|
"""
|
|
588
738
|
cwd = Path(cfg.cwd).resolve()
|
|
589
739
|
|
|
590
|
-
env =
|
|
591
|
-
if cfg.override_env:
|
|
592
|
-
env = cfg.env
|
|
593
|
-
elif cfg.env:
|
|
594
|
-
env = {**os.environ, **cfg.env}
|
|
740
|
+
env = task_environment(cfg)
|
|
595
741
|
|
|
596
742
|
shell = isinstance(cfg.cmd, str)
|
|
597
743
|
if ON_WINDOWS and isinstance(cfg.cmd, list) and len(cfg.cmd) > 0:
|
|
598
744
|
# On Windows, use full path for the executable when shell=False
|
|
599
745
|
cfg.cmd[0] = shutil.which(cfg.cmd[0]) or cfg.cmd[0]
|
|
600
746
|
|
|
601
|
-
def signal_handler(signum: int, frame) -> None:
|
|
602
|
-
if ON_WINDOWS and signum == signal.SIGINT:
|
|
603
|
-
signum = signal.SIGTERM
|
|
604
|
-
proc.send_signal(signum)
|
|
605
|
-
|
|
606
|
-
prev_handle_int = signal.signal(signal.SIGINT, signal_handler)
|
|
607
|
-
prev_handle_term = signal.signal(signal.SIGTERM, signal_handler)
|
|
608
|
-
|
|
609
747
|
proc = subprocess.Popen(
|
|
610
748
|
cfg.cmd,
|
|
611
749
|
cwd=cwd,
|
|
@@ -614,8 +752,24 @@ def execute(cfg: DmonTaskConfig):
|
|
|
614
752
|
bufsize=0, # unbuffered
|
|
615
753
|
close_fds=False,
|
|
616
754
|
)
|
|
617
|
-
ret = proc.wait()
|
|
618
755
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
756
|
+
def signal_handler(signum: int, frame) -> None:
|
|
757
|
+
# POSIX terminals deliver SIGINT to both foreground processes. Forwarding
|
|
758
|
+
# it would make the child receive Ctrl-C twice. Windows needs explicit
|
|
759
|
+
# forwarding, translated to a signal subprocess can deliver reliably.
|
|
760
|
+
if not ON_WINDOWS and signum == signal.SIGINT:
|
|
761
|
+
return
|
|
762
|
+
if ON_WINDOWS and signum == signal.SIGINT:
|
|
763
|
+
signum = signal.SIGTERM
|
|
764
|
+
try:
|
|
765
|
+
proc.send_signal(signum)
|
|
766
|
+
except ProcessLookupError:
|
|
767
|
+
pass
|
|
768
|
+
|
|
769
|
+
prev_handle_int = signal.signal(signal.SIGINT, signal_handler)
|
|
770
|
+
prev_handle_term = signal.signal(signal.SIGTERM, signal_handler)
|
|
771
|
+
try:
|
|
772
|
+
return proc.wait()
|
|
773
|
+
finally:
|
|
774
|
+
signal.signal(signal.SIGTERM, prev_handle_term)
|
|
775
|
+
signal.signal(signal.SIGINT, prev_handle_int)
|
dmon/runner.py
CHANGED
|
@@ -3,30 +3,63 @@ from datetime import datetime
|
|
|
3
3
|
import logging
|
|
4
4
|
from logging.handlers import RotatingFileHandler
|
|
5
5
|
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
6
8
|
import signal
|
|
7
9
|
import subprocess
|
|
8
10
|
import sys
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
logger = logging.getLogger("dmon.runner")
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
TIMESTAMP_FORMAT = "%Y%m%d-%H%M%S"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def timestamped_backups(log_path):
|
|
18
|
+
path = Path(log_path)
|
|
19
|
+
# Include the pre-0.3.1 runner-log format so opt-in retention can also
|
|
20
|
+
# clean up legacy archives on platforms where ':' was a valid filename.
|
|
21
|
+
timestamp = r"(?:\d{8}-\d{6}|\d{8}-\d{2}:\d{2}:\d{2})"
|
|
22
|
+
pattern = re.compile(rf"^{re.escape(path.name)}\.{timestamp}(?:\.\d+)?$")
|
|
23
|
+
return [
|
|
24
|
+
candidate
|
|
25
|
+
for candidate in path.parent.iterdir()
|
|
26
|
+
if pattern.fullmatch(candidate.name)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def rotate_file(log_path, timestamp=None, backup_count=None):
|
|
31
|
+
path = Path(log_path)
|
|
32
|
+
timestamp = timestamp or datetime.now().strftime(TIMESTAMP_FORMAT)
|
|
33
|
+
backup = Path(f"{path}.{timestamp}")
|
|
34
|
+
counter = 1
|
|
35
|
+
while backup.exists():
|
|
36
|
+
backup = Path(f"{path}.{timestamp}.{counter}")
|
|
37
|
+
counter += 1
|
|
38
|
+
|
|
39
|
+
os.replace(path, backup)
|
|
40
|
+
if backup_count is not None:
|
|
41
|
+
backups = sorted(
|
|
42
|
+
timestamped_backups(path),
|
|
43
|
+
key=lambda item: (item.stat().st_mtime_ns, item.name),
|
|
44
|
+
)
|
|
45
|
+
for old_backup in backups[:-backup_count]:
|
|
46
|
+
old_backup.unlink()
|
|
47
|
+
return backup
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TimestampedRotatingFileHandler(RotatingFileHandler):
|
|
51
|
+
def __init__(self, filename, maxBytes, backup_count=None):
|
|
52
|
+
super().__init__(filename, maxBytes=maxBytes, backupCount=1)
|
|
53
|
+
self.backup_count = backup_count
|
|
22
54
|
|
|
23
55
|
def doRollover(self):
|
|
24
56
|
if self.stream:
|
|
25
57
|
self.stream.close()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
58
|
+
self.stream = None
|
|
59
|
+
if os.path.exists(self.baseFilename):
|
|
60
|
+
rotate_file(self.baseFilename, backup_count=self.backup_count)
|
|
61
|
+
if not self.delay:
|
|
62
|
+
self.stream = self._open()
|
|
30
63
|
|
|
31
64
|
|
|
32
65
|
def get_file_dir(file_path):
|
|
@@ -48,18 +81,12 @@ def need_rotate(log_path, max_log_size):
|
|
|
48
81
|
return os.path.getsize(log_path) >= max_log_size and max_log_size > 0
|
|
49
82
|
|
|
50
83
|
|
|
51
|
-
def rotate_log(log_path):
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
logger.info(f"Rotating {log_path} to {new_name}")
|
|
55
|
-
if os.path.exists(new_name):
|
|
56
|
-
logger.warning(f"{new_name} already exists, skip renaming")
|
|
57
|
-
else:
|
|
58
|
-
make_file_dir(new_name)
|
|
59
|
-
os.rename(log_path, new_name)
|
|
84
|
+
def rotate_log(log_path, backup_count=None):
|
|
85
|
+
logger.info(f"Rotating {log_path}")
|
|
86
|
+
rotate_file(log_path, backup_count=backup_count)
|
|
60
87
|
|
|
61
88
|
|
|
62
|
-
def loop_to_log(bin_fd, log_path, max_log_size):
|
|
89
|
+
def loop_to_log(bin_fd, log_path, max_log_size, backup_count=None):
|
|
63
90
|
while True: # loop once whenever need to rotate
|
|
64
91
|
with open(log_path, "ab") as log_file:
|
|
65
92
|
while True:
|
|
@@ -79,7 +106,7 @@ def loop_to_log(bin_fd, log_path, max_log_size):
|
|
|
79
106
|
return
|
|
80
107
|
except Exception as e:
|
|
81
108
|
logger.exception(f"Exception in while loop: {e}")
|
|
82
|
-
rotate_log(log_path) # rotate log file
|
|
109
|
+
rotate_log(log_path, backup_count) # rotate log file
|
|
83
110
|
|
|
84
111
|
|
|
85
112
|
def catch_exception(func):
|
|
@@ -97,13 +124,19 @@ def main(
|
|
|
97
124
|
cmd,
|
|
98
125
|
log_path,
|
|
99
126
|
max_log_size,
|
|
127
|
+
log_backup_count,
|
|
100
128
|
rotate_log_path,
|
|
101
129
|
max_rotate_log_size,
|
|
130
|
+
rotate_log_backup_count,
|
|
102
131
|
):
|
|
103
132
|
# Configure logging
|
|
104
133
|
rh = None
|
|
105
134
|
if rotate_log_path:
|
|
106
|
-
rh =
|
|
135
|
+
rh = TimestampedRotatingFileHandler(
|
|
136
|
+
rotate_log_path,
|
|
137
|
+
maxBytes=max_rotate_log_size,
|
|
138
|
+
backup_count=rotate_log_backup_count,
|
|
139
|
+
)
|
|
107
140
|
logging.basicConfig(
|
|
108
141
|
format="%(asctime)s.%(msecs)03d - %(process)d - %(levelname)s - %(message)s",
|
|
109
142
|
level=logging.INFO,
|
|
@@ -149,7 +182,7 @@ def main(
|
|
|
149
182
|
logger.info(f"Started process {proc.pid} with command: {cmd} (shell={shell})")
|
|
150
183
|
|
|
151
184
|
make_file_dir(log_path)
|
|
152
|
-
loop_to_log(proc.stdout, log_path, max_log_size)
|
|
185
|
+
loop_to_log(proc.stdout, log_path, max_log_size, log_backup_count)
|
|
153
186
|
|
|
154
187
|
|
|
155
188
|
if __name__ == "__main__":
|
|
@@ -175,18 +208,32 @@ if __name__ == "__main__":
|
|
|
175
208
|
help="Path of the log output of this rotation process; if not provided, output to stdout",
|
|
176
209
|
default=None,
|
|
177
210
|
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--log-backup-count",
|
|
213
|
+
help="Number of task log archives to retain; omit to retain all",
|
|
214
|
+
type=int,
|
|
215
|
+
default=None,
|
|
216
|
+
)
|
|
178
217
|
parser.add_argument(
|
|
179
218
|
"--max-rotate-log-size",
|
|
180
219
|
help="Max file size (MB) for this rotation log; 0 for no rotation",
|
|
181
220
|
type=float,
|
|
182
221
|
default=5,
|
|
183
222
|
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--rotate-log-backup-count",
|
|
225
|
+
help="Number of runner log archives to retain; omit to retain all",
|
|
226
|
+
type=int,
|
|
227
|
+
default=None,
|
|
228
|
+
)
|
|
184
229
|
args = parser.parse_args()
|
|
185
230
|
main(
|
|
186
231
|
" ".join(args.command) if args.shell else args.command,
|
|
187
232
|
args.log_path,
|
|
188
233
|
int(args.max_log_size * 1024 * 1024),
|
|
234
|
+
args.log_backup_count,
|
|
189
235
|
args.rotate_log_path,
|
|
190
236
|
int(args.max_rotate_log_size * 1024 * 1024),
|
|
237
|
+
args.rotate_log_backup_count,
|
|
191
238
|
)
|
|
192
239
|
logger.info("Process finished.")
|
dmon/types.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from dataclasses import asdict, dataclass, field
|
|
2
2
|
import json
|
|
3
|
+
import os
|
|
3
4
|
from os import PathLike
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
import sys
|
|
7
|
+
import tempfile
|
|
6
8
|
from typing import Dict, List, Optional, Union
|
|
7
9
|
|
|
8
10
|
|
|
@@ -33,10 +35,14 @@ class DmonTaskConfig:
|
|
|
33
35
|
"""Whether to rotate log file"""
|
|
34
36
|
log_max_size: float = 5
|
|
35
37
|
"""Size in MB to rotate log file"""
|
|
38
|
+
log_backup_count: Optional[int] = None
|
|
39
|
+
"""Number of task log archives to retain; None keeps all archives"""
|
|
36
40
|
rotate_log_path: str = ""
|
|
37
41
|
"""Path to rotation log file"""
|
|
38
42
|
rotate_log_max_size: float = 5
|
|
39
43
|
"""Size in MB to rotation log file"""
|
|
44
|
+
rotate_log_backup_count: Optional[int] = None
|
|
45
|
+
"""Number of runner log archives to retain; None keeps all archives"""
|
|
40
46
|
meta_path: str = ""
|
|
41
47
|
"""Path to meta file"""
|
|
42
48
|
|
|
@@ -44,14 +50,45 @@ class DmonTaskConfig:
|
|
|
44
50
|
@dataclass
|
|
45
51
|
class DmonMeta(DmonTaskConfig):
|
|
46
52
|
pid: int = -1
|
|
53
|
+
state: str = "running"
|
|
47
54
|
shell: bool = False
|
|
48
55
|
popen_kwargs: Dict = field(default_factory=dict)
|
|
49
56
|
create_time: float = -1
|
|
50
57
|
create_time_human: str = "N/A"
|
|
51
58
|
|
|
52
|
-
def dump(self, path: PathType):
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
def dump(self, path: PathType, *, exclusive: bool = False):
|
|
60
|
+
target = Path(path)
|
|
61
|
+
data = asdict(self)
|
|
62
|
+
if exclusive:
|
|
63
|
+
descriptor = os.open(
|
|
64
|
+
target,
|
|
65
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
66
|
+
0o600,
|
|
67
|
+
)
|
|
68
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
69
|
+
json.dump(data, stream, indent=2, ensure_ascii=False)
|
|
70
|
+
stream.flush()
|
|
71
|
+
os.fsync(stream.fileno())
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
temporary_path = None
|
|
75
|
+
try:
|
|
76
|
+
with tempfile.NamedTemporaryFile(
|
|
77
|
+
mode="w",
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
dir=target.parent,
|
|
80
|
+
prefix=f".{target.name}.",
|
|
81
|
+
suffix=".tmp",
|
|
82
|
+
delete=False,
|
|
83
|
+
) as stream:
|
|
84
|
+
temporary_path = Path(stream.name)
|
|
85
|
+
json.dump(data, stream, indent=2, ensure_ascii=False)
|
|
86
|
+
stream.flush()
|
|
87
|
+
os.fsync(stream.fileno())
|
|
88
|
+
os.replace(temporary_path, target)
|
|
89
|
+
finally:
|
|
90
|
+
if temporary_path is not None:
|
|
91
|
+
temporary_path.unlink(missing_ok=True)
|
|
55
92
|
|
|
56
93
|
@staticmethod
|
|
57
94
|
def load(path: PathType) -> Optional["DmonMeta"]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: python-dmon
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A lightweight, cross-platform daemon manager that runs any command as a background process.
|
|
5
5
|
Keywords: python-dmon,dmon,daemon,background,detach,process management
|
|
6
6
|
Author: Atomie CHEN
|
|
@@ -37,7 +37,7 @@ It is a Python-based and more powerful successor to the [handy-backend shell scr
|
|
|
37
37
|
- 🖥️ **Cross-platform:** Works on Linux, macOS, and Windows.
|
|
38
38
|
- ⚡ **Lightweight:** Pure Python, no Docker or external dependencies needed.
|
|
39
39
|
- 🧩 **Flexible tasks:** Tasks can be configured in `pyproject.toml` or `dmon.yaml`; or run ad-hoc commands directly.
|
|
40
|
-
- 🪵 **Logging & log rotation:**
|
|
40
|
+
- 🪵 **Logging & log rotation:** Keep active log files manageable, with optional archive retention limits.
|
|
41
41
|
|
|
42
42
|

|
|
43
43
|
|
|
@@ -126,6 +126,11 @@ dmon exec app
|
|
|
126
126
|
|
|
127
127
|
You can specify multiple tasks at once, e.g.: `dmon start app1 app2 app3`, except for `dmon exec` which only accepts one task.
|
|
128
128
|
|
|
129
|
+
Multi-task `start` is best-effort: dmon attempts every requested task and leaves
|
|
130
|
+
successful tasks running if another task cannot start. The command returns a
|
|
131
|
+
non-zero status and prints a summary naming the failed tasks. This is useful for
|
|
132
|
+
independent background services and does not provide atomic stack semantics.
|
|
133
|
+
|
|
129
134
|
Or use `--all` to operate on all tasks:
|
|
130
135
|
|
|
131
136
|
```sh
|
|
@@ -145,12 +150,12 @@ dmon status
|
|
|
145
150
|
dmon exec
|
|
146
151
|
```
|
|
147
152
|
|
|
148
|
-
You can use `--config` to specify a custom config file or the directory containing it:
|
|
153
|
+
You can use `-c` / `--config` to specify a custom config file or the directory containing it:
|
|
149
154
|
|
|
150
155
|
```sh
|
|
151
156
|
dmon start --config /path/to/dmon.yaml app # YAML
|
|
152
157
|
dmon start --config /path/to/pyproject.toml app # or TOML
|
|
153
|
-
dmon start
|
|
158
|
+
dmon start -c /path/to/dir app # shorter, dir with `dmon.y(a)ml` or `pyproject.toml`
|
|
154
159
|
```
|
|
155
160
|
|
|
156
161
|
And yes, you can use `dmon` to run in a nested manner:
|
|
@@ -172,6 +177,9 @@ tasks:
|
|
|
172
177
|
# Run a command with arguments in the background
|
|
173
178
|
dmon run --name myserver python -u server.py
|
|
174
179
|
|
|
180
|
+
# Optionally use -- to make the child-command boundary explicit
|
|
181
|
+
dmon run --name timer -- python -c 'import time; time.sleep(30)'
|
|
182
|
+
|
|
175
183
|
# Run a shell command in the background
|
|
176
184
|
dmon run --shell echo "Hello World"
|
|
177
185
|
|
|
@@ -194,6 +202,16 @@ dmon list
|
|
|
194
202
|
|
|
195
203
|
A task can be a **string**, **list**, or **dictionary**.
|
|
196
204
|
|
|
205
|
+
When rotation is enabled, dmon keeps timestamped archives such as
|
|
206
|
+
`app.log.20260807-142106`. Both task and runner logs use this cross-platform
|
|
207
|
+
format; a same-second collision adds `.1`, `.2`, and so on. Archives are never
|
|
208
|
+
deleted by default. Set a backup count explicitly to enable retention cleanup.
|
|
209
|
+
`log_path` contains task output; `rotate_log_path` contains diagnostics from the
|
|
210
|
+
dmon process that captures and rotates that output. They are independent log
|
|
211
|
+
streams and use independent retention settings.
|
|
212
|
+
The size limit is checked at line boundaries, so a single long line may exceed
|
|
213
|
+
the configured limit.
|
|
214
|
+
|
|
197
215
|
Here is a more complete example with default values:
|
|
198
216
|
|
|
199
217
|
```yaml
|
|
@@ -208,8 +226,10 @@ tasks:
|
|
|
208
226
|
log_path: "logs/<task>.log" # path to log file
|
|
209
227
|
log_rotate: false # enable log rotation
|
|
210
228
|
log_max_size: 5 # max log file size before rotation in MB
|
|
229
|
+
# log_backup_count: 10 # optional; omit to retain all task log archives
|
|
211
230
|
rotate_log_path: "logs/<task>.rotate.log" # path to rotation log
|
|
212
231
|
rotate_log_max_size: 5 # max rotation log file size in MB
|
|
232
|
+
# rotate_log_backup_count: 10 # optional; omit to retain all runner log archives
|
|
213
233
|
meta_path: ".dmon/<task>.meta.json" # path to meta file
|
|
214
234
|
default_task: your_task_name # the default task name
|
|
215
235
|
```
|
|
@@ -227,6 +247,8 @@ another_task = "cd subdir && ls && bash start.sh"
|
|
|
227
247
|
default_task = "your_task_name"
|
|
228
248
|
```
|
|
229
249
|
|
|
250
|
+
All paths can be absolute or relative to the **config file location**.
|
|
251
|
+
|
|
230
252
|
|
|
231
253
|
## Under the Hood
|
|
232
254
|
|
|
@@ -234,6 +256,10 @@ Each task is associated with a meta file (e.g. `.dmon/<task>.meta.json`) stored
|
|
|
234
256
|
The file contains details such as the command, PID, log path, and more.
|
|
235
257
|
**Do not** modify or delete these files manually.
|
|
236
258
|
|
|
259
|
+
`dmon status` returns a non-zero status if a recorded task has exited. Starting
|
|
260
|
+
that task again removes its stale metadata automatically. `dmon stop` terminates
|
|
261
|
+
the complete process tree and also cleans stale metadata left by an exited task.
|
|
262
|
+
|
|
237
263
|
|
|
238
264
|
## License
|
|
239
265
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
dmon/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
dmon/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
|
|
3
|
+
dmon/cli.py,sha256=M4T7QY5fbeQ94Dx3_OA-FQKVXnJlEStIAtOefmoaaYg,11436
|
|
4
|
+
dmon/config.py,sha256=cYK-KV3sSc6UqOm5cN1dgU-SWRBGoiNE_xFOg2D4GKU,8903
|
|
5
|
+
dmon/constants.py,sha256=uJPI66IJvT6vhamqEDeqHjZQS12RxCPe3DD5yCU0UR8,407
|
|
6
|
+
dmon/control.py,sha256=XaEz8JS2MnsztQC23Dj7guhg6IgNz3uavdfcAyWDbrE,24064
|
|
7
|
+
dmon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
dmon/runner.py,sha256=XNUbvw-eOZUQBQ_CLpZNLTFjjI3Uh0IDK_PgqggUPyk,7597
|
|
9
|
+
dmon/types.py,sha256=0BoCkc1hyqriDrPleDVQ7Z53f-U5WeF_DyLLVCVru3k,3131
|
|
10
|
+
dmon/utils.py,sha256=zVQC9YNhMQu4hrAp9k2ds2egnbWZZv7S5w8i2oVIPJg,808
|
|
11
|
+
python_dmon-0.3.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
12
|
+
python_dmon-0.3.1.dist-info/entry_points.txt,sha256=1XmEpyjShANXEHMIMa4p3WXpcllIZfGt5DQyTFuSI74,78
|
|
13
|
+
python_dmon-0.3.1.dist-info/METADATA,sha256=W4_--3lc_zT4RkTC83susBVqNVUF5WZSBOgtAlY1QzU,8216
|
|
14
|
+
python_dmon-0.3.1.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
dmon/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
dmon/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
|
|
3
|
-
dmon/cli.py,sha256=NgTrOsYjXRPI6BO1T9VReO__quyis91sY04nfk7OWTQ,10514
|
|
4
|
-
dmon/config.py,sha256=JAxasW5G0NIok6hZUZVEpsvF3vp5iiDNr2pjnLpUmh4,7911
|
|
5
|
-
dmon/constants.py,sha256=uJPI66IJvT6vhamqEDeqHjZQS12RxCPe3DD5yCU0UR8,407
|
|
6
|
-
dmon/control.py,sha256=bjRv2YWSo4RFoUHNl1kU1S_XHjC3tWH0aR9QgQtpDY0,18758
|
|
7
|
-
dmon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
dmon/runner.py,sha256=zW5dPan0RFAlTsuHwCZ7Pklxt6M740Cu7y8m1f5U9xU,6096
|
|
9
|
-
dmon/types.py,sha256=Cbppb5os7GoKCcwr66S5ge_DunuJepeefpnZGaSr788,1799
|
|
10
|
-
dmon/utils.py,sha256=zVQC9YNhMQu4hrAp9k2ds2egnbWZZv7S5w8i2oVIPJg,808
|
|
11
|
-
python_dmon-0.3.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
12
|
-
python_dmon-0.3.0.dist-info/entry_points.txt,sha256=1XmEpyjShANXEHMIMa4p3WXpcllIZfGt5DQyTFuSI74,78
|
|
13
|
-
python_dmon-0.3.0.dist-info/METADATA,sha256=KmrhqMP-5A4CVpv1iJS1UDbmMGEpfELMtFLMAs-k8u0,6661
|
|
14
|
-
python_dmon-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|