meerschaum.utils.daemon.Daemon
Manage running daemons via the Daemon class.
1#! /usr/bin/env python3 2# -*- coding: utf-8 -*- 3# vim:fenc=utf-8 4 5""" 6Manage running daemons via the Daemon class. 7""" 8 9from __future__ import annotations 10import os 11import importlib 12import pathlib 13import json 14import shutil 15import signal 16import time 17import traceback 18from functools import partial 19from datetime import datetime, timezone 20 21import meerschaum as mrsm 22from meerschaum.utils.typing import ( 23 Optional, Dict, Any, SuccessTuple, Callable, List, Union, 24 is_success_tuple, Tuple, 25) 26from meerschaum.config import get_config 27from meerschaum._internal.static import STATIC_CONFIG 28from meerschaum.config._patch import apply_patch_to_config 29from meerschaum.utils.warnings import warn, error 30from meerschaum.utils.packages import attempt_import 31from meerschaum.utils.venv import venv_exec 32from meerschaum.utils.daemon._names import get_new_daemon_name 33from meerschaum.utils.daemon.RotatingFile import RotatingFile 34from meerschaum.utils.daemon.StdinFile import StdinFile 35from meerschaum.utils.threading import RepeatTimer 36from meerschaum.__main__ import _close_pools 37 38_daemons = [] 39_results = {} 40 41class Daemon: 42 """ 43 Daemonize Python functions into background processes. 44 45 Examples 46 -------- 47 >>> import meerschaum as mrsm 48 >>> from meerschaum.utils.daemons import Daemon 49 >>> daemon = Daemon(print, ('hi',)) 50 >>> success, msg = daemon.run() 51 >>> print(daemon.log_text) 52 53 2024-07-29 18:03 | hi 54 2024-07-29 18:03 | 55 >>> daemon.run(allow_dirty_run=True) 56 >>> print(daemon.log_text) 57 58 2024-07-29 18:03 | hi 59 2024-07-29 18:03 | 60 2024-07-29 18:05 | hi 61 2024-07-29 18:05 | 62 >>> mrsm.pprint(daemon.properties) 63 { 64 'label': 'print', 65 'target': {'name': 'print', 'module': 'builtins', 'args': ['hi'], 'kw': {}}, 66 'result': None, 67 'process': {'ended': '2024-07-29T18:03:33.752806'} 68 } 69 70 """ 71 72 def __new__( 73 cls, 74 *args, 75 daemon_id: Optional[str] = None, 76 **kw 77 ): 78 """ 79 If a daemon_id is provided and already exists, read from its pickle file. 80 """ 81 instance = super(Daemon, cls).__new__(cls) 82 if daemon_id is not None: 83 instance.daemon_id = daemon_id 84 if instance.pickle_path.exists(): 85 instance = instance.read_pickle() 86 return instance 87 88 @classmethod 89 def from_properties_file(cls, daemon_id: str) -> Daemon: 90 """ 91 Return a Daemon from a properties dictionary. 92 """ 93 properties_path = cls._get_properties_path_from_daemon_id(daemon_id) 94 if not properties_path.exists(): 95 raise OSError(f"Properties file '{properties_path}' does not exist.") 96 97 try: 98 with open(properties_path, 'r', encoding='utf-8') as f: 99 properties = json.load(f) 100 except Exception: 101 properties = {} 102 103 if not properties: 104 raise ValueError(f"No properties could be read for daemon '{daemon_id}'.") 105 106 daemon_id = properties_path.parent.name 107 target_cf = properties.get('target', {}) 108 target_module_name = target_cf.get('module', None) 109 target_function_name = target_cf.get('name', None) 110 target_args = target_cf.get('args', None) 111 target_kw = target_cf.get('kw', None) 112 label = properties.get('label', None) 113 114 if None in [ 115 target_module_name, 116 target_function_name, 117 target_args, 118 target_kw, 119 ]: 120 raise ValueError("Missing target function information.") 121 122 target_module = importlib.import_module(target_module_name) 123 target_function = getattr(target_module, target_function_name) 124 125 return Daemon( 126 daemon_id=daemon_id, 127 target=target_function, 128 target_args=target_args, 129 target_kw=target_kw, 130 properties=properties, 131 label=label, 132 ) 133 134 135 def __init__( 136 self, 137 target: Optional[Callable[[Any], Any]] = None, 138 target_args: Union[List[Any], Tuple[Any], None] = None, 139 target_kw: Optional[Dict[str, Any]] = None, 140 env: Optional[Dict[str, str]] = None, 141 daemon_id: Optional[str] = None, 142 label: Optional[str] = None, 143 properties: Optional[Dict[str, Any]] = None, 144 pickle: bool = True, 145 ): 146 """ 147 Parameters 148 ---------- 149 target: Optional[Callable[[Any], Any]], default None, 150 The function to execute in a child process. 151 152 target_args: Union[List[Any], Tuple[Any], None], default None 153 Positional arguments to pass to the target function. 154 155 target_kw: Optional[Dict[str, Any]], default None 156 Keyword arguments to pass to the target function. 157 158 env: Optional[Dict[str, str]], default None 159 If provided, set these environment variables in the daemon process. 160 161 daemon_id: Optional[str], default None 162 Build a `Daemon` from an existing `daemon_id`. 163 If `daemon_id` is provided, other arguments are ignored and are derived 164 from the existing pickled `Daemon`. 165 166 label: Optional[str], default None 167 Label string to help identifiy a daemon. 168 If `None`, use the function name instead. 169 170 properties: Optional[Dict[str, Any]], default None 171 Override reading from the properties JSON by providing an existing dictionary. 172 """ 173 _pickle = self.__dict__.get('_pickle', False) 174 if daemon_id is not None: 175 self.daemon_id = daemon_id 176 if not self.pickle_path.exists() and not target and ('target' not in self.__dict__): 177 178 if not self.properties_path.exists(): 179 raise Exception( 180 f"Daemon '{self.daemon_id}' does not exist. " 181 + "Pass a target to create a new Daemon." 182 ) 183 184 try: 185 new_daemon = self.from_properties_file(daemon_id) 186 except Exception: 187 new_daemon = None 188 189 if new_daemon is not None: 190 new_daemon.write_pickle() 191 target = new_daemon.target 192 target_args = new_daemon.target_args 193 target_kw = new_daemon.target_kw 194 label = new_daemon.label 195 self._properties = new_daemon.properties 196 else: 197 try: 198 self.properties_path.unlink() 199 except Exception: 200 pass 201 202 raise Exception( 203 f"Could not recover daemon '{self.daemon_id}' " 204 + "from its properties file." 205 ) 206 207 if 'target' not in self.__dict__: 208 if target is None: 209 error("Cannot create a Daemon without a target.") 210 self.target = target 211 212 self.pickle = pickle 213 214 ### NOTE: We have to check self.__dict__ in case we un-pickling. 215 if '_target_args' not in self.__dict__: 216 self._target_args = target_args 217 if '_target_kw' not in self.__dict__: 218 self._target_kw = target_kw 219 220 if 'label' not in self.__dict__: 221 if label is None: 222 label = ( 223 self.target.__name__ if '__name__' in self.target.__dir__() 224 else str(self.target) 225 ) 226 self.label = label 227 elif label is not None: 228 self.label = label 229 230 if 'daemon_id' not in self.__dict__: 231 self.daemon_id = get_new_daemon_name() 232 if '_properties' not in self.__dict__: 233 self._properties = properties 234 elif properties: 235 if self._properties is None: 236 self._properties = {} 237 self._properties.update(properties) 238 if self._properties is None: 239 self._properties = {} 240 241 self._properties.update({'label': self.label}) 242 if env: 243 self._properties.update({'env': env}) 244 245 ### Instantiate the process and if it doesn't exist, make sure the PID is removed. 246 _ = self.process 247 248 249 def _run_exit( 250 self, 251 keep_daemon_output: bool = True, 252 allow_dirty_run: bool = False, 253 ) -> Any: 254 """Run the daemon's target function. 255 NOTE: This WILL EXIT the parent process! 256 257 Parameters 258 ---------- 259 keep_daemon_output: bool, default True 260 If `False`, delete the daemon's output directory upon exiting. 261 262 allow_dirty_run, bool, default False: 263 If `True`, run the daemon, even if the `daemon_id` directory exists. 264 This option is dangerous because if the same `daemon_id` runs twice, 265 the last to finish will overwrite the output of the first. 266 267 Returns 268 ------- 269 Nothing — this will exit the parent process. 270 """ 271 import platform 272 import sys 273 import os 274 import traceback 275 from meerschaum.utils.warnings import warn 276 from meerschaum.config import get_config 277 daemon = attempt_import('daemon') 278 lines = get_config('jobs', 'terminal', 'lines') 279 columns = get_config('jobs', 'terminal', 'columns') 280 281 if platform.system() == 'Windows': 282 return False, "Windows is no longer supported." 283 284 self._setup(allow_dirty_run) 285 286 _daemons.append(self) 287 288 logs_cf = self.properties.get('logs', {}) 289 log_refresh_seconds = logs_cf.get('refresh_files_seconds', None) 290 if log_refresh_seconds is None: 291 log_refresh_seconds = get_config('jobs', 'logs', 'refresh_files_seconds') 292 write_timestamps = logs_cf.get('write_timestamps', None) 293 if write_timestamps is None: 294 write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled') 295 296 self._log_refresh_timer = RepeatTimer( 297 log_refresh_seconds, 298 partial(self.rotating_log.refresh_files, start_interception=write_timestamps), 299 ) 300 301 capture_stdin = logs_cf.get('stdin', True) 302 cwd = self.properties.get('cwd', os.getcwd()) 303 304 ### NOTE: The SIGINT handler has been removed so that child processes may handle 305 ### KeyboardInterrupts themselves. 306 ### The previous aggressive approach was redundant because of the SIGTERM handler. 307 self._daemon_context = daemon.DaemonContext( 308 pidfile=self.pid_lock, 309 stdout=self.rotating_log, 310 stderr=self.rotating_log, 311 stdin=(self.stdin_file if capture_stdin else None), 312 working_directory=cwd, 313 detach_process=True, 314 files_preserve=list(self.rotating_log.subfile_objects.values()), 315 signal_map={ 316 signal.SIGTERM: self._handle_sigterm, 317 }, 318 ) 319 320 if capture_stdin and sys.stdin is None: 321 raise OSError("Cannot daemonize without stdin.") 322 323 try: 324 os.environ['LINES'], os.environ['COLUMNS'] = str(int(lines)), str(int(columns)) 325 with self._daemon_context: 326 if capture_stdin: 327 sys.stdin = self.stdin_file 328 _ = os.environ.pop(STATIC_CONFIG['environment']['systemd_stdin_path'], None) 329 os.environ[STATIC_CONFIG['environment']['daemon_id']] = self.daemon_id 330 os.environ['PYTHONUNBUFFERED'] = '1' 331 332 ### Allow the user to override environment variables. 333 env = self.properties.get('env', {}) 334 if env and isinstance(env, dict): 335 os.environ.update({str(k): str(v) for k, v in env.items()}) 336 337 self.rotating_log.refresh_files(start_interception=True) 338 result = None 339 try: 340 with open(self.pid_path, 'w+', encoding='utf-8') as f: 341 f.write(str(os.getpid())) 342 343 ### NOTE: The timer fails to start for remote actions to localhost. 344 try: 345 if not self._log_refresh_timer.is_running(): 346 self._log_refresh_timer.start() 347 except Exception: 348 pass 349 350 self.properties['result'] = None 351 self._capture_process_timestamp('began') 352 result = self.target(*self.target_args, **self.target_kw) 353 self.properties['result'] = result 354 except SystemExit as e: 355 result = ( 356 (True, f"Stopped daemon '{self.daemon_id}'.") 357 if e.code is None or e.code == 0 358 else (False, traceback.format_exc()) 359 ) 360 except (BrokenPipeError, KeyboardInterrupt): 361 result = False, traceback.format_exc() 362 except Exception as e: 363 warn( 364 f"Exception in daemon target function: {traceback.format_exc()}", 365 ) 366 result = False, str(e) 367 finally: 368 _results[self.daemon_id] = result 369 self.properties['result'] = result 370 371 if keep_daemon_output: 372 self._capture_process_timestamp('ended') 373 else: 374 self.cleanup() 375 376 self._log_refresh_timer.cancel() 377 try: 378 if self.pid is None and self.pid_path.exists(): 379 self.pid_path.unlink() 380 except Exception: 381 pass 382 383 if is_success_tuple(result): 384 try: 385 mrsm.pprint(result) 386 except BrokenPipeError: 387 pass 388 389 except Exception: 390 daemon_error = traceback.format_exc() 391 import meerschaum.config.paths as paths 392 with open(paths.DAEMON_ERROR_LOG_PATH, 'a+', encoding='utf-8') as f: 393 f.write( 394 f"Error in Daemon '{self}':\n\n" 395 f"{sys.stdin=}\n" 396 f"{self.stdin_file_path=}\n" 397 f"{self.stdin_file_path.exists()=}\n\n" 398 f"{daemon_error}\n\n" 399 ) 400 warn(f"Encountered an error while running the daemon '{self}':\n{daemon_error}") 401 402 def _capture_process_timestamp( 403 self, 404 process_key: str, 405 write_properties: bool = True, 406 ) -> None: 407 """ 408 Record the current timestamp to the parameters `process:<process_key>`. 409 410 Parameters 411 ---------- 412 process_key: str 413 Under which key to store the timestamp. 414 415 write_properties: bool, default True 416 If `True` persist the properties to disk immediately after capturing the timestamp. 417 """ 418 if 'process' not in self.properties: 419 self.properties['process'] = {} 420 421 if process_key not in ('began', 'ended', 'paused', 'stopped'): 422 raise ValueError(f"Invalid key '{process_key}'.") 423 424 self.properties['process'][process_key] = ( 425 datetime.now(timezone.utc).replace(tzinfo=None).isoformat() 426 ) 427 if write_properties: 428 self.write_properties() 429 430 def run( 431 self, 432 keep_daemon_output: bool = True, 433 allow_dirty_run: bool = False, 434 wait: bool = False, 435 timeout: Union[int, float] = 4, 436 debug: bool = False, 437 ) -> SuccessTuple: 438 """Run the daemon as a child process and continue executing the parent. 439 440 Parameters 441 ---------- 442 keep_daemon_output: bool, default True 443 If `False`, delete the daemon's output directory upon exiting. 444 445 allow_dirty_run: bool, default False 446 If `True`, run the daemon, even if the `daemon_id` directory exists. 447 This option is dangerous because if the same `daemon_id` runs concurrently, 448 the last to finish will overwrite the output of the first. 449 450 wait: bool, default True 451 If `True`, block until `Daemon.status` is running (or the timeout expires). 452 453 timeout: Union[int, float], default 4 454 If `wait` is `True`, block for up to `timeout` seconds before returning a failure. 455 456 Returns 457 ------- 458 A SuccessTuple indicating success. 459 460 """ 461 import platform 462 if platform.system() == 'Windows': 463 return False, "Cannot run background jobs on Windows." 464 465 ### The daemon might exist and be paused. 466 if self.status == 'paused': 467 return self.resume() 468 469 self._remove_stop_file() 470 if self.status == 'running': 471 return True, f"Daemon '{self}' is already running." 472 473 self.mkdir_if_not_exists(allow_dirty_run) 474 _write_pickle_success_tuple = self.write_pickle() 475 if not _write_pickle_success_tuple[0]: 476 return _write_pickle_success_tuple 477 478 _launch_daemon_code = ( 479 "from meerschaum.utils.daemon import Daemon, _daemons; " 480 f"daemon = Daemon(daemon_id='{self.daemon_id}'); " 481 f"_daemons['{self.daemon_id}'] = daemon; " 482 f"daemon._run_exit(keep_daemon_output={keep_daemon_output}, " 483 "allow_dirty_run=True)" 484 ) 485 env = dict(os.environ) 486 _launch_success_bool = venv_exec(_launch_daemon_code, debug=debug, venv=None, env=env) 487 msg = ( 488 "Success" 489 if _launch_success_bool 490 else f"Failed to start daemon '{self.daemon_id}'." 491 ) 492 if not wait or not _launch_success_bool: 493 return _launch_success_bool, msg 494 495 timeout = self.get_timeout_seconds(timeout) 496 check_timeout_interval = self.get_check_timeout_interval_seconds() 497 498 if not timeout: 499 success = self.status == 'running' 500 msg = "Success" if success else f"Failed to run daemon '{self.daemon_id}'." 501 if success: 502 self._capture_process_timestamp('began') 503 return success, msg 504 505 begin = time.perf_counter() 506 while (time.perf_counter() - begin) < timeout: 507 if self.status == 'running': 508 self._capture_process_timestamp('began') 509 return True, "Success" 510 time.sleep(check_timeout_interval) 511 512 return False, ( 513 f"Failed to start daemon '{self.daemon_id}' within {timeout} second" 514 + ('s' if timeout != 1 else '') + '.' 515 ) 516 517 518 def kill(self, timeout: Union[int, float, None] = 8) -> SuccessTuple: 519 """ 520 Forcibly terminate a running daemon. 521 Sends a SIGTERM signal to the process. 522 523 Parameters 524 ---------- 525 timeout: Optional[int], default 3 526 How many seconds to wait for the process to terminate. 527 528 Returns 529 ------- 530 A SuccessTuple indicating success. 531 """ 532 ### A lost PID file means a detached/orphaned daemon — possibly SEVERAL processes 533 ### sharing this daemon_id (e.g. accumulated across crashed restarts). Reap them 534 ### ALL by daemon_id rather than reporting a false "stopped" and stranding them 535 ### (which forces a manual `pkill meerschaum` or hunting the daemon_id in htop). 536 if not self.pid_path.exists(): 537 reaped = self._kill_detached_processes(timeout) 538 self._write_stop_file('kill') 539 self.stdin_file.close() 540 self._remove_blocking_stdin_file() 541 return True, ( 542 (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.') 543 if reaped 544 else "Process has already stopped." 545 ) 546 547 if self.status != 'paused': 548 success, msg = self._send_signal(signal.SIGTERM, timeout=timeout) 549 if success: 550 ### Sweep any sibling/detached processes left over for this daemon_id. 551 self._kill_detached_processes(timeout) 552 self._write_stop_file('kill') 553 self.stdin_file.close() 554 self._remove_blocking_stdin_file() 555 return success, msg 556 557 if self.status == 'stopped': 558 reaped = self._kill_detached_processes(timeout) 559 self._write_stop_file('kill') 560 self.stdin_file.close() 561 self._remove_blocking_stdin_file() 562 return True, ( 563 (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.') 564 if reaped 565 else "Process has already stopped." 566 ) 567 568 psutil = attempt_import('psutil') 569 process = self.process 570 try: 571 process.terminate() 572 process.kill() 573 process.wait(timeout=timeout) 574 except Exception as e: 575 return False, f"Failed to kill job {self} ({process}) with exception: {e}" 576 577 try: 578 if process.status(): 579 return False, "Failed to stop daemon '{self}' ({process})." 580 except psutil.NoSuchProcess: 581 pass 582 583 if self.pid_path.exists(): 584 try: 585 self.pid_path.unlink() 586 except Exception: 587 pass 588 589 self._write_stop_file('kill') 590 self.stdin_file.close() 591 self._remove_blocking_stdin_file() 592 return True, "Success" 593 594 def quit(self, timeout: Union[int, float, None] = None) -> SuccessTuple: 595 """Gracefully quit a running daemon.""" 596 if self.status == 'paused': 597 return self.kill(timeout) 598 599 ### A lost PID file means a detached/orphaned daemon (possibly several processes); 600 ### the normal signal path can't see them, so reap them all by daemon_id instead 601 ### of returning a false "not running". 602 if not self.pid_path.exists(): 603 reaped = self._kill_detached_processes(timeout) 604 self._write_stop_file('quit') 605 self.stdin_file.close() 606 self._remove_blocking_stdin_file() 607 return True, ( 608 (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.') 609 if reaped 610 else "Process is not running." 611 ) 612 613 signal_success, signal_msg = self._send_signal(signal.SIGINT, timeout=timeout) 614 if signal_success: 615 self._write_stop_file('quit') 616 self.stdin_file.close() 617 self._remove_blocking_stdin_file() 618 return signal_success, signal_msg 619 620 def pause( 621 self, 622 timeout: Union[int, float, None] = None, 623 check_timeout_interval: Union[float, int, None] = None, 624 ) -> SuccessTuple: 625 """ 626 Pause the daemon if it is running. 627 628 Parameters 629 ---------- 630 timeout: Union[float, int, None], default None 631 The maximum number of seconds to wait for a process to suspend. 632 633 check_timeout_interval: Union[float, int, None], default None 634 The number of seconds to wait between checking if the process is still running. 635 636 Returns 637 ------- 638 A `SuccessTuple` indicating whether the `Daemon` process was successfully suspended. 639 """ 640 self._remove_blocking_stdin_file() 641 642 if self.process is None: 643 return False, f"Daemon '{self.daemon_id}' is not running and cannot be paused." 644 645 if self.status == 'paused': 646 return True, f"Daemon '{self.daemon_id}' is already paused." 647 648 self._write_stop_file('pause') 649 self.stdin_file.close() 650 self._remove_blocking_stdin_file() 651 try: 652 self.process.suspend() 653 except Exception as e: 654 return False, f"Failed to pause daemon '{self.daemon_id}':\n{e}" 655 656 timeout = self.get_timeout_seconds(timeout) 657 check_timeout_interval = self.get_check_timeout_interval_seconds( 658 check_timeout_interval 659 ) 660 661 psutil = attempt_import('psutil') 662 663 if not timeout: 664 try: 665 success = self.process.status() == 'stopped' 666 except psutil.NoSuchProcess: 667 success = True 668 msg = "Success" if success else f"Failed to suspend daemon '{self.daemon_id}'." 669 if success: 670 self._capture_process_timestamp('paused') 671 return success, msg 672 673 begin = time.perf_counter() 674 while (time.perf_counter() - begin) < timeout: 675 try: 676 if self.process.status() == 'stopped': 677 self._capture_process_timestamp('paused') 678 return True, "Success" 679 except psutil.NoSuchProcess as e: 680 return False, f"Process exited unexpectedly. Was it killed?\n{e}" 681 time.sleep(check_timeout_interval) 682 683 return False, ( 684 f"Failed to pause daemon '{self.daemon_id}' within {timeout} second" 685 + ('s' if timeout != 1 else '') + '.' 686 ) 687 688 def resume( 689 self, 690 timeout: Union[int, float, None] = None, 691 check_timeout_interval: Union[float, int, None] = None, 692 ) -> SuccessTuple: 693 """ 694 Resume the daemon if it is paused. 695 696 Parameters 697 ---------- 698 timeout: Union[float, int, None], default None 699 The maximum number of seconds to wait for a process to resume. 700 701 check_timeout_interval: Union[float, int, None], default None 702 The number of seconds to wait between checking if the process is still stopped. 703 704 Returns 705 ------- 706 A `SuccessTuple` indicating whether the `Daemon` process was successfully resumed. 707 """ 708 if self.status == 'running': 709 return True, f"Daemon '{self.daemon_id}' is already running." 710 711 if self.status == 'stopped': 712 return False, f"Daemon '{self.daemon_id}' is stopped and cannot be resumed." 713 714 self._remove_stop_file() 715 try: 716 if self.process is None: 717 return False, f"Cannot resume daemon '{self.daemon_id}'." 718 719 self.process.resume() 720 except Exception as e: 721 return False, f"Failed to resume daemon '{self.daemon_id}':\n{e}" 722 723 timeout = self.get_timeout_seconds(timeout) 724 check_timeout_interval = self.get_check_timeout_interval_seconds( 725 check_timeout_interval 726 ) 727 728 if not timeout: 729 success = self.status == 'running' 730 msg = "Success" if success else f"Failed to resume daemon '{self.daemon_id}'." 731 if success: 732 self._capture_process_timestamp('began') 733 return success, msg 734 735 begin = time.perf_counter() 736 while (time.perf_counter() - begin) < timeout: 737 if self.status == 'running': 738 self._capture_process_timestamp('began') 739 return True, "Success" 740 time.sleep(check_timeout_interval) 741 742 return False, ( 743 f"Failed to resume daemon '{self.daemon_id}' within {timeout} second" 744 + ('s' if timeout != 1 else '') + '.' 745 ) 746 747 def _write_stop_file(self, action: str) -> SuccessTuple: 748 """Write the stop file timestamp and action.""" 749 if action not in ('quit', 'kill', 'pause'): 750 return False, f"Unsupported action '{action}'." 751 752 if not self.stop_path.parent.exists(): 753 self.stop_path.parent.mkdir(parents=True, exist_ok=True) 754 755 with open(self.stop_path, 'w+', encoding='utf-8') as f: 756 json.dump( 757 { 758 'stop_time': datetime.now(timezone.utc).isoformat(), 759 'action': action, 760 }, 761 f 762 ) 763 764 return True, "Success" 765 766 def _remove_stop_file(self) -> SuccessTuple: 767 """Remove the stop file""" 768 if not self.stop_path.exists(): 769 return True, "Stop file does not exist." 770 771 try: 772 self.stop_path.unlink() 773 except Exception as e: 774 return False, f"Failed to remove stop file:\n{e}" 775 776 return True, "Success" 777 778 def _read_stop_file(self) -> Dict[str, Any]: 779 """ 780 Read the stop file if it exists. 781 """ 782 if not self.stop_path.exists(): 783 return {} 784 785 try: 786 with open(self.stop_path, 'r', encoding='utf-8') as f: 787 data = json.load(f) 788 return data 789 except Exception: 790 return {} 791 792 def _remove_blocking_stdin_file(self) -> mrsm.SuccessTuple: 793 """ 794 Remove the blocking STDIN file if it exists. 795 """ 796 try: 797 if self.blocking_stdin_file_path.exists(): 798 self.blocking_stdin_file_path.unlink() 799 except Exception as e: 800 return False, str(e) 801 802 return True, "Success" 803 804 def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None: 805 """ 806 Handle `SIGTERM` within the `Daemon` context. 807 This method is injected into the `DaemonContext`. 808 """ 809 from meerschaum.utils.process import signal_handler 810 from meerschaum.utils.threading import request_stop, interrupt_threads 811 signal_handler(signal_number, stack_frame) 812 813 ### Tell cooperative loops (e.g. `sync pipes`) to stop, then actively unwind 814 ### any worker threads so they cannot keep the process alive as a zombie. 815 request_stop() 816 interrupt_threads(SystemExit) 817 818 timer = self.__dict__.get('_log_refresh_timer', None) 819 if timer is not None: 820 timer.cancel() 821 822 daemon_context = self.__dict__.get('_daemon_context', None) 823 if daemon_context is not None: 824 daemon_context.close() 825 826 _close_pools() 827 raise SystemExit(0) 828 829 def _send_signal( 830 self, 831 signal_to_send, 832 timeout: Union[float, int, None] = None, 833 check_timeout_interval: Union[float, int, None] = None, 834 ) -> SuccessTuple: 835 """Send a signal to the daemon process. 836 837 Parameters 838 ---------- 839 signal_to_send: 840 The signal the send to the daemon, e.g. `signals.SIGINT`. 841 842 timeout: Union[float, int, None], default None 843 The maximum number of seconds to wait for a process to terminate. 844 845 check_timeout_interval: Union[float, int, None], default None 846 The number of seconds to wait between checking if the process is still running. 847 848 Returns 849 ------- 850 A SuccessTuple indicating success. 851 """ 852 try: 853 pid = self.pid 854 if pid is None: 855 return ( 856 False, 857 f"Daemon '{self.daemon_id}' is not running, " 858 + f"cannot send signal '{signal_to_send}'." 859 ) 860 861 os.kill(pid, signal_to_send) 862 except Exception: 863 return False, f"Failed to send signal {signal_to_send}:\n{traceback.format_exc()}" 864 865 timeout = self.get_timeout_seconds(timeout) 866 check_timeout_interval = self.get_check_timeout_interval_seconds( 867 check_timeout_interval 868 ) 869 870 if not timeout: 871 return True, f"Successfully sent '{signal_to_send}' to daemon '{self.daemon_id}'." 872 873 begin = time.perf_counter() 874 while (time.perf_counter() - begin) < timeout: 875 if not self.status == 'running': 876 return True, "Success" 877 time.sleep(check_timeout_interval) 878 879 return False, ( 880 f"Failed to stop daemon '{self.daemon_id}' (PID: {pid}) within {timeout} second" 881 + ('s' if timeout != 1 else '') + '.' 882 ) 883 884 def _find_detached_pids(self) -> List[int]: 885 """ 886 Return the PIDs of any processes whose command line references this daemon_id. 887 888 A daemon is launched via `venv_exec` with `Daemon(daemon_id='<id>')` embedded in 889 the executed code, so the daemon_id stays visible in the process's command line. 890 When the PID file is lost (e.g. the launcher exited without cleanup, leaving an 891 orphaned/detached daemon), this is the only reliable way to find the process — 892 otherwise stopping the job reports a false "already stopped" and the user must 893 resort to `pkill meerschaum` or hunting the daemon_id in `htop`. 894 """ 895 psutil = attempt_import('psutil') 896 marker = f"daemon_id='{self.daemon_id}'" 897 my_pid = os.getpid() 898 pids = [] 899 for proc in psutil.process_iter(['pid', 'cmdline']): 900 try: 901 if proc.info['pid'] == my_pid: 902 continue 903 cmdline = proc.info.get('cmdline') or [] 904 if any(marker in (part or '') for part in cmdline): 905 pids.append(int(proc.info['pid'])) 906 except Exception: 907 continue 908 return pids 909 910 def _kill_detached_processes(self, timeout: Union[int, float, None] = None) -> int: 911 """ 912 SIGTERM then SIGKILL any detached processes referencing this daemon_id. 913 914 Fallback for when the PID file is gone but the daemon (or its threads) are still 915 alive. Returns the number of processes that were targeted. 916 """ 917 psutil = attempt_import('psutil') 918 pids = self._find_detached_pids() 919 if not pids: 920 return 0 921 922 procs = [] 923 for pid in pids: 924 try: 925 procs.append(psutil.Process(pid)) 926 except Exception: 927 continue 928 for proc in procs: 929 try: 930 proc.terminate() 931 except Exception: 932 pass 933 934 timeout = self.get_timeout_seconds(timeout) 935 try: 936 _, alive = psutil.wait_procs(procs, timeout=(timeout or 8)) 937 except Exception: 938 alive = procs 939 for proc in alive: 940 try: 941 proc.kill() 942 except Exception: 943 pass 944 return len(procs) 945 946 def mkdir_if_not_exists(self, allow_dirty_run: bool = False): 947 """Create the Daemon's directory. 948 If `allow_dirty_run` is `False` and the directory already exists, 949 raise a `FileExistsError`. 950 """ 951 try: 952 self.path.mkdir(parents=True, exist_ok=True) 953 _already_exists = any(os.scandir(self.path)) 954 except FileExistsError: 955 _already_exists = True 956 957 if _already_exists and not allow_dirty_run: 958 error( 959 f"Daemon '{self.daemon_id}' already exists. " + 960 "To allow this daemon to run, do one of the following:\n" 961 + " - Execute `daemon.cleanup()`.\n" 962 + f" - Delete the directory '{self.path}'.\n" 963 + " - Pass `allow_dirty_run=True` to `daemon.run()`.\n", 964 FileExistsError, 965 ) 966 967 @property 968 def process(self) -> Union['psutil.Process', None]: 969 """ 970 Return the psutil process for the Daemon. 971 """ 972 psutil = attempt_import('psutil') 973 pid = self.pid 974 if pid is None: 975 return None 976 if '_process' not in self.__dict__ or self.__dict__['_process'].pid != int(pid): 977 try: 978 self._process = psutil.Process(int(pid)) 979 process_exists = True 980 except Exception: 981 process_exists = False 982 if not process_exists: 983 _ = self.__dict__.pop('_process', None) 984 try: 985 if self.pid_path.exists(): 986 self.pid_path.unlink() 987 except Exception: 988 pass 989 return None 990 return self._process 991 992 @property 993 def status(self) -> str: 994 """ 995 Return the running status of this Daemon. 996 """ 997 if self.process is None: 998 return 'stopped' 999 1000 psutil = attempt_import('psutil', lazy=False) 1001 try: 1002 if self.process.status() == 'stopped': 1003 return 'paused' 1004 if self.process.status() == 'zombie': 1005 raise psutil.NoSuchProcess(self.process.pid) 1006 except (psutil.NoSuchProcess, AttributeError): 1007 if self.pid_path.exists(): 1008 try: 1009 self.pid_path.unlink() 1010 except Exception: 1011 pass 1012 return 'stopped' 1013 1014 return 'running' 1015 1016 @classmethod 1017 def _get_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path: 1018 """ 1019 Return a Daemon's path from its `daemon_id`. 1020 """ 1021 import meerschaum.config.paths as paths 1022 return paths.DAEMON_RESOURCES_PATH / daemon_id 1023 1024 @property 1025 def path(self) -> pathlib.Path: 1026 """ 1027 Return the path for this Daemon's directory. 1028 """ 1029 return self._get_path_from_daemon_id(self.daemon_id) 1030 1031 @classmethod 1032 def _get_properties_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path: 1033 """ 1034 Return the `properties.json` path for a given `daemon_id`. 1035 """ 1036 return cls._get_path_from_daemon_id(daemon_id) / 'properties.json' 1037 1038 @property 1039 def properties_path(self) -> pathlib.Path: 1040 """ 1041 Return the `propterties.json` path for this Daemon. 1042 """ 1043 return self._get_properties_path_from_daemon_id(self.daemon_id) 1044 1045 @property 1046 def stop_path(self) -> pathlib.Path: 1047 """ 1048 Return the path for the stop file (created when manually stopped). 1049 """ 1050 return self.path / '.stop.json' 1051 1052 @property 1053 def log_path(self) -> pathlib.Path: 1054 """ 1055 Return the log path. 1056 """ 1057 logs_cf = self.properties.get('logs', None) or {} 1058 if 'path' not in logs_cf: 1059 import meerschaum.config.paths as paths 1060 return paths.LOGS_RESOURCES_PATH / (self.daemon_id + '.log') 1061 1062 return pathlib.Path(logs_cf['path']) 1063 1064 @property 1065 def stdin_file_path(self) -> pathlib.Path: 1066 """ 1067 Return the stdin file path. 1068 """ 1069 return self.path / 'input.stdin' 1070 1071 @property 1072 def blocking_stdin_file_path(self) -> pathlib.Path: 1073 """ 1074 Return the stdin file path. 1075 """ 1076 if '_blocking_stdin_file_path' in self.__dict__: 1077 return self._blocking_stdin_file_path 1078 1079 return self.path / 'input.stdin.block' 1080 1081 @property 1082 def prompt_kwargs_file_path(self) -> pathlib.Path: 1083 """ 1084 Return the file path to the kwargs for the invoking `prompt()`. 1085 """ 1086 return self.path / 'prompt_kwargs.json' 1087 1088 @property 1089 def log_offset_path(self) -> pathlib.Path: 1090 """ 1091 Return the log offset file path. 1092 """ 1093 import meerschaum.config.paths as paths 1094 return paths.LOGS_RESOURCES_PATH / ('.' + self.daemon_id + '.log.offset') 1095 1096 @property 1097 def log_offset_lock(self) -> 'InterProcessLock': 1098 """ 1099 Return the process lock context manager. 1100 """ 1101 if '_log_offset_lock' in self.__dict__: 1102 return self._log_offset_lock 1103 1104 from meerschaum.utils.locks import InterProcessLock 1105 self._log_offset_lock = InterProcessLock(self.log_offset_path) 1106 return self._log_offset_lock 1107 1108 @property 1109 def rotating_log(self) -> RotatingFile: 1110 """ 1111 The rotating log file for the daemon's output. 1112 """ 1113 if '_rotating_log' in self.__dict__: 1114 return self._rotating_log 1115 1116 logs_cf = self.properties.get('logs', None) or {} 1117 write_timestamps = logs_cf.get('write_timestamps', None) 1118 if write_timestamps is None: 1119 write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled') 1120 1121 timestamp_format = logs_cf.get('timestamp_format', None) 1122 if timestamp_format is None: 1123 timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format') 1124 1125 num_files_to_keep = logs_cf.get('num_files_to_keep', None) 1126 if num_files_to_keep is None: 1127 num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep') 1128 1129 max_file_size = logs_cf.get('max_file_size', None) 1130 if max_file_size is None: 1131 max_file_size = get_config('jobs', 'logs', 'max_file_size') 1132 1133 redirect_streams = logs_cf.get('redirect_streams', True) 1134 1135 self._rotating_log = RotatingFile( 1136 self.log_path, 1137 redirect_streams=redirect_streams, 1138 write_timestamps=write_timestamps, 1139 timestamp_format=timestamp_format, 1140 num_files_to_keep=num_files_to_keep, 1141 max_file_size=max_file_size, 1142 ) 1143 return self._rotating_log 1144 1145 @property 1146 def stdin_file(self): 1147 """ 1148 Return the file handler for the stdin file. 1149 """ 1150 if (_stdin_file := self.__dict__.get('_stdin_file', None)): 1151 return _stdin_file 1152 1153 self._stdin_file = StdinFile( 1154 self.stdin_file_path, 1155 lock_file_path=self.blocking_stdin_file_path, 1156 ) 1157 return self._stdin_file 1158 1159 @property 1160 def log_text(self) -> Union[str, None]: 1161 """ 1162 Read the log files and return their contents. 1163 Returns `None` if the log file does not exist. 1164 """ 1165 logs_cf = self.properties.get('logs', None) or {} 1166 write_timestamps = logs_cf.get('write_timestamps', None) 1167 if write_timestamps is None: 1168 write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled') 1169 1170 timestamp_format = logs_cf.get('timestamp_format', None) 1171 if timestamp_format is None: 1172 timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format') 1173 1174 num_files_to_keep = logs_cf.get('num_files_to_keep', None) 1175 if num_files_to_keep is None: 1176 num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep') 1177 1178 max_file_size = logs_cf.get('max_file_size', None) 1179 if max_file_size is None: 1180 max_file_size = get_config('jobs', 'logs', 'max_file_size') 1181 1182 new_rotating_log = RotatingFile( 1183 self.rotating_log.file_path, 1184 num_files_to_keep=num_files_to_keep, 1185 max_file_size=max_file_size, 1186 write_timestamps=write_timestamps, 1187 timestamp_format=timestamp_format, 1188 ) 1189 return new_rotating_log.read() 1190 1191 def readlines(self) -> List[str]: 1192 """ 1193 Read the next log lines, persisting the cursor for later use. 1194 Note this will alter the cursor of `self.rotating_log`. 1195 """ 1196 self.rotating_log._cursor = self._read_log_offset() 1197 lines = self.rotating_log.readlines() 1198 self._write_log_offset() 1199 return lines 1200 1201 def _read_log_offset(self) -> Tuple[int, int]: 1202 """ 1203 Return the current log offset cursor. 1204 1205 Returns 1206 ------- 1207 A tuple of the form (`subfile_index`, `position`). 1208 """ 1209 if not self.log_offset_path.exists(): 1210 return 0, 0 1211 1212 try: 1213 with open(self.log_offset_path, 'r', encoding='utf-8') as f: 1214 cursor_text = f.read() 1215 cursor_parts = cursor_text.split(' ') 1216 subfile_index, subfile_position = int(cursor_parts[0]), int(cursor_parts[1]) 1217 return subfile_index, subfile_position 1218 except Exception as e: 1219 warn(f"Failed to read cursor:\n{e}") 1220 return 0, 0 1221 1222 def _write_log_offset(self) -> None: 1223 """ 1224 Write the current log offset file. 1225 """ 1226 with self.log_offset_lock: 1227 with open(self.log_offset_path, 'w+', encoding='utf-8') as f: 1228 subfile_index = self.rotating_log._cursor[0] 1229 subfile_position = self.rotating_log._cursor[1] 1230 f.write(f"{subfile_index} {subfile_position}") 1231 1232 @property 1233 def pid(self) -> Union[int, None]: 1234 """ 1235 Read the PID file and return its contents. 1236 Returns `None` if the PID file does not exist. 1237 """ 1238 if not self.pid_path.exists(): 1239 ### The PID file can be lost while a detached daemon keeps running (e.g. the 1240 ### launcher exited without cleanup). Recover the PID by finding the process 1241 ### whose command line embeds this daemon_id, so `status`/`stop`/`kill` see the 1242 ### live process instead of reporting a false "stopped" (which would strand the 1243 ### orphan, forcing a manual `pkill meerschaum`). 1244 detached_pids = self._find_detached_pids() 1245 return detached_pids[0] if detached_pids else None 1246 try: 1247 with open(self.pid_path, 'r', encoding='utf-8') as f: 1248 text = f.read() 1249 if len(text) == 0: 1250 return None 1251 pid = int(text.rstrip()) 1252 except Exception as e: 1253 warn(e) 1254 text = None 1255 pid = None 1256 return pid 1257 1258 @property 1259 def pid_path(self) -> pathlib.Path: 1260 """ 1261 Return the path to a file containing the PID for this Daemon. 1262 """ 1263 return self.path / 'process.pid' 1264 1265 @property 1266 def pid_lock(self) -> 'InterProcessLock': 1267 """ 1268 Return the process lock context manager. 1269 """ 1270 if '_pid_lock' in self.__dict__: 1271 return self._pid_lock 1272 1273 from meerschaum.utils.locks import InterProcessLock 1274 self._pid_lock = InterProcessLock(self.pid_path) 1275 return self._pid_lock 1276 1277 @property 1278 def pickle_path(self) -> pathlib.Path: 1279 """ 1280 Return the path for the pickle file. 1281 """ 1282 return self.path / 'pickle.pkl' 1283 1284 def read_properties(self) -> Optional[Dict[str, Any]]: 1285 """Read the properties JSON file and return the dictionary.""" 1286 if not self.properties_path.exists(): 1287 return None 1288 try: 1289 with open(self.properties_path, 'r', encoding='utf-8') as file: 1290 properties = json.load(file) 1291 except Exception: 1292 properties = {} 1293 1294 return properties or {} 1295 1296 def read_pickle(self) -> Daemon: 1297 """Read a Daemon's pickle file and return the `Daemon`.""" 1298 import pickle 1299 import traceback 1300 if not self.pickle_path.exists(): 1301 error(f"Pickle file does not exist for daemon '{self.daemon_id}'.") 1302 1303 if self.pickle_path.stat().st_size == 0: 1304 error(f"Pickle was empty for daemon '{self.daemon_id}'.") 1305 1306 try: 1307 with open(self.pickle_path, 'rb') as pickle_file: 1308 daemon = pickle.load(pickle_file) 1309 success, msg = True, 'Success' 1310 except Exception as e: 1311 success, msg = False, str(e) 1312 daemon = None 1313 traceback.print_exception(type(e), e, e.__traceback__) 1314 if not success: 1315 error(msg) 1316 return daemon 1317 1318 @property 1319 def properties(self) -> Dict[str, Any]: 1320 """ 1321 Return the contents of the properties JSON file. 1322 """ 1323 try: 1324 _file_properties = self.read_properties() or {} 1325 except Exception: 1326 traceback.print_exc() 1327 _file_properties = {} 1328 1329 if not self._properties: 1330 self._properties = _file_properties 1331 1332 if self._properties is None: 1333 self._properties = {} 1334 1335 if ( 1336 self._properties.get('result', None) is None 1337 and _file_properties.get('result', None) is not None 1338 ): 1339 _ = self._properties.pop('result', None) 1340 1341 if _file_properties is not None: 1342 self._properties = apply_patch_to_config( 1343 _file_properties, 1344 self._properties, 1345 ) 1346 1347 return self._properties 1348 1349 @property 1350 def hidden(self) -> bool: 1351 """ 1352 Return a bool indicating whether this Daemon should be displayed. 1353 """ 1354 return self.daemon_id.startswith('_') or self.daemon_id.startswith('.') 1355 1356 def write_properties(self) -> SuccessTuple: 1357 """Write the properties dictionary to the properties JSON file 1358 (only if self.properties exists). 1359 """ 1360 from meerschaum.utils.misc import generate_password 1361 success, msg = ( 1362 False, 1363 f"No properties to write for daemon '{self.daemon_id}'." 1364 ) 1365 backup_path = self.properties_path.parent / (generate_password(8) + '.json') 1366 props = self.properties 1367 if props is not None: 1368 try: 1369 self.path.mkdir(parents=True, exist_ok=True) 1370 if self.properties_path.exists(): 1371 self.properties_path.rename(backup_path) 1372 with open(self.properties_path, 'w+', encoding='utf-8') as properties_file: 1373 json.dump(props, properties_file) 1374 success, msg = True, 'Success' 1375 except Exception as e: 1376 success, msg = False, str(e) 1377 1378 try: 1379 if backup_path.exists(): 1380 if not success: 1381 backup_path.rename(self.properties_path) 1382 else: 1383 backup_path.unlink() 1384 except Exception as e: 1385 success, msg = False, str(e) 1386 1387 return success, msg 1388 1389 def write_pickle(self) -> SuccessTuple: 1390 """Write the pickle file for the daemon.""" 1391 import pickle 1392 import traceback 1393 from meerschaum.utils.misc import generate_password 1394 1395 if not self.pickle: 1396 return True, "Success" 1397 1398 from meerschaum._internal.entry import _shells 1399 if _shells: 1400 from meerschaum._internal.shell.Shell import revert_input 1401 revert_input() 1402 1403 backup_path = self.pickle_path.parent / (generate_password(7) + '.pkl') 1404 try: 1405 self.path.mkdir(parents=True, exist_ok=True) 1406 if self.pickle_path.exists(): 1407 self.pickle_path.rename(backup_path) 1408 with open(self.pickle_path, 'wb+') as pickle_file: 1409 pickle.dump(self, pickle_file) 1410 success, msg = True, "Success" 1411 except Exception as e: 1412 success, msg = False, str(e) 1413 traceback.print_exception(type(e), e, e.__traceback__) 1414 try: 1415 if backup_path.exists(): 1416 if not success: 1417 backup_path.rename(self.pickle_path) 1418 else: 1419 backup_path.unlink() 1420 except Exception as e: 1421 success, msg = False, str(e) 1422 return success, msg 1423 1424 1425 def _setup( 1426 self, 1427 allow_dirty_run: bool = False, 1428 ) -> None: 1429 """ 1430 Update properties before starting the Daemon. 1431 """ 1432 if self.properties is None: 1433 self._properties = {} 1434 1435 self._properties.update({ 1436 'target': { 1437 'name': self.target.__name__, 1438 'module': self.target.__module__, 1439 'args': self.target_args, 1440 'kw': self.target_kw, 1441 }, 1442 }) 1443 self.mkdir_if_not_exists(allow_dirty_run) 1444 _write_properties_success_tuple = self.write_properties() 1445 if not _write_properties_success_tuple[0]: 1446 error(_write_properties_success_tuple[1]) 1447 1448 _write_pickle_success_tuple = self.write_pickle() 1449 if not _write_pickle_success_tuple[0]: 1450 error(_write_pickle_success_tuple[1]) 1451 1452 def cleanup(self, keep_logs: bool = False) -> SuccessTuple: 1453 """ 1454 Remove a daemon's directory after execution. 1455 1456 Parameters 1457 ---------- 1458 keep_logs: bool, default False 1459 If `True`, skip deleting the daemon's log files. 1460 1461 Returns 1462 ------- 1463 A `SuccessTuple` indicating success. 1464 """ 1465 if self.path.exists(): 1466 try: 1467 shutil.rmtree(self.path) 1468 except Exception as e: 1469 msg = f"Failed to clean up '{self.daemon_id}':\n{e}" 1470 warn(msg) 1471 return False, msg 1472 if not keep_logs: 1473 self.rotating_log.delete() 1474 try: 1475 if self.log_offset_path.exists(): 1476 self.log_offset_path.unlink() 1477 except Exception as e: 1478 msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}" 1479 warn(msg) 1480 return False, msg 1481 return True, "Success" 1482 1483 1484 def get_timeout_seconds(self, timeout: Union[int, float, None] = None) -> Union[int, float]: 1485 """ 1486 Return the timeout value to use. Use `--timeout-seconds` if provided, 1487 else the configured default (8). 1488 """ 1489 if isinstance(timeout, (int, float)): 1490 return timeout 1491 return get_config('jobs', 'timeout_seconds') 1492 1493 1494 def get_check_timeout_interval_seconds( 1495 self, 1496 check_timeout_interval: Union[int, float, None] = None, 1497 ) -> Union[int, float]: 1498 """ 1499 Return the interval value to check the status of timeouts. 1500 """ 1501 if isinstance(check_timeout_interval, (int, float)): 1502 return check_timeout_interval 1503 return get_config('jobs', 'check_timeout_interval_seconds') 1504 1505 @property 1506 def target_args(self) -> Union[Tuple[Any], None]: 1507 """ 1508 Return the positional arguments to pass to the target function. 1509 """ 1510 target_args = ( 1511 self.__dict__.get('_target_args', None) 1512 or self.properties.get('target', {}).get('args', None) 1513 ) 1514 if target_args is None: 1515 return tuple([]) 1516 1517 return tuple(target_args) 1518 1519 @property 1520 def target_kw(self) -> Union[Dict[str, Any], None]: 1521 """ 1522 Return the keyword arguments to pass to the target function. 1523 """ 1524 target_kw = ( 1525 self.__dict__.get('_target_kw', None) 1526 or self.properties.get('target', {}).get('kw', None) 1527 ) 1528 if target_kw is None: 1529 return {} 1530 1531 return {key: val for key, val in target_kw.items()} 1532 1533 @staticmethod 1534 def _get_target_reference(target) -> Union[Dict[str, str], None]: 1535 """ 1536 If `target` is an importable, top-level function (e.g. `entry`), return a 1537 ``{'module': ..., 'qualname': ...}`` reference so it can be re-imported in the 1538 daemon process instead of pickled by value. 1539 1540 Pickling such a target by value serializes its entire global graph, which can 1541 reach unpicklable live state (e.g. a connector caching an `_asyncio.Task`) and 1542 raise `TypeError: cannot pickle '_asyncio.Task' object`. dill also falls back to 1543 by-value pickling whenever it cannot match the function by identity — which 1544 happens when two copies of a package are importable (a stale install shadowing 1545 a venv), so `byref=True` alone is not enough. Returns `None` for closures, 1546 lambdas, and anything not importable, which fall back to dill. 1547 """ 1548 module = getattr(target, '__module__', None) 1549 qualname = getattr(target, '__qualname__', None) 1550 if not module or not qualname: 1551 return None 1552 if '<locals>' in qualname or '<lambda>' in qualname: 1553 return None 1554 return {'module': module, 'qualname': qualname} 1555 1556 @staticmethod 1557 def _load_target_reference(target_ref: Dict[str, str]): 1558 """ 1559 Re-import a target from a `{'module': ..., 'qualname': ...}` reference. 1560 """ 1561 import importlib 1562 obj = importlib.import_module(target_ref['module']) 1563 for part in target_ref['qualname'].split('.'): 1564 obj = getattr(obj, part) 1565 return obj 1566 1567 def __getstate__(self): 1568 """ 1569 Pickle this Daemon. 1570 """ 1571 dill = attempt_import('dill') 1572 state = { 1573 'target_args': self.target_args, 1574 'target_kw': self.target_kw, 1575 'daemon_id': self.daemon_id, 1576 'label': self.label, 1577 'properties': self.properties, 1578 } 1579 target_ref = self._get_target_reference(self.target) 1580 if target_ref is not None: 1581 ### Store by reference (re-imported in the daemon process), not by value. 1582 state['target'] = None 1583 state['target_ref'] = target_ref 1584 else: 1585 state['target'] = dill.dumps(self.target, byref=True) 1586 return state 1587 1588 def __setstate__(self, _state: Dict[str, Any]): 1589 """ 1590 Restore this Daemon from a pickled state. 1591 If the properties file exists, skip the old pickled version. 1592 """ 1593 dill = attempt_import('dill') 1594 target_ref = _state.pop('target_ref', None) 1595 if target_ref is not None and _state.get('target', None) is None: 1596 _state['target'] = self._load_target_reference(target_ref) 1597 else: 1598 _state['target'] = dill.loads(_state['target']) 1599 self._pickle = True 1600 daemon_id = _state.get('daemon_id', None) 1601 if not daemon_id: 1602 raise ValueError("Need a daemon_id to un-pickle a Daemon.") 1603 1604 properties_path = self._get_properties_path_from_daemon_id(daemon_id) 1605 ignore_properties = properties_path.exists() 1606 if ignore_properties: 1607 _state = { 1608 key: val 1609 for key, val in _state.items() 1610 if key != 'properties' 1611 } 1612 self.__init__(**_state) 1613 1614 1615 def __repr__(self): 1616 return str(self) 1617 1618 def __str__(self): 1619 return self.daemon_id 1620 1621 def __eq__(self, other): 1622 if not isinstance(other, Daemon): 1623 return False 1624 return self.daemon_id == other.daemon_id 1625 1626 def __hash__(self): 1627 return hash(self.daemon_id)
42class Daemon: 43 """ 44 Daemonize Python functions into background processes. 45 46 Examples 47 -------- 48 >>> import meerschaum as mrsm 49 >>> from meerschaum.utils.daemons import Daemon 50 >>> daemon = Daemon(print, ('hi',)) 51 >>> success, msg = daemon.run() 52 >>> print(daemon.log_text) 53 54 2024-07-29 18:03 | hi 55 2024-07-29 18:03 | 56 >>> daemon.run(allow_dirty_run=True) 57 >>> print(daemon.log_text) 58 59 2024-07-29 18:03 | hi 60 2024-07-29 18:03 | 61 2024-07-29 18:05 | hi 62 2024-07-29 18:05 | 63 >>> mrsm.pprint(daemon.properties) 64 { 65 'label': 'print', 66 'target': {'name': 'print', 'module': 'builtins', 'args': ['hi'], 'kw': {}}, 67 'result': None, 68 'process': {'ended': '2024-07-29T18:03:33.752806'} 69 } 70 71 """ 72 73 def __new__( 74 cls, 75 *args, 76 daemon_id: Optional[str] = None, 77 **kw 78 ): 79 """ 80 If a daemon_id is provided and already exists, read from its pickle file. 81 """ 82 instance = super(Daemon, cls).__new__(cls) 83 if daemon_id is not None: 84 instance.daemon_id = daemon_id 85 if instance.pickle_path.exists(): 86 instance = instance.read_pickle() 87 return instance 88 89 @classmethod 90 def from_properties_file(cls, daemon_id: str) -> Daemon: 91 """ 92 Return a Daemon from a properties dictionary. 93 """ 94 properties_path = cls._get_properties_path_from_daemon_id(daemon_id) 95 if not properties_path.exists(): 96 raise OSError(f"Properties file '{properties_path}' does not exist.") 97 98 try: 99 with open(properties_path, 'r', encoding='utf-8') as f: 100 properties = json.load(f) 101 except Exception: 102 properties = {} 103 104 if not properties: 105 raise ValueError(f"No properties could be read for daemon '{daemon_id}'.") 106 107 daemon_id = properties_path.parent.name 108 target_cf = properties.get('target', {}) 109 target_module_name = target_cf.get('module', None) 110 target_function_name = target_cf.get('name', None) 111 target_args = target_cf.get('args', None) 112 target_kw = target_cf.get('kw', None) 113 label = properties.get('label', None) 114 115 if None in [ 116 target_module_name, 117 target_function_name, 118 target_args, 119 target_kw, 120 ]: 121 raise ValueError("Missing target function information.") 122 123 target_module = importlib.import_module(target_module_name) 124 target_function = getattr(target_module, target_function_name) 125 126 return Daemon( 127 daemon_id=daemon_id, 128 target=target_function, 129 target_args=target_args, 130 target_kw=target_kw, 131 properties=properties, 132 label=label, 133 ) 134 135 136 def __init__( 137 self, 138 target: Optional[Callable[[Any], Any]] = None, 139 target_args: Union[List[Any], Tuple[Any], None] = None, 140 target_kw: Optional[Dict[str, Any]] = None, 141 env: Optional[Dict[str, str]] = None, 142 daemon_id: Optional[str] = None, 143 label: Optional[str] = None, 144 properties: Optional[Dict[str, Any]] = None, 145 pickle: bool = True, 146 ): 147 """ 148 Parameters 149 ---------- 150 target: Optional[Callable[[Any], Any]], default None, 151 The function to execute in a child process. 152 153 target_args: Union[List[Any], Tuple[Any], None], default None 154 Positional arguments to pass to the target function. 155 156 target_kw: Optional[Dict[str, Any]], default None 157 Keyword arguments to pass to the target function. 158 159 env: Optional[Dict[str, str]], default None 160 If provided, set these environment variables in the daemon process. 161 162 daemon_id: Optional[str], default None 163 Build a `Daemon` from an existing `daemon_id`. 164 If `daemon_id` is provided, other arguments are ignored and are derived 165 from the existing pickled `Daemon`. 166 167 label: Optional[str], default None 168 Label string to help identifiy a daemon. 169 If `None`, use the function name instead. 170 171 properties: Optional[Dict[str, Any]], default None 172 Override reading from the properties JSON by providing an existing dictionary. 173 """ 174 _pickle = self.__dict__.get('_pickle', False) 175 if daemon_id is not None: 176 self.daemon_id = daemon_id 177 if not self.pickle_path.exists() and not target and ('target' not in self.__dict__): 178 179 if not self.properties_path.exists(): 180 raise Exception( 181 f"Daemon '{self.daemon_id}' does not exist. " 182 + "Pass a target to create a new Daemon." 183 ) 184 185 try: 186 new_daemon = self.from_properties_file(daemon_id) 187 except Exception: 188 new_daemon = None 189 190 if new_daemon is not None: 191 new_daemon.write_pickle() 192 target = new_daemon.target 193 target_args = new_daemon.target_args 194 target_kw = new_daemon.target_kw 195 label = new_daemon.label 196 self._properties = new_daemon.properties 197 else: 198 try: 199 self.properties_path.unlink() 200 except Exception: 201 pass 202 203 raise Exception( 204 f"Could not recover daemon '{self.daemon_id}' " 205 + "from its properties file." 206 ) 207 208 if 'target' not in self.__dict__: 209 if target is None: 210 error("Cannot create a Daemon without a target.") 211 self.target = target 212 213 self.pickle = pickle 214 215 ### NOTE: We have to check self.__dict__ in case we un-pickling. 216 if '_target_args' not in self.__dict__: 217 self._target_args = target_args 218 if '_target_kw' not in self.__dict__: 219 self._target_kw = target_kw 220 221 if 'label' not in self.__dict__: 222 if label is None: 223 label = ( 224 self.target.__name__ if '__name__' in self.target.__dir__() 225 else str(self.target) 226 ) 227 self.label = label 228 elif label is not None: 229 self.label = label 230 231 if 'daemon_id' not in self.__dict__: 232 self.daemon_id = get_new_daemon_name() 233 if '_properties' not in self.__dict__: 234 self._properties = properties 235 elif properties: 236 if self._properties is None: 237 self._properties = {} 238 self._properties.update(properties) 239 if self._properties is None: 240 self._properties = {} 241 242 self._properties.update({'label': self.label}) 243 if env: 244 self._properties.update({'env': env}) 245 246 ### Instantiate the process and if it doesn't exist, make sure the PID is removed. 247 _ = self.process 248 249 250 def _run_exit( 251 self, 252 keep_daemon_output: bool = True, 253 allow_dirty_run: bool = False, 254 ) -> Any: 255 """Run the daemon's target function. 256 NOTE: This WILL EXIT the parent process! 257 258 Parameters 259 ---------- 260 keep_daemon_output: bool, default True 261 If `False`, delete the daemon's output directory upon exiting. 262 263 allow_dirty_run, bool, default False: 264 If `True`, run the daemon, even if the `daemon_id` directory exists. 265 This option is dangerous because if the same `daemon_id` runs twice, 266 the last to finish will overwrite the output of the first. 267 268 Returns 269 ------- 270 Nothing — this will exit the parent process. 271 """ 272 import platform 273 import sys 274 import os 275 import traceback 276 from meerschaum.utils.warnings import warn 277 from meerschaum.config import get_config 278 daemon = attempt_import('daemon') 279 lines = get_config('jobs', 'terminal', 'lines') 280 columns = get_config('jobs', 'terminal', 'columns') 281 282 if platform.system() == 'Windows': 283 return False, "Windows is no longer supported." 284 285 self._setup(allow_dirty_run) 286 287 _daemons.append(self) 288 289 logs_cf = self.properties.get('logs', {}) 290 log_refresh_seconds = logs_cf.get('refresh_files_seconds', None) 291 if log_refresh_seconds is None: 292 log_refresh_seconds = get_config('jobs', 'logs', 'refresh_files_seconds') 293 write_timestamps = logs_cf.get('write_timestamps', None) 294 if write_timestamps is None: 295 write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled') 296 297 self._log_refresh_timer = RepeatTimer( 298 log_refresh_seconds, 299 partial(self.rotating_log.refresh_files, start_interception=write_timestamps), 300 ) 301 302 capture_stdin = logs_cf.get('stdin', True) 303 cwd = self.properties.get('cwd', os.getcwd()) 304 305 ### NOTE: The SIGINT handler has been removed so that child processes may handle 306 ### KeyboardInterrupts themselves. 307 ### The previous aggressive approach was redundant because of the SIGTERM handler. 308 self._daemon_context = daemon.DaemonContext( 309 pidfile=self.pid_lock, 310 stdout=self.rotating_log, 311 stderr=self.rotating_log, 312 stdin=(self.stdin_file if capture_stdin else None), 313 working_directory=cwd, 314 detach_process=True, 315 files_preserve=list(self.rotating_log.subfile_objects.values()), 316 signal_map={ 317 signal.SIGTERM: self._handle_sigterm, 318 }, 319 ) 320 321 if capture_stdin and sys.stdin is None: 322 raise OSError("Cannot daemonize without stdin.") 323 324 try: 325 os.environ['LINES'], os.environ['COLUMNS'] = str(int(lines)), str(int(columns)) 326 with self._daemon_context: 327 if capture_stdin: 328 sys.stdin = self.stdin_file 329 _ = os.environ.pop(STATIC_CONFIG['environment']['systemd_stdin_path'], None) 330 os.environ[STATIC_CONFIG['environment']['daemon_id']] = self.daemon_id 331 os.environ['PYTHONUNBUFFERED'] = '1' 332 333 ### Allow the user to override environment variables. 334 env = self.properties.get('env', {}) 335 if env and isinstance(env, dict): 336 os.environ.update({str(k): str(v) for k, v in env.items()}) 337 338 self.rotating_log.refresh_files(start_interception=True) 339 result = None 340 try: 341 with open(self.pid_path, 'w+', encoding='utf-8') as f: 342 f.write(str(os.getpid())) 343 344 ### NOTE: The timer fails to start for remote actions to localhost. 345 try: 346 if not self._log_refresh_timer.is_running(): 347 self._log_refresh_timer.start() 348 except Exception: 349 pass 350 351 self.properties['result'] = None 352 self._capture_process_timestamp('began') 353 result = self.target(*self.target_args, **self.target_kw) 354 self.properties['result'] = result 355 except SystemExit as e: 356 result = ( 357 (True, f"Stopped daemon '{self.daemon_id}'.") 358 if e.code is None or e.code == 0 359 else (False, traceback.format_exc()) 360 ) 361 except (BrokenPipeError, KeyboardInterrupt): 362 result = False, traceback.format_exc() 363 except Exception as e: 364 warn( 365 f"Exception in daemon target function: {traceback.format_exc()}", 366 ) 367 result = False, str(e) 368 finally: 369 _results[self.daemon_id] = result 370 self.properties['result'] = result 371 372 if keep_daemon_output: 373 self._capture_process_timestamp('ended') 374 else: 375 self.cleanup() 376 377 self._log_refresh_timer.cancel() 378 try: 379 if self.pid is None and self.pid_path.exists(): 380 self.pid_path.unlink() 381 except Exception: 382 pass 383 384 if is_success_tuple(result): 385 try: 386 mrsm.pprint(result) 387 except BrokenPipeError: 388 pass 389 390 except Exception: 391 daemon_error = traceback.format_exc() 392 import meerschaum.config.paths as paths 393 with open(paths.DAEMON_ERROR_LOG_PATH, 'a+', encoding='utf-8') as f: 394 f.write( 395 f"Error in Daemon '{self}':\n\n" 396 f"{sys.stdin=}\n" 397 f"{self.stdin_file_path=}\n" 398 f"{self.stdin_file_path.exists()=}\n\n" 399 f"{daemon_error}\n\n" 400 ) 401 warn(f"Encountered an error while running the daemon '{self}':\n{daemon_error}") 402 403 def _capture_process_timestamp( 404 self, 405 process_key: str, 406 write_properties: bool = True, 407 ) -> None: 408 """ 409 Record the current timestamp to the parameters `process:<process_key>`. 410 411 Parameters 412 ---------- 413 process_key: str 414 Under which key to store the timestamp. 415 416 write_properties: bool, default True 417 If `True` persist the properties to disk immediately after capturing the timestamp. 418 """ 419 if 'process' not in self.properties: 420 self.properties['process'] = {} 421 422 if process_key not in ('began', 'ended', 'paused', 'stopped'): 423 raise ValueError(f"Invalid key '{process_key}'.") 424 425 self.properties['process'][process_key] = ( 426 datetime.now(timezone.utc).replace(tzinfo=None).isoformat() 427 ) 428 if write_properties: 429 self.write_properties() 430 431 def run( 432 self, 433 keep_daemon_output: bool = True, 434 allow_dirty_run: bool = False, 435 wait: bool = False, 436 timeout: Union[int, float] = 4, 437 debug: bool = False, 438 ) -> SuccessTuple: 439 """Run the daemon as a child process and continue executing the parent. 440 441 Parameters 442 ---------- 443 keep_daemon_output: bool, default True 444 If `False`, delete the daemon's output directory upon exiting. 445 446 allow_dirty_run: bool, default False 447 If `True`, run the daemon, even if the `daemon_id` directory exists. 448 This option is dangerous because if the same `daemon_id` runs concurrently, 449 the last to finish will overwrite the output of the first. 450 451 wait: bool, default True 452 If `True`, block until `Daemon.status` is running (or the timeout expires). 453 454 timeout: Union[int, float], default 4 455 If `wait` is `True`, block for up to `timeout` seconds before returning a failure. 456 457 Returns 458 ------- 459 A SuccessTuple indicating success. 460 461 """ 462 import platform 463 if platform.system() == 'Windows': 464 return False, "Cannot run background jobs on Windows." 465 466 ### The daemon might exist and be paused. 467 if self.status == 'paused': 468 return self.resume() 469 470 self._remove_stop_file() 471 if self.status == 'running': 472 return True, f"Daemon '{self}' is already running." 473 474 self.mkdir_if_not_exists(allow_dirty_run) 475 _write_pickle_success_tuple = self.write_pickle() 476 if not _write_pickle_success_tuple[0]: 477 return _write_pickle_success_tuple 478 479 _launch_daemon_code = ( 480 "from meerschaum.utils.daemon import Daemon, _daemons; " 481 f"daemon = Daemon(daemon_id='{self.daemon_id}'); " 482 f"_daemons['{self.daemon_id}'] = daemon; " 483 f"daemon._run_exit(keep_daemon_output={keep_daemon_output}, " 484 "allow_dirty_run=True)" 485 ) 486 env = dict(os.environ) 487 _launch_success_bool = venv_exec(_launch_daemon_code, debug=debug, venv=None, env=env) 488 msg = ( 489 "Success" 490 if _launch_success_bool 491 else f"Failed to start daemon '{self.daemon_id}'." 492 ) 493 if not wait or not _launch_success_bool: 494 return _launch_success_bool, msg 495 496 timeout = self.get_timeout_seconds(timeout) 497 check_timeout_interval = self.get_check_timeout_interval_seconds() 498 499 if not timeout: 500 success = self.status == 'running' 501 msg = "Success" if success else f"Failed to run daemon '{self.daemon_id}'." 502 if success: 503 self._capture_process_timestamp('began') 504 return success, msg 505 506 begin = time.perf_counter() 507 while (time.perf_counter() - begin) < timeout: 508 if self.status == 'running': 509 self._capture_process_timestamp('began') 510 return True, "Success" 511 time.sleep(check_timeout_interval) 512 513 return False, ( 514 f"Failed to start daemon '{self.daemon_id}' within {timeout} second" 515 + ('s' if timeout != 1 else '') + '.' 516 ) 517 518 519 def kill(self, timeout: Union[int, float, None] = 8) -> SuccessTuple: 520 """ 521 Forcibly terminate a running daemon. 522 Sends a SIGTERM signal to the process. 523 524 Parameters 525 ---------- 526 timeout: Optional[int], default 3 527 How many seconds to wait for the process to terminate. 528 529 Returns 530 ------- 531 A SuccessTuple indicating success. 532 """ 533 ### A lost PID file means a detached/orphaned daemon — possibly SEVERAL processes 534 ### sharing this daemon_id (e.g. accumulated across crashed restarts). Reap them 535 ### ALL by daemon_id rather than reporting a false "stopped" and stranding them 536 ### (which forces a manual `pkill meerschaum` or hunting the daemon_id in htop). 537 if not self.pid_path.exists(): 538 reaped = self._kill_detached_processes(timeout) 539 self._write_stop_file('kill') 540 self.stdin_file.close() 541 self._remove_blocking_stdin_file() 542 return True, ( 543 (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.') 544 if reaped 545 else "Process has already stopped." 546 ) 547 548 if self.status != 'paused': 549 success, msg = self._send_signal(signal.SIGTERM, timeout=timeout) 550 if success: 551 ### Sweep any sibling/detached processes left over for this daemon_id. 552 self._kill_detached_processes(timeout) 553 self._write_stop_file('kill') 554 self.stdin_file.close() 555 self._remove_blocking_stdin_file() 556 return success, msg 557 558 if self.status == 'stopped': 559 reaped = self._kill_detached_processes(timeout) 560 self._write_stop_file('kill') 561 self.stdin_file.close() 562 self._remove_blocking_stdin_file() 563 return True, ( 564 (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.') 565 if reaped 566 else "Process has already stopped." 567 ) 568 569 psutil = attempt_import('psutil') 570 process = self.process 571 try: 572 process.terminate() 573 process.kill() 574 process.wait(timeout=timeout) 575 except Exception as e: 576 return False, f"Failed to kill job {self} ({process}) with exception: {e}" 577 578 try: 579 if process.status(): 580 return False, "Failed to stop daemon '{self}' ({process})." 581 except psutil.NoSuchProcess: 582 pass 583 584 if self.pid_path.exists(): 585 try: 586 self.pid_path.unlink() 587 except Exception: 588 pass 589 590 self._write_stop_file('kill') 591 self.stdin_file.close() 592 self._remove_blocking_stdin_file() 593 return True, "Success" 594 595 def quit(self, timeout: Union[int, float, None] = None) -> SuccessTuple: 596 """Gracefully quit a running daemon.""" 597 if self.status == 'paused': 598 return self.kill(timeout) 599 600 ### A lost PID file means a detached/orphaned daemon (possibly several processes); 601 ### the normal signal path can't see them, so reap them all by daemon_id instead 602 ### of returning a false "not running". 603 if not self.pid_path.exists(): 604 reaped = self._kill_detached_processes(timeout) 605 self._write_stop_file('quit') 606 self.stdin_file.close() 607 self._remove_blocking_stdin_file() 608 return True, ( 609 (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.') 610 if reaped 611 else "Process is not running." 612 ) 613 614 signal_success, signal_msg = self._send_signal(signal.SIGINT, timeout=timeout) 615 if signal_success: 616 self._write_stop_file('quit') 617 self.stdin_file.close() 618 self._remove_blocking_stdin_file() 619 return signal_success, signal_msg 620 621 def pause( 622 self, 623 timeout: Union[int, float, None] = None, 624 check_timeout_interval: Union[float, int, None] = None, 625 ) -> SuccessTuple: 626 """ 627 Pause the daemon if it is running. 628 629 Parameters 630 ---------- 631 timeout: Union[float, int, None], default None 632 The maximum number of seconds to wait for a process to suspend. 633 634 check_timeout_interval: Union[float, int, None], default None 635 The number of seconds to wait between checking if the process is still running. 636 637 Returns 638 ------- 639 A `SuccessTuple` indicating whether the `Daemon` process was successfully suspended. 640 """ 641 self._remove_blocking_stdin_file() 642 643 if self.process is None: 644 return False, f"Daemon '{self.daemon_id}' is not running and cannot be paused." 645 646 if self.status == 'paused': 647 return True, f"Daemon '{self.daemon_id}' is already paused." 648 649 self._write_stop_file('pause') 650 self.stdin_file.close() 651 self._remove_blocking_stdin_file() 652 try: 653 self.process.suspend() 654 except Exception as e: 655 return False, f"Failed to pause daemon '{self.daemon_id}':\n{e}" 656 657 timeout = self.get_timeout_seconds(timeout) 658 check_timeout_interval = self.get_check_timeout_interval_seconds( 659 check_timeout_interval 660 ) 661 662 psutil = attempt_import('psutil') 663 664 if not timeout: 665 try: 666 success = self.process.status() == 'stopped' 667 except psutil.NoSuchProcess: 668 success = True 669 msg = "Success" if success else f"Failed to suspend daemon '{self.daemon_id}'." 670 if success: 671 self._capture_process_timestamp('paused') 672 return success, msg 673 674 begin = time.perf_counter() 675 while (time.perf_counter() - begin) < timeout: 676 try: 677 if self.process.status() == 'stopped': 678 self._capture_process_timestamp('paused') 679 return True, "Success" 680 except psutil.NoSuchProcess as e: 681 return False, f"Process exited unexpectedly. Was it killed?\n{e}" 682 time.sleep(check_timeout_interval) 683 684 return False, ( 685 f"Failed to pause daemon '{self.daemon_id}' within {timeout} second" 686 + ('s' if timeout != 1 else '') + '.' 687 ) 688 689 def resume( 690 self, 691 timeout: Union[int, float, None] = None, 692 check_timeout_interval: Union[float, int, None] = None, 693 ) -> SuccessTuple: 694 """ 695 Resume the daemon if it is paused. 696 697 Parameters 698 ---------- 699 timeout: Union[float, int, None], default None 700 The maximum number of seconds to wait for a process to resume. 701 702 check_timeout_interval: Union[float, int, None], default None 703 The number of seconds to wait between checking if the process is still stopped. 704 705 Returns 706 ------- 707 A `SuccessTuple` indicating whether the `Daemon` process was successfully resumed. 708 """ 709 if self.status == 'running': 710 return True, f"Daemon '{self.daemon_id}' is already running." 711 712 if self.status == 'stopped': 713 return False, f"Daemon '{self.daemon_id}' is stopped and cannot be resumed." 714 715 self._remove_stop_file() 716 try: 717 if self.process is None: 718 return False, f"Cannot resume daemon '{self.daemon_id}'." 719 720 self.process.resume() 721 except Exception as e: 722 return False, f"Failed to resume daemon '{self.daemon_id}':\n{e}" 723 724 timeout = self.get_timeout_seconds(timeout) 725 check_timeout_interval = self.get_check_timeout_interval_seconds( 726 check_timeout_interval 727 ) 728 729 if not timeout: 730 success = self.status == 'running' 731 msg = "Success" if success else f"Failed to resume daemon '{self.daemon_id}'." 732 if success: 733 self._capture_process_timestamp('began') 734 return success, msg 735 736 begin = time.perf_counter() 737 while (time.perf_counter() - begin) < timeout: 738 if self.status == 'running': 739 self._capture_process_timestamp('began') 740 return True, "Success" 741 time.sleep(check_timeout_interval) 742 743 return False, ( 744 f"Failed to resume daemon '{self.daemon_id}' within {timeout} second" 745 + ('s' if timeout != 1 else '') + '.' 746 ) 747 748 def _write_stop_file(self, action: str) -> SuccessTuple: 749 """Write the stop file timestamp and action.""" 750 if action not in ('quit', 'kill', 'pause'): 751 return False, f"Unsupported action '{action}'." 752 753 if not self.stop_path.parent.exists(): 754 self.stop_path.parent.mkdir(parents=True, exist_ok=True) 755 756 with open(self.stop_path, 'w+', encoding='utf-8') as f: 757 json.dump( 758 { 759 'stop_time': datetime.now(timezone.utc).isoformat(), 760 'action': action, 761 }, 762 f 763 ) 764 765 return True, "Success" 766 767 def _remove_stop_file(self) -> SuccessTuple: 768 """Remove the stop file""" 769 if not self.stop_path.exists(): 770 return True, "Stop file does not exist." 771 772 try: 773 self.stop_path.unlink() 774 except Exception as e: 775 return False, f"Failed to remove stop file:\n{e}" 776 777 return True, "Success" 778 779 def _read_stop_file(self) -> Dict[str, Any]: 780 """ 781 Read the stop file if it exists. 782 """ 783 if not self.stop_path.exists(): 784 return {} 785 786 try: 787 with open(self.stop_path, 'r', encoding='utf-8') as f: 788 data = json.load(f) 789 return data 790 except Exception: 791 return {} 792 793 def _remove_blocking_stdin_file(self) -> mrsm.SuccessTuple: 794 """ 795 Remove the blocking STDIN file if it exists. 796 """ 797 try: 798 if self.blocking_stdin_file_path.exists(): 799 self.blocking_stdin_file_path.unlink() 800 except Exception as e: 801 return False, str(e) 802 803 return True, "Success" 804 805 def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None: 806 """ 807 Handle `SIGTERM` within the `Daemon` context. 808 This method is injected into the `DaemonContext`. 809 """ 810 from meerschaum.utils.process import signal_handler 811 from meerschaum.utils.threading import request_stop, interrupt_threads 812 signal_handler(signal_number, stack_frame) 813 814 ### Tell cooperative loops (e.g. `sync pipes`) to stop, then actively unwind 815 ### any worker threads so they cannot keep the process alive as a zombie. 816 request_stop() 817 interrupt_threads(SystemExit) 818 819 timer = self.__dict__.get('_log_refresh_timer', None) 820 if timer is not None: 821 timer.cancel() 822 823 daemon_context = self.__dict__.get('_daemon_context', None) 824 if daemon_context is not None: 825 daemon_context.close() 826 827 _close_pools() 828 raise SystemExit(0) 829 830 def _send_signal( 831 self, 832 signal_to_send, 833 timeout: Union[float, int, None] = None, 834 check_timeout_interval: Union[float, int, None] = None, 835 ) -> SuccessTuple: 836 """Send a signal to the daemon process. 837 838 Parameters 839 ---------- 840 signal_to_send: 841 The signal the send to the daemon, e.g. `signals.SIGINT`. 842 843 timeout: Union[float, int, None], default None 844 The maximum number of seconds to wait for a process to terminate. 845 846 check_timeout_interval: Union[float, int, None], default None 847 The number of seconds to wait between checking if the process is still running. 848 849 Returns 850 ------- 851 A SuccessTuple indicating success. 852 """ 853 try: 854 pid = self.pid 855 if pid is None: 856 return ( 857 False, 858 f"Daemon '{self.daemon_id}' is not running, " 859 + f"cannot send signal '{signal_to_send}'." 860 ) 861 862 os.kill(pid, signal_to_send) 863 except Exception: 864 return False, f"Failed to send signal {signal_to_send}:\n{traceback.format_exc()}" 865 866 timeout = self.get_timeout_seconds(timeout) 867 check_timeout_interval = self.get_check_timeout_interval_seconds( 868 check_timeout_interval 869 ) 870 871 if not timeout: 872 return True, f"Successfully sent '{signal_to_send}' to daemon '{self.daemon_id}'." 873 874 begin = time.perf_counter() 875 while (time.perf_counter() - begin) < timeout: 876 if not self.status == 'running': 877 return True, "Success" 878 time.sleep(check_timeout_interval) 879 880 return False, ( 881 f"Failed to stop daemon '{self.daemon_id}' (PID: {pid}) within {timeout} second" 882 + ('s' if timeout != 1 else '') + '.' 883 ) 884 885 def _find_detached_pids(self) -> List[int]: 886 """ 887 Return the PIDs of any processes whose command line references this daemon_id. 888 889 A daemon is launched via `venv_exec` with `Daemon(daemon_id='<id>')` embedded in 890 the executed code, so the daemon_id stays visible in the process's command line. 891 When the PID file is lost (e.g. the launcher exited without cleanup, leaving an 892 orphaned/detached daemon), this is the only reliable way to find the process — 893 otherwise stopping the job reports a false "already stopped" and the user must 894 resort to `pkill meerschaum` or hunting the daemon_id in `htop`. 895 """ 896 psutil = attempt_import('psutil') 897 marker = f"daemon_id='{self.daemon_id}'" 898 my_pid = os.getpid() 899 pids = [] 900 for proc in psutil.process_iter(['pid', 'cmdline']): 901 try: 902 if proc.info['pid'] == my_pid: 903 continue 904 cmdline = proc.info.get('cmdline') or [] 905 if any(marker in (part or '') for part in cmdline): 906 pids.append(int(proc.info['pid'])) 907 except Exception: 908 continue 909 return pids 910 911 def _kill_detached_processes(self, timeout: Union[int, float, None] = None) -> int: 912 """ 913 SIGTERM then SIGKILL any detached processes referencing this daemon_id. 914 915 Fallback for when the PID file is gone but the daemon (or its threads) are still 916 alive. Returns the number of processes that were targeted. 917 """ 918 psutil = attempt_import('psutil') 919 pids = self._find_detached_pids() 920 if not pids: 921 return 0 922 923 procs = [] 924 for pid in pids: 925 try: 926 procs.append(psutil.Process(pid)) 927 except Exception: 928 continue 929 for proc in procs: 930 try: 931 proc.terminate() 932 except Exception: 933 pass 934 935 timeout = self.get_timeout_seconds(timeout) 936 try: 937 _, alive = psutil.wait_procs(procs, timeout=(timeout or 8)) 938 except Exception: 939 alive = procs 940 for proc in alive: 941 try: 942 proc.kill() 943 except Exception: 944 pass 945 return len(procs) 946 947 def mkdir_if_not_exists(self, allow_dirty_run: bool = False): 948 """Create the Daemon's directory. 949 If `allow_dirty_run` is `False` and the directory already exists, 950 raise a `FileExistsError`. 951 """ 952 try: 953 self.path.mkdir(parents=True, exist_ok=True) 954 _already_exists = any(os.scandir(self.path)) 955 except FileExistsError: 956 _already_exists = True 957 958 if _already_exists and not allow_dirty_run: 959 error( 960 f"Daemon '{self.daemon_id}' already exists. " + 961 "To allow this daemon to run, do one of the following:\n" 962 + " - Execute `daemon.cleanup()`.\n" 963 + f" - Delete the directory '{self.path}'.\n" 964 + " - Pass `allow_dirty_run=True` to `daemon.run()`.\n", 965 FileExistsError, 966 ) 967 968 @property 969 def process(self) -> Union['psutil.Process', None]: 970 """ 971 Return the psutil process for the Daemon. 972 """ 973 psutil = attempt_import('psutil') 974 pid = self.pid 975 if pid is None: 976 return None 977 if '_process' not in self.__dict__ or self.__dict__['_process'].pid != int(pid): 978 try: 979 self._process = psutil.Process(int(pid)) 980 process_exists = True 981 except Exception: 982 process_exists = False 983 if not process_exists: 984 _ = self.__dict__.pop('_process', None) 985 try: 986 if self.pid_path.exists(): 987 self.pid_path.unlink() 988 except Exception: 989 pass 990 return None 991 return self._process 992 993 @property 994 def status(self) -> str: 995 """ 996 Return the running status of this Daemon. 997 """ 998 if self.process is None: 999 return 'stopped' 1000 1001 psutil = attempt_import('psutil', lazy=False) 1002 try: 1003 if self.process.status() == 'stopped': 1004 return 'paused' 1005 if self.process.status() == 'zombie': 1006 raise psutil.NoSuchProcess(self.process.pid) 1007 except (psutil.NoSuchProcess, AttributeError): 1008 if self.pid_path.exists(): 1009 try: 1010 self.pid_path.unlink() 1011 except Exception: 1012 pass 1013 return 'stopped' 1014 1015 return 'running' 1016 1017 @classmethod 1018 def _get_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path: 1019 """ 1020 Return a Daemon's path from its `daemon_id`. 1021 """ 1022 import meerschaum.config.paths as paths 1023 return paths.DAEMON_RESOURCES_PATH / daemon_id 1024 1025 @property 1026 def path(self) -> pathlib.Path: 1027 """ 1028 Return the path for this Daemon's directory. 1029 """ 1030 return self._get_path_from_daemon_id(self.daemon_id) 1031 1032 @classmethod 1033 def _get_properties_path_from_daemon_id(cls, daemon_id: str) -> pathlib.Path: 1034 """ 1035 Return the `properties.json` path for a given `daemon_id`. 1036 """ 1037 return cls._get_path_from_daemon_id(daemon_id) / 'properties.json' 1038 1039 @property 1040 def properties_path(self) -> pathlib.Path: 1041 """ 1042 Return the `propterties.json` path for this Daemon. 1043 """ 1044 return self._get_properties_path_from_daemon_id(self.daemon_id) 1045 1046 @property 1047 def stop_path(self) -> pathlib.Path: 1048 """ 1049 Return the path for the stop file (created when manually stopped). 1050 """ 1051 return self.path / '.stop.json' 1052 1053 @property 1054 def log_path(self) -> pathlib.Path: 1055 """ 1056 Return the log path. 1057 """ 1058 logs_cf = self.properties.get('logs', None) or {} 1059 if 'path' not in logs_cf: 1060 import meerschaum.config.paths as paths 1061 return paths.LOGS_RESOURCES_PATH / (self.daemon_id + '.log') 1062 1063 return pathlib.Path(logs_cf['path']) 1064 1065 @property 1066 def stdin_file_path(self) -> pathlib.Path: 1067 """ 1068 Return the stdin file path. 1069 """ 1070 return self.path / 'input.stdin' 1071 1072 @property 1073 def blocking_stdin_file_path(self) -> pathlib.Path: 1074 """ 1075 Return the stdin file path. 1076 """ 1077 if '_blocking_stdin_file_path' in self.__dict__: 1078 return self._blocking_stdin_file_path 1079 1080 return self.path / 'input.stdin.block' 1081 1082 @property 1083 def prompt_kwargs_file_path(self) -> pathlib.Path: 1084 """ 1085 Return the file path to the kwargs for the invoking `prompt()`. 1086 """ 1087 return self.path / 'prompt_kwargs.json' 1088 1089 @property 1090 def log_offset_path(self) -> pathlib.Path: 1091 """ 1092 Return the log offset file path. 1093 """ 1094 import meerschaum.config.paths as paths 1095 return paths.LOGS_RESOURCES_PATH / ('.' + self.daemon_id + '.log.offset') 1096 1097 @property 1098 def log_offset_lock(self) -> 'InterProcessLock': 1099 """ 1100 Return the process lock context manager. 1101 """ 1102 if '_log_offset_lock' in self.__dict__: 1103 return self._log_offset_lock 1104 1105 from meerschaum.utils.locks import InterProcessLock 1106 self._log_offset_lock = InterProcessLock(self.log_offset_path) 1107 return self._log_offset_lock 1108 1109 @property 1110 def rotating_log(self) -> RotatingFile: 1111 """ 1112 The rotating log file for the daemon's output. 1113 """ 1114 if '_rotating_log' in self.__dict__: 1115 return self._rotating_log 1116 1117 logs_cf = self.properties.get('logs', None) or {} 1118 write_timestamps = logs_cf.get('write_timestamps', None) 1119 if write_timestamps is None: 1120 write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled') 1121 1122 timestamp_format = logs_cf.get('timestamp_format', None) 1123 if timestamp_format is None: 1124 timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format') 1125 1126 num_files_to_keep = logs_cf.get('num_files_to_keep', None) 1127 if num_files_to_keep is None: 1128 num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep') 1129 1130 max_file_size = logs_cf.get('max_file_size', None) 1131 if max_file_size is None: 1132 max_file_size = get_config('jobs', 'logs', 'max_file_size') 1133 1134 redirect_streams = logs_cf.get('redirect_streams', True) 1135 1136 self._rotating_log = RotatingFile( 1137 self.log_path, 1138 redirect_streams=redirect_streams, 1139 write_timestamps=write_timestamps, 1140 timestamp_format=timestamp_format, 1141 num_files_to_keep=num_files_to_keep, 1142 max_file_size=max_file_size, 1143 ) 1144 return self._rotating_log 1145 1146 @property 1147 def stdin_file(self): 1148 """ 1149 Return the file handler for the stdin file. 1150 """ 1151 if (_stdin_file := self.__dict__.get('_stdin_file', None)): 1152 return _stdin_file 1153 1154 self._stdin_file = StdinFile( 1155 self.stdin_file_path, 1156 lock_file_path=self.blocking_stdin_file_path, 1157 ) 1158 return self._stdin_file 1159 1160 @property 1161 def log_text(self) -> Union[str, None]: 1162 """ 1163 Read the log files and return their contents. 1164 Returns `None` if the log file does not exist. 1165 """ 1166 logs_cf = self.properties.get('logs', None) or {} 1167 write_timestamps = logs_cf.get('write_timestamps', None) 1168 if write_timestamps is None: 1169 write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled') 1170 1171 timestamp_format = logs_cf.get('timestamp_format', None) 1172 if timestamp_format is None: 1173 timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format') 1174 1175 num_files_to_keep = logs_cf.get('num_files_to_keep', None) 1176 if num_files_to_keep is None: 1177 num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep') 1178 1179 max_file_size = logs_cf.get('max_file_size', None) 1180 if max_file_size is None: 1181 max_file_size = get_config('jobs', 'logs', 'max_file_size') 1182 1183 new_rotating_log = RotatingFile( 1184 self.rotating_log.file_path, 1185 num_files_to_keep=num_files_to_keep, 1186 max_file_size=max_file_size, 1187 write_timestamps=write_timestamps, 1188 timestamp_format=timestamp_format, 1189 ) 1190 return new_rotating_log.read() 1191 1192 def readlines(self) -> List[str]: 1193 """ 1194 Read the next log lines, persisting the cursor for later use. 1195 Note this will alter the cursor of `self.rotating_log`. 1196 """ 1197 self.rotating_log._cursor = self._read_log_offset() 1198 lines = self.rotating_log.readlines() 1199 self._write_log_offset() 1200 return lines 1201 1202 def _read_log_offset(self) -> Tuple[int, int]: 1203 """ 1204 Return the current log offset cursor. 1205 1206 Returns 1207 ------- 1208 A tuple of the form (`subfile_index`, `position`). 1209 """ 1210 if not self.log_offset_path.exists(): 1211 return 0, 0 1212 1213 try: 1214 with open(self.log_offset_path, 'r', encoding='utf-8') as f: 1215 cursor_text = f.read() 1216 cursor_parts = cursor_text.split(' ') 1217 subfile_index, subfile_position = int(cursor_parts[0]), int(cursor_parts[1]) 1218 return subfile_index, subfile_position 1219 except Exception as e: 1220 warn(f"Failed to read cursor:\n{e}") 1221 return 0, 0 1222 1223 def _write_log_offset(self) -> None: 1224 """ 1225 Write the current log offset file. 1226 """ 1227 with self.log_offset_lock: 1228 with open(self.log_offset_path, 'w+', encoding='utf-8') as f: 1229 subfile_index = self.rotating_log._cursor[0] 1230 subfile_position = self.rotating_log._cursor[1] 1231 f.write(f"{subfile_index} {subfile_position}") 1232 1233 @property 1234 def pid(self) -> Union[int, None]: 1235 """ 1236 Read the PID file and return its contents. 1237 Returns `None` if the PID file does not exist. 1238 """ 1239 if not self.pid_path.exists(): 1240 ### The PID file can be lost while a detached daemon keeps running (e.g. the 1241 ### launcher exited without cleanup). Recover the PID by finding the process 1242 ### whose command line embeds this daemon_id, so `status`/`stop`/`kill` see the 1243 ### live process instead of reporting a false "stopped" (which would strand the 1244 ### orphan, forcing a manual `pkill meerschaum`). 1245 detached_pids = self._find_detached_pids() 1246 return detached_pids[0] if detached_pids else None 1247 try: 1248 with open(self.pid_path, 'r', encoding='utf-8') as f: 1249 text = f.read() 1250 if len(text) == 0: 1251 return None 1252 pid = int(text.rstrip()) 1253 except Exception as e: 1254 warn(e) 1255 text = None 1256 pid = None 1257 return pid 1258 1259 @property 1260 def pid_path(self) -> pathlib.Path: 1261 """ 1262 Return the path to a file containing the PID for this Daemon. 1263 """ 1264 return self.path / 'process.pid' 1265 1266 @property 1267 def pid_lock(self) -> 'InterProcessLock': 1268 """ 1269 Return the process lock context manager. 1270 """ 1271 if '_pid_lock' in self.__dict__: 1272 return self._pid_lock 1273 1274 from meerschaum.utils.locks import InterProcessLock 1275 self._pid_lock = InterProcessLock(self.pid_path) 1276 return self._pid_lock 1277 1278 @property 1279 def pickle_path(self) -> pathlib.Path: 1280 """ 1281 Return the path for the pickle file. 1282 """ 1283 return self.path / 'pickle.pkl' 1284 1285 def read_properties(self) -> Optional[Dict[str, Any]]: 1286 """Read the properties JSON file and return the dictionary.""" 1287 if not self.properties_path.exists(): 1288 return None 1289 try: 1290 with open(self.properties_path, 'r', encoding='utf-8') as file: 1291 properties = json.load(file) 1292 except Exception: 1293 properties = {} 1294 1295 return properties or {} 1296 1297 def read_pickle(self) -> Daemon: 1298 """Read a Daemon's pickle file and return the `Daemon`.""" 1299 import pickle 1300 import traceback 1301 if not self.pickle_path.exists(): 1302 error(f"Pickle file does not exist for daemon '{self.daemon_id}'.") 1303 1304 if self.pickle_path.stat().st_size == 0: 1305 error(f"Pickle was empty for daemon '{self.daemon_id}'.") 1306 1307 try: 1308 with open(self.pickle_path, 'rb') as pickle_file: 1309 daemon = pickle.load(pickle_file) 1310 success, msg = True, 'Success' 1311 except Exception as e: 1312 success, msg = False, str(e) 1313 daemon = None 1314 traceback.print_exception(type(e), e, e.__traceback__) 1315 if not success: 1316 error(msg) 1317 return daemon 1318 1319 @property 1320 def properties(self) -> Dict[str, Any]: 1321 """ 1322 Return the contents of the properties JSON file. 1323 """ 1324 try: 1325 _file_properties = self.read_properties() or {} 1326 except Exception: 1327 traceback.print_exc() 1328 _file_properties = {} 1329 1330 if not self._properties: 1331 self._properties = _file_properties 1332 1333 if self._properties is None: 1334 self._properties = {} 1335 1336 if ( 1337 self._properties.get('result', None) is None 1338 and _file_properties.get('result', None) is not None 1339 ): 1340 _ = self._properties.pop('result', None) 1341 1342 if _file_properties is not None: 1343 self._properties = apply_patch_to_config( 1344 _file_properties, 1345 self._properties, 1346 ) 1347 1348 return self._properties 1349 1350 @property 1351 def hidden(self) -> bool: 1352 """ 1353 Return a bool indicating whether this Daemon should be displayed. 1354 """ 1355 return self.daemon_id.startswith('_') or self.daemon_id.startswith('.') 1356 1357 def write_properties(self) -> SuccessTuple: 1358 """Write the properties dictionary to the properties JSON file 1359 (only if self.properties exists). 1360 """ 1361 from meerschaum.utils.misc import generate_password 1362 success, msg = ( 1363 False, 1364 f"No properties to write for daemon '{self.daemon_id}'." 1365 ) 1366 backup_path = self.properties_path.parent / (generate_password(8) + '.json') 1367 props = self.properties 1368 if props is not None: 1369 try: 1370 self.path.mkdir(parents=True, exist_ok=True) 1371 if self.properties_path.exists(): 1372 self.properties_path.rename(backup_path) 1373 with open(self.properties_path, 'w+', encoding='utf-8') as properties_file: 1374 json.dump(props, properties_file) 1375 success, msg = True, 'Success' 1376 except Exception as e: 1377 success, msg = False, str(e) 1378 1379 try: 1380 if backup_path.exists(): 1381 if not success: 1382 backup_path.rename(self.properties_path) 1383 else: 1384 backup_path.unlink() 1385 except Exception as e: 1386 success, msg = False, str(e) 1387 1388 return success, msg 1389 1390 def write_pickle(self) -> SuccessTuple: 1391 """Write the pickle file for the daemon.""" 1392 import pickle 1393 import traceback 1394 from meerschaum.utils.misc import generate_password 1395 1396 if not self.pickle: 1397 return True, "Success" 1398 1399 from meerschaum._internal.entry import _shells 1400 if _shells: 1401 from meerschaum._internal.shell.Shell import revert_input 1402 revert_input() 1403 1404 backup_path = self.pickle_path.parent / (generate_password(7) + '.pkl') 1405 try: 1406 self.path.mkdir(parents=True, exist_ok=True) 1407 if self.pickle_path.exists(): 1408 self.pickle_path.rename(backup_path) 1409 with open(self.pickle_path, 'wb+') as pickle_file: 1410 pickle.dump(self, pickle_file) 1411 success, msg = True, "Success" 1412 except Exception as e: 1413 success, msg = False, str(e) 1414 traceback.print_exception(type(e), e, e.__traceback__) 1415 try: 1416 if backup_path.exists(): 1417 if not success: 1418 backup_path.rename(self.pickle_path) 1419 else: 1420 backup_path.unlink() 1421 except Exception as e: 1422 success, msg = False, str(e) 1423 return success, msg 1424 1425 1426 def _setup( 1427 self, 1428 allow_dirty_run: bool = False, 1429 ) -> None: 1430 """ 1431 Update properties before starting the Daemon. 1432 """ 1433 if self.properties is None: 1434 self._properties = {} 1435 1436 self._properties.update({ 1437 'target': { 1438 'name': self.target.__name__, 1439 'module': self.target.__module__, 1440 'args': self.target_args, 1441 'kw': self.target_kw, 1442 }, 1443 }) 1444 self.mkdir_if_not_exists(allow_dirty_run) 1445 _write_properties_success_tuple = self.write_properties() 1446 if not _write_properties_success_tuple[0]: 1447 error(_write_properties_success_tuple[1]) 1448 1449 _write_pickle_success_tuple = self.write_pickle() 1450 if not _write_pickle_success_tuple[0]: 1451 error(_write_pickle_success_tuple[1]) 1452 1453 def cleanup(self, keep_logs: bool = False) -> SuccessTuple: 1454 """ 1455 Remove a daemon's directory after execution. 1456 1457 Parameters 1458 ---------- 1459 keep_logs: bool, default False 1460 If `True`, skip deleting the daemon's log files. 1461 1462 Returns 1463 ------- 1464 A `SuccessTuple` indicating success. 1465 """ 1466 if self.path.exists(): 1467 try: 1468 shutil.rmtree(self.path) 1469 except Exception as e: 1470 msg = f"Failed to clean up '{self.daemon_id}':\n{e}" 1471 warn(msg) 1472 return False, msg 1473 if not keep_logs: 1474 self.rotating_log.delete() 1475 try: 1476 if self.log_offset_path.exists(): 1477 self.log_offset_path.unlink() 1478 except Exception as e: 1479 msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}" 1480 warn(msg) 1481 return False, msg 1482 return True, "Success" 1483 1484 1485 def get_timeout_seconds(self, timeout: Union[int, float, None] = None) -> Union[int, float]: 1486 """ 1487 Return the timeout value to use. Use `--timeout-seconds` if provided, 1488 else the configured default (8). 1489 """ 1490 if isinstance(timeout, (int, float)): 1491 return timeout 1492 return get_config('jobs', 'timeout_seconds') 1493 1494 1495 def get_check_timeout_interval_seconds( 1496 self, 1497 check_timeout_interval: Union[int, float, None] = None, 1498 ) -> Union[int, float]: 1499 """ 1500 Return the interval value to check the status of timeouts. 1501 """ 1502 if isinstance(check_timeout_interval, (int, float)): 1503 return check_timeout_interval 1504 return get_config('jobs', 'check_timeout_interval_seconds') 1505 1506 @property 1507 def target_args(self) -> Union[Tuple[Any], None]: 1508 """ 1509 Return the positional arguments to pass to the target function. 1510 """ 1511 target_args = ( 1512 self.__dict__.get('_target_args', None) 1513 or self.properties.get('target', {}).get('args', None) 1514 ) 1515 if target_args is None: 1516 return tuple([]) 1517 1518 return tuple(target_args) 1519 1520 @property 1521 def target_kw(self) -> Union[Dict[str, Any], None]: 1522 """ 1523 Return the keyword arguments to pass to the target function. 1524 """ 1525 target_kw = ( 1526 self.__dict__.get('_target_kw', None) 1527 or self.properties.get('target', {}).get('kw', None) 1528 ) 1529 if target_kw is None: 1530 return {} 1531 1532 return {key: val for key, val in target_kw.items()} 1533 1534 @staticmethod 1535 def _get_target_reference(target) -> Union[Dict[str, str], None]: 1536 """ 1537 If `target` is an importable, top-level function (e.g. `entry`), return a 1538 ``{'module': ..., 'qualname': ...}`` reference so it can be re-imported in the 1539 daemon process instead of pickled by value. 1540 1541 Pickling such a target by value serializes its entire global graph, which can 1542 reach unpicklable live state (e.g. a connector caching an `_asyncio.Task`) and 1543 raise `TypeError: cannot pickle '_asyncio.Task' object`. dill also falls back to 1544 by-value pickling whenever it cannot match the function by identity — which 1545 happens when two copies of a package are importable (a stale install shadowing 1546 a venv), so `byref=True` alone is not enough. Returns `None` for closures, 1547 lambdas, and anything not importable, which fall back to dill. 1548 """ 1549 module = getattr(target, '__module__', None) 1550 qualname = getattr(target, '__qualname__', None) 1551 if not module or not qualname: 1552 return None 1553 if '<locals>' in qualname or '<lambda>' in qualname: 1554 return None 1555 return {'module': module, 'qualname': qualname} 1556 1557 @staticmethod 1558 def _load_target_reference(target_ref: Dict[str, str]): 1559 """ 1560 Re-import a target from a `{'module': ..., 'qualname': ...}` reference. 1561 """ 1562 import importlib 1563 obj = importlib.import_module(target_ref['module']) 1564 for part in target_ref['qualname'].split('.'): 1565 obj = getattr(obj, part) 1566 return obj 1567 1568 def __getstate__(self): 1569 """ 1570 Pickle this Daemon. 1571 """ 1572 dill = attempt_import('dill') 1573 state = { 1574 'target_args': self.target_args, 1575 'target_kw': self.target_kw, 1576 'daemon_id': self.daemon_id, 1577 'label': self.label, 1578 'properties': self.properties, 1579 } 1580 target_ref = self._get_target_reference(self.target) 1581 if target_ref is not None: 1582 ### Store by reference (re-imported in the daemon process), not by value. 1583 state['target'] = None 1584 state['target_ref'] = target_ref 1585 else: 1586 state['target'] = dill.dumps(self.target, byref=True) 1587 return state 1588 1589 def __setstate__(self, _state: Dict[str, Any]): 1590 """ 1591 Restore this Daemon from a pickled state. 1592 If the properties file exists, skip the old pickled version. 1593 """ 1594 dill = attempt_import('dill') 1595 target_ref = _state.pop('target_ref', None) 1596 if target_ref is not None and _state.get('target', None) is None: 1597 _state['target'] = self._load_target_reference(target_ref) 1598 else: 1599 _state['target'] = dill.loads(_state['target']) 1600 self._pickle = True 1601 daemon_id = _state.get('daemon_id', None) 1602 if not daemon_id: 1603 raise ValueError("Need a daemon_id to un-pickle a Daemon.") 1604 1605 properties_path = self._get_properties_path_from_daemon_id(daemon_id) 1606 ignore_properties = properties_path.exists() 1607 if ignore_properties: 1608 _state = { 1609 key: val 1610 for key, val in _state.items() 1611 if key != 'properties' 1612 } 1613 self.__init__(**_state) 1614 1615 1616 def __repr__(self): 1617 return str(self) 1618 1619 def __str__(self): 1620 return self.daemon_id 1621 1622 def __eq__(self, other): 1623 if not isinstance(other, Daemon): 1624 return False 1625 return self.daemon_id == other.daemon_id 1626 1627 def __hash__(self): 1628 return hash(self.daemon_id)
Daemonize Python functions into background processes.
Examples
>>> import meerschaum as mrsm
>>> from meerschaum.utils.daemons import Daemon
>>> daemon = Daemon(print, ('hi',))
>>> success, msg = daemon.run()
>>> print(daemon.log_text)
2024-07-29 18:03 | hi 2024-07-29 18:03 |
>>> daemon.run(allow_dirty_run=True)
>>> print(daemon.log_text)
2024-07-29 18:03 | hi 2024-07-29 18:03 | 2024-07-29 18:05 | hi 2024-07-29 18:05 |
>>> mrsm.pprint(daemon.properties)
{
'label': 'print',
'target': {'name': 'print', 'module': 'builtins', 'args': ['hi'], 'kw': {}},
'result': None,
'process': {'ended': '2024-07-29T18:03:33.752806'}
}
136 def __init__( 137 self, 138 target: Optional[Callable[[Any], Any]] = None, 139 target_args: Union[List[Any], Tuple[Any], None] = None, 140 target_kw: Optional[Dict[str, Any]] = None, 141 env: Optional[Dict[str, str]] = None, 142 daemon_id: Optional[str] = None, 143 label: Optional[str] = None, 144 properties: Optional[Dict[str, Any]] = None, 145 pickle: bool = True, 146 ): 147 """ 148 Parameters 149 ---------- 150 target: Optional[Callable[[Any], Any]], default None, 151 The function to execute in a child process. 152 153 target_args: Union[List[Any], Tuple[Any], None], default None 154 Positional arguments to pass to the target function. 155 156 target_kw: Optional[Dict[str, Any]], default None 157 Keyword arguments to pass to the target function. 158 159 env: Optional[Dict[str, str]], default None 160 If provided, set these environment variables in the daemon process. 161 162 daemon_id: Optional[str], default None 163 Build a `Daemon` from an existing `daemon_id`. 164 If `daemon_id` is provided, other arguments are ignored and are derived 165 from the existing pickled `Daemon`. 166 167 label: Optional[str], default None 168 Label string to help identifiy a daemon. 169 If `None`, use the function name instead. 170 171 properties: Optional[Dict[str, Any]], default None 172 Override reading from the properties JSON by providing an existing dictionary. 173 """ 174 _pickle = self.__dict__.get('_pickle', False) 175 if daemon_id is not None: 176 self.daemon_id = daemon_id 177 if not self.pickle_path.exists() and not target and ('target' not in self.__dict__): 178 179 if not self.properties_path.exists(): 180 raise Exception( 181 f"Daemon '{self.daemon_id}' does not exist. " 182 + "Pass a target to create a new Daemon." 183 ) 184 185 try: 186 new_daemon = self.from_properties_file(daemon_id) 187 except Exception: 188 new_daemon = None 189 190 if new_daemon is not None: 191 new_daemon.write_pickle() 192 target = new_daemon.target 193 target_args = new_daemon.target_args 194 target_kw = new_daemon.target_kw 195 label = new_daemon.label 196 self._properties = new_daemon.properties 197 else: 198 try: 199 self.properties_path.unlink() 200 except Exception: 201 pass 202 203 raise Exception( 204 f"Could not recover daemon '{self.daemon_id}' " 205 + "from its properties file." 206 ) 207 208 if 'target' not in self.__dict__: 209 if target is None: 210 error("Cannot create a Daemon without a target.") 211 self.target = target 212 213 self.pickle = pickle 214 215 ### NOTE: We have to check self.__dict__ in case we un-pickling. 216 if '_target_args' not in self.__dict__: 217 self._target_args = target_args 218 if '_target_kw' not in self.__dict__: 219 self._target_kw = target_kw 220 221 if 'label' not in self.__dict__: 222 if label is None: 223 label = ( 224 self.target.__name__ if '__name__' in self.target.__dir__() 225 else str(self.target) 226 ) 227 self.label = label 228 elif label is not None: 229 self.label = label 230 231 if 'daemon_id' not in self.__dict__: 232 self.daemon_id = get_new_daemon_name() 233 if '_properties' not in self.__dict__: 234 self._properties = properties 235 elif properties: 236 if self._properties is None: 237 self._properties = {} 238 self._properties.update(properties) 239 if self._properties is None: 240 self._properties = {} 241 242 self._properties.update({'label': self.label}) 243 if env: 244 self._properties.update({'env': env}) 245 246 ### Instantiate the process and if it doesn't exist, make sure the PID is removed. 247 _ = self.process
Parameters
- target (Optional[Callable[[Any], Any]], default None,): The function to execute in a child process.
- target_args (Union[List[Any], Tuple[Any], None], default None): Positional arguments to pass to the target function.
- target_kw (Optional[Dict[str, Any]], default None): Keyword arguments to pass to the target function.
- env (Optional[Dict[str, str]], default None): If provided, set these environment variables in the daemon process.
- daemon_id (Optional[str], default None):
Build a
Daemonfrom an existingdaemon_id. Ifdaemon_idis provided, other arguments are ignored and are derived from the existing pickledDaemon. - label (Optional[str], default None):
Label string to help identifiy a daemon.
If
None, use the function name instead. - properties (Optional[Dict[str, Any]], default None): Override reading from the properties JSON by providing an existing dictionary.
89 @classmethod 90 def from_properties_file(cls, daemon_id: str) -> Daemon: 91 """ 92 Return a Daemon from a properties dictionary. 93 """ 94 properties_path = cls._get_properties_path_from_daemon_id(daemon_id) 95 if not properties_path.exists(): 96 raise OSError(f"Properties file '{properties_path}' does not exist.") 97 98 try: 99 with open(properties_path, 'r', encoding='utf-8') as f: 100 properties = json.load(f) 101 except Exception: 102 properties = {} 103 104 if not properties: 105 raise ValueError(f"No properties could be read for daemon '{daemon_id}'.") 106 107 daemon_id = properties_path.parent.name 108 target_cf = properties.get('target', {}) 109 target_module_name = target_cf.get('module', None) 110 target_function_name = target_cf.get('name', None) 111 target_args = target_cf.get('args', None) 112 target_kw = target_cf.get('kw', None) 113 label = properties.get('label', None) 114 115 if None in [ 116 target_module_name, 117 target_function_name, 118 target_args, 119 target_kw, 120 ]: 121 raise ValueError("Missing target function information.") 122 123 target_module = importlib.import_module(target_module_name) 124 target_function = getattr(target_module, target_function_name) 125 126 return Daemon( 127 daemon_id=daemon_id, 128 target=target_function, 129 target_args=target_args, 130 target_kw=target_kw, 131 properties=properties, 132 label=label, 133 )
Return a Daemon from a properties dictionary.
431 def run( 432 self, 433 keep_daemon_output: bool = True, 434 allow_dirty_run: bool = False, 435 wait: bool = False, 436 timeout: Union[int, float] = 4, 437 debug: bool = False, 438 ) -> SuccessTuple: 439 """Run the daemon as a child process and continue executing the parent. 440 441 Parameters 442 ---------- 443 keep_daemon_output: bool, default True 444 If `False`, delete the daemon's output directory upon exiting. 445 446 allow_dirty_run: bool, default False 447 If `True`, run the daemon, even if the `daemon_id` directory exists. 448 This option is dangerous because if the same `daemon_id` runs concurrently, 449 the last to finish will overwrite the output of the first. 450 451 wait: bool, default True 452 If `True`, block until `Daemon.status` is running (or the timeout expires). 453 454 timeout: Union[int, float], default 4 455 If `wait` is `True`, block for up to `timeout` seconds before returning a failure. 456 457 Returns 458 ------- 459 A SuccessTuple indicating success. 460 461 """ 462 import platform 463 if platform.system() == 'Windows': 464 return False, "Cannot run background jobs on Windows." 465 466 ### The daemon might exist and be paused. 467 if self.status == 'paused': 468 return self.resume() 469 470 self._remove_stop_file() 471 if self.status == 'running': 472 return True, f"Daemon '{self}' is already running." 473 474 self.mkdir_if_not_exists(allow_dirty_run) 475 _write_pickle_success_tuple = self.write_pickle() 476 if not _write_pickle_success_tuple[0]: 477 return _write_pickle_success_tuple 478 479 _launch_daemon_code = ( 480 "from meerschaum.utils.daemon import Daemon, _daemons; " 481 f"daemon = Daemon(daemon_id='{self.daemon_id}'); " 482 f"_daemons['{self.daemon_id}'] = daemon; " 483 f"daemon._run_exit(keep_daemon_output={keep_daemon_output}, " 484 "allow_dirty_run=True)" 485 ) 486 env = dict(os.environ) 487 _launch_success_bool = venv_exec(_launch_daemon_code, debug=debug, venv=None, env=env) 488 msg = ( 489 "Success" 490 if _launch_success_bool 491 else f"Failed to start daemon '{self.daemon_id}'." 492 ) 493 if not wait or not _launch_success_bool: 494 return _launch_success_bool, msg 495 496 timeout = self.get_timeout_seconds(timeout) 497 check_timeout_interval = self.get_check_timeout_interval_seconds() 498 499 if not timeout: 500 success = self.status == 'running' 501 msg = "Success" if success else f"Failed to run daemon '{self.daemon_id}'." 502 if success: 503 self._capture_process_timestamp('began') 504 return success, msg 505 506 begin = time.perf_counter() 507 while (time.perf_counter() - begin) < timeout: 508 if self.status == 'running': 509 self._capture_process_timestamp('began') 510 return True, "Success" 511 time.sleep(check_timeout_interval) 512 513 return False, ( 514 f"Failed to start daemon '{self.daemon_id}' within {timeout} second" 515 + ('s' if timeout != 1 else '') + '.' 516 )
Run the daemon as a child process and continue executing the parent.
Parameters
- keep_daemon_output (bool, default True):
If
False, delete the daemon's output directory upon exiting. - allow_dirty_run (bool, default False):
If
True, run the daemon, even if thedaemon_iddirectory exists. This option is dangerous because if the samedaemon_idruns concurrently, the last to finish will overwrite the output of the first. - wait (bool, default True):
If
True, block untilDaemon.statusis running (or the timeout expires). - timeout (Union[int, float], default 4):
If
waitisTrue, block for up totimeoutseconds before returning a failure.
Returns
- A SuccessTuple indicating success.
519 def kill(self, timeout: Union[int, float, None] = 8) -> SuccessTuple: 520 """ 521 Forcibly terminate a running daemon. 522 Sends a SIGTERM signal to the process. 523 524 Parameters 525 ---------- 526 timeout: Optional[int], default 3 527 How many seconds to wait for the process to terminate. 528 529 Returns 530 ------- 531 A SuccessTuple indicating success. 532 """ 533 ### A lost PID file means a detached/orphaned daemon — possibly SEVERAL processes 534 ### sharing this daemon_id (e.g. accumulated across crashed restarts). Reap them 535 ### ALL by daemon_id rather than reporting a false "stopped" and stranding them 536 ### (which forces a manual `pkill meerschaum` or hunting the daemon_id in htop). 537 if not self.pid_path.exists(): 538 reaped = self._kill_detached_processes(timeout) 539 self._write_stop_file('kill') 540 self.stdin_file.close() 541 self._remove_blocking_stdin_file() 542 return True, ( 543 (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.') 544 if reaped 545 else "Process has already stopped." 546 ) 547 548 if self.status != 'paused': 549 success, msg = self._send_signal(signal.SIGTERM, timeout=timeout) 550 if success: 551 ### Sweep any sibling/detached processes left over for this daemon_id. 552 self._kill_detached_processes(timeout) 553 self._write_stop_file('kill') 554 self.stdin_file.close() 555 self._remove_blocking_stdin_file() 556 return success, msg 557 558 if self.status == 'stopped': 559 reaped = self._kill_detached_processes(timeout) 560 self._write_stop_file('kill') 561 self.stdin_file.close() 562 self._remove_blocking_stdin_file() 563 return True, ( 564 (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.') 565 if reaped 566 else "Process has already stopped." 567 ) 568 569 psutil = attempt_import('psutil') 570 process = self.process 571 try: 572 process.terminate() 573 process.kill() 574 process.wait(timeout=timeout) 575 except Exception as e: 576 return False, f"Failed to kill job {self} ({process}) with exception: {e}" 577 578 try: 579 if process.status(): 580 return False, "Failed to stop daemon '{self}' ({process})." 581 except psutil.NoSuchProcess: 582 pass 583 584 if self.pid_path.exists(): 585 try: 586 self.pid_path.unlink() 587 except Exception: 588 pass 589 590 self._write_stop_file('kill') 591 self.stdin_file.close() 592 self._remove_blocking_stdin_file() 593 return True, "Success"
Forcibly terminate a running daemon. Sends a SIGTERM signal to the process.
Parameters
- timeout (Optional[int], default 3): How many seconds to wait for the process to terminate.
Returns
- A SuccessTuple indicating success.
595 def quit(self, timeout: Union[int, float, None] = None) -> SuccessTuple: 596 """Gracefully quit a running daemon.""" 597 if self.status == 'paused': 598 return self.kill(timeout) 599 600 ### A lost PID file means a detached/orphaned daemon (possibly several processes); 601 ### the normal signal path can't see them, so reap them all by daemon_id instead 602 ### of returning a false "not running". 603 if not self.pid_path.exists(): 604 reaped = self._kill_detached_processes(timeout) 605 self._write_stop_file('quit') 606 self.stdin_file.close() 607 self._remove_blocking_stdin_file() 608 return True, ( 609 (f"Reaped {reaped} detached process" + ('es' if reaped != 1 else '') + '.') 610 if reaped 611 else "Process is not running." 612 ) 613 614 signal_success, signal_msg = self._send_signal(signal.SIGINT, timeout=timeout) 615 if signal_success: 616 self._write_stop_file('quit') 617 self.stdin_file.close() 618 self._remove_blocking_stdin_file() 619 return signal_success, signal_msg
Gracefully quit a running daemon.
621 def pause( 622 self, 623 timeout: Union[int, float, None] = None, 624 check_timeout_interval: Union[float, int, None] = None, 625 ) -> SuccessTuple: 626 """ 627 Pause the daemon if it is running. 628 629 Parameters 630 ---------- 631 timeout: Union[float, int, None], default None 632 The maximum number of seconds to wait for a process to suspend. 633 634 check_timeout_interval: Union[float, int, None], default None 635 The number of seconds to wait between checking if the process is still running. 636 637 Returns 638 ------- 639 A `SuccessTuple` indicating whether the `Daemon` process was successfully suspended. 640 """ 641 self._remove_blocking_stdin_file() 642 643 if self.process is None: 644 return False, f"Daemon '{self.daemon_id}' is not running and cannot be paused." 645 646 if self.status == 'paused': 647 return True, f"Daemon '{self.daemon_id}' is already paused." 648 649 self._write_stop_file('pause') 650 self.stdin_file.close() 651 self._remove_blocking_stdin_file() 652 try: 653 self.process.suspend() 654 except Exception as e: 655 return False, f"Failed to pause daemon '{self.daemon_id}':\n{e}" 656 657 timeout = self.get_timeout_seconds(timeout) 658 check_timeout_interval = self.get_check_timeout_interval_seconds( 659 check_timeout_interval 660 ) 661 662 psutil = attempt_import('psutil') 663 664 if not timeout: 665 try: 666 success = self.process.status() == 'stopped' 667 except psutil.NoSuchProcess: 668 success = True 669 msg = "Success" if success else f"Failed to suspend daemon '{self.daemon_id}'." 670 if success: 671 self._capture_process_timestamp('paused') 672 return success, msg 673 674 begin = time.perf_counter() 675 while (time.perf_counter() - begin) < timeout: 676 try: 677 if self.process.status() == 'stopped': 678 self._capture_process_timestamp('paused') 679 return True, "Success" 680 except psutil.NoSuchProcess as e: 681 return False, f"Process exited unexpectedly. Was it killed?\n{e}" 682 time.sleep(check_timeout_interval) 683 684 return False, ( 685 f"Failed to pause daemon '{self.daemon_id}' within {timeout} second" 686 + ('s' if timeout != 1 else '') + '.' 687 )
Pause the daemon if it is running.
Parameters
- timeout (Union[float, int, None], default None): The maximum number of seconds to wait for a process to suspend.
- check_timeout_interval (Union[float, int, None], default None): The number of seconds to wait between checking if the process is still running.
Returns
- A
SuccessTupleindicating whether theDaemonprocess was successfully suspended.
689 def resume( 690 self, 691 timeout: Union[int, float, None] = None, 692 check_timeout_interval: Union[float, int, None] = None, 693 ) -> SuccessTuple: 694 """ 695 Resume the daemon if it is paused. 696 697 Parameters 698 ---------- 699 timeout: Union[float, int, None], default None 700 The maximum number of seconds to wait for a process to resume. 701 702 check_timeout_interval: Union[float, int, None], default None 703 The number of seconds to wait between checking if the process is still stopped. 704 705 Returns 706 ------- 707 A `SuccessTuple` indicating whether the `Daemon` process was successfully resumed. 708 """ 709 if self.status == 'running': 710 return True, f"Daemon '{self.daemon_id}' is already running." 711 712 if self.status == 'stopped': 713 return False, f"Daemon '{self.daemon_id}' is stopped and cannot be resumed." 714 715 self._remove_stop_file() 716 try: 717 if self.process is None: 718 return False, f"Cannot resume daemon '{self.daemon_id}'." 719 720 self.process.resume() 721 except Exception as e: 722 return False, f"Failed to resume daemon '{self.daemon_id}':\n{e}" 723 724 timeout = self.get_timeout_seconds(timeout) 725 check_timeout_interval = self.get_check_timeout_interval_seconds( 726 check_timeout_interval 727 ) 728 729 if not timeout: 730 success = self.status == 'running' 731 msg = "Success" if success else f"Failed to resume daemon '{self.daemon_id}'." 732 if success: 733 self._capture_process_timestamp('began') 734 return success, msg 735 736 begin = time.perf_counter() 737 while (time.perf_counter() - begin) < timeout: 738 if self.status == 'running': 739 self._capture_process_timestamp('began') 740 return True, "Success" 741 time.sleep(check_timeout_interval) 742 743 return False, ( 744 f"Failed to resume daemon '{self.daemon_id}' within {timeout} second" 745 + ('s' if timeout != 1 else '') + '.' 746 )
Resume the daemon if it is paused.
Parameters
- timeout (Union[float, int, None], default None): The maximum number of seconds to wait for a process to resume.
- check_timeout_interval (Union[float, int, None], default None): The number of seconds to wait between checking if the process is still stopped.
Returns
- A
SuccessTupleindicating whether theDaemonprocess was successfully resumed.
947 def mkdir_if_not_exists(self, allow_dirty_run: bool = False): 948 """Create the Daemon's directory. 949 If `allow_dirty_run` is `False` and the directory already exists, 950 raise a `FileExistsError`. 951 """ 952 try: 953 self.path.mkdir(parents=True, exist_ok=True) 954 _already_exists = any(os.scandir(self.path)) 955 except FileExistsError: 956 _already_exists = True 957 958 if _already_exists and not allow_dirty_run: 959 error( 960 f"Daemon '{self.daemon_id}' already exists. " + 961 "To allow this daemon to run, do one of the following:\n" 962 + " - Execute `daemon.cleanup()`.\n" 963 + f" - Delete the directory '{self.path}'.\n" 964 + " - Pass `allow_dirty_run=True` to `daemon.run()`.\n", 965 FileExistsError, 966 )
Create the Daemon's directory.
If allow_dirty_run is False and the directory already exists,
raise a FileExistsError.
968 @property 969 def process(self) -> Union['psutil.Process', None]: 970 """ 971 Return the psutil process for the Daemon. 972 """ 973 psutil = attempt_import('psutil') 974 pid = self.pid 975 if pid is None: 976 return None 977 if '_process' not in self.__dict__ or self.__dict__['_process'].pid != int(pid): 978 try: 979 self._process = psutil.Process(int(pid)) 980 process_exists = True 981 except Exception: 982 process_exists = False 983 if not process_exists: 984 _ = self.__dict__.pop('_process', None) 985 try: 986 if self.pid_path.exists(): 987 self.pid_path.unlink() 988 except Exception: 989 pass 990 return None 991 return self._process
Return the psutil process for the Daemon.
993 @property 994 def status(self) -> str: 995 """ 996 Return the running status of this Daemon. 997 """ 998 if self.process is None: 999 return 'stopped' 1000 1001 psutil = attempt_import('psutil', lazy=False) 1002 try: 1003 if self.process.status() == 'stopped': 1004 return 'paused' 1005 if self.process.status() == 'zombie': 1006 raise psutil.NoSuchProcess(self.process.pid) 1007 except (psutil.NoSuchProcess, AttributeError): 1008 if self.pid_path.exists(): 1009 try: 1010 self.pid_path.unlink() 1011 except Exception: 1012 pass 1013 return 'stopped' 1014 1015 return 'running'
Return the running status of this Daemon.
1025 @property 1026 def path(self) -> pathlib.Path: 1027 """ 1028 Return the path for this Daemon's directory. 1029 """ 1030 return self._get_path_from_daemon_id(self.daemon_id)
Return the path for this Daemon's directory.
1039 @property 1040 def properties_path(self) -> pathlib.Path: 1041 """ 1042 Return the `propterties.json` path for this Daemon. 1043 """ 1044 return self._get_properties_path_from_daemon_id(self.daemon_id)
Return the propterties.json path for this Daemon.
1046 @property 1047 def stop_path(self) -> pathlib.Path: 1048 """ 1049 Return the path for the stop file (created when manually stopped). 1050 """ 1051 return self.path / '.stop.json'
Return the path for the stop file (created when manually stopped).
1053 @property 1054 def log_path(self) -> pathlib.Path: 1055 """ 1056 Return the log path. 1057 """ 1058 logs_cf = self.properties.get('logs', None) or {} 1059 if 'path' not in logs_cf: 1060 import meerschaum.config.paths as paths 1061 return paths.LOGS_RESOURCES_PATH / (self.daemon_id + '.log') 1062 1063 return pathlib.Path(logs_cf['path'])
Return the log path.
1065 @property 1066 def stdin_file_path(self) -> pathlib.Path: 1067 """ 1068 Return the stdin file path. 1069 """ 1070 return self.path / 'input.stdin'
Return the stdin file path.
1072 @property 1073 def blocking_stdin_file_path(self) -> pathlib.Path: 1074 """ 1075 Return the stdin file path. 1076 """ 1077 if '_blocking_stdin_file_path' in self.__dict__: 1078 return self._blocking_stdin_file_path 1079 1080 return self.path / 'input.stdin.block'
Return the stdin file path.
1082 @property 1083 def prompt_kwargs_file_path(self) -> pathlib.Path: 1084 """ 1085 Return the file path to the kwargs for the invoking `prompt()`. 1086 """ 1087 return self.path / 'prompt_kwargs.json'
Return the file path to the kwargs for the invoking prompt().
1089 @property 1090 def log_offset_path(self) -> pathlib.Path: 1091 """ 1092 Return the log offset file path. 1093 """ 1094 import meerschaum.config.paths as paths 1095 return paths.LOGS_RESOURCES_PATH / ('.' + self.daemon_id + '.log.offset')
Return the log offset file path.
1097 @property 1098 def log_offset_lock(self) -> 'InterProcessLock': 1099 """ 1100 Return the process lock context manager. 1101 """ 1102 if '_log_offset_lock' in self.__dict__: 1103 return self._log_offset_lock 1104 1105 from meerschaum.utils.locks import InterProcessLock 1106 self._log_offset_lock = InterProcessLock(self.log_offset_path) 1107 return self._log_offset_lock
Return the process lock context manager.
1109 @property 1110 def rotating_log(self) -> RotatingFile: 1111 """ 1112 The rotating log file for the daemon's output. 1113 """ 1114 if '_rotating_log' in self.__dict__: 1115 return self._rotating_log 1116 1117 logs_cf = self.properties.get('logs', None) or {} 1118 write_timestamps = logs_cf.get('write_timestamps', None) 1119 if write_timestamps is None: 1120 write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled') 1121 1122 timestamp_format = logs_cf.get('timestamp_format', None) 1123 if timestamp_format is None: 1124 timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format') 1125 1126 num_files_to_keep = logs_cf.get('num_files_to_keep', None) 1127 if num_files_to_keep is None: 1128 num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep') 1129 1130 max_file_size = logs_cf.get('max_file_size', None) 1131 if max_file_size is None: 1132 max_file_size = get_config('jobs', 'logs', 'max_file_size') 1133 1134 redirect_streams = logs_cf.get('redirect_streams', True) 1135 1136 self._rotating_log = RotatingFile( 1137 self.log_path, 1138 redirect_streams=redirect_streams, 1139 write_timestamps=write_timestamps, 1140 timestamp_format=timestamp_format, 1141 num_files_to_keep=num_files_to_keep, 1142 max_file_size=max_file_size, 1143 ) 1144 return self._rotating_log
The rotating log file for the daemon's output.
1146 @property 1147 def stdin_file(self): 1148 """ 1149 Return the file handler for the stdin file. 1150 """ 1151 if (_stdin_file := self.__dict__.get('_stdin_file', None)): 1152 return _stdin_file 1153 1154 self._stdin_file = StdinFile( 1155 self.stdin_file_path, 1156 lock_file_path=self.blocking_stdin_file_path, 1157 ) 1158 return self._stdin_file
Return the file handler for the stdin file.
1160 @property 1161 def log_text(self) -> Union[str, None]: 1162 """ 1163 Read the log files and return their contents. 1164 Returns `None` if the log file does not exist. 1165 """ 1166 logs_cf = self.properties.get('logs', None) or {} 1167 write_timestamps = logs_cf.get('write_timestamps', None) 1168 if write_timestamps is None: 1169 write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled') 1170 1171 timestamp_format = logs_cf.get('timestamp_format', None) 1172 if timestamp_format is None: 1173 timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format') 1174 1175 num_files_to_keep = logs_cf.get('num_files_to_keep', None) 1176 if num_files_to_keep is None: 1177 num_files_to_keep = get_config('jobs', 'logs', 'num_files_to_keep') 1178 1179 max_file_size = logs_cf.get('max_file_size', None) 1180 if max_file_size is None: 1181 max_file_size = get_config('jobs', 'logs', 'max_file_size') 1182 1183 new_rotating_log = RotatingFile( 1184 self.rotating_log.file_path, 1185 num_files_to_keep=num_files_to_keep, 1186 max_file_size=max_file_size, 1187 write_timestamps=write_timestamps, 1188 timestamp_format=timestamp_format, 1189 ) 1190 return new_rotating_log.read()
Read the log files and return their contents.
Returns None if the log file does not exist.
1192 def readlines(self) -> List[str]: 1193 """ 1194 Read the next log lines, persisting the cursor for later use. 1195 Note this will alter the cursor of `self.rotating_log`. 1196 """ 1197 self.rotating_log._cursor = self._read_log_offset() 1198 lines = self.rotating_log.readlines() 1199 self._write_log_offset() 1200 return lines
Read the next log lines, persisting the cursor for later use.
Note this will alter the cursor of self.rotating_log.
1233 @property 1234 def pid(self) -> Union[int, None]: 1235 """ 1236 Read the PID file and return its contents. 1237 Returns `None` if the PID file does not exist. 1238 """ 1239 if not self.pid_path.exists(): 1240 ### The PID file can be lost while a detached daemon keeps running (e.g. the 1241 ### launcher exited without cleanup). Recover the PID by finding the process 1242 ### whose command line embeds this daemon_id, so `status`/`stop`/`kill` see the 1243 ### live process instead of reporting a false "stopped" (which would strand the 1244 ### orphan, forcing a manual `pkill meerschaum`). 1245 detached_pids = self._find_detached_pids() 1246 return detached_pids[0] if detached_pids else None 1247 try: 1248 with open(self.pid_path, 'r', encoding='utf-8') as f: 1249 text = f.read() 1250 if len(text) == 0: 1251 return None 1252 pid = int(text.rstrip()) 1253 except Exception as e: 1254 warn(e) 1255 text = None 1256 pid = None 1257 return pid
Read the PID file and return its contents.
Returns None if the PID file does not exist.
1259 @property 1260 def pid_path(self) -> pathlib.Path: 1261 """ 1262 Return the path to a file containing the PID for this Daemon. 1263 """ 1264 return self.path / 'process.pid'
Return the path to a file containing the PID for this Daemon.
1266 @property 1267 def pid_lock(self) -> 'InterProcessLock': 1268 """ 1269 Return the process lock context manager. 1270 """ 1271 if '_pid_lock' in self.__dict__: 1272 return self._pid_lock 1273 1274 from meerschaum.utils.locks import InterProcessLock 1275 self._pid_lock = InterProcessLock(self.pid_path) 1276 return self._pid_lock
Return the process lock context manager.
1278 @property 1279 def pickle_path(self) -> pathlib.Path: 1280 """ 1281 Return the path for the pickle file. 1282 """ 1283 return self.path / 'pickle.pkl'
Return the path for the pickle file.
1285 def read_properties(self) -> Optional[Dict[str, Any]]: 1286 """Read the properties JSON file and return the dictionary.""" 1287 if not self.properties_path.exists(): 1288 return None 1289 try: 1290 with open(self.properties_path, 'r', encoding='utf-8') as file: 1291 properties = json.load(file) 1292 except Exception: 1293 properties = {} 1294 1295 return properties or {}
Read the properties JSON file and return the dictionary.
1297 def read_pickle(self) -> Daemon: 1298 """Read a Daemon's pickle file and return the `Daemon`.""" 1299 import pickle 1300 import traceback 1301 if not self.pickle_path.exists(): 1302 error(f"Pickle file does not exist for daemon '{self.daemon_id}'.") 1303 1304 if self.pickle_path.stat().st_size == 0: 1305 error(f"Pickle was empty for daemon '{self.daemon_id}'.") 1306 1307 try: 1308 with open(self.pickle_path, 'rb') as pickle_file: 1309 daemon = pickle.load(pickle_file) 1310 success, msg = True, 'Success' 1311 except Exception as e: 1312 success, msg = False, str(e) 1313 daemon = None 1314 traceback.print_exception(type(e), e, e.__traceback__) 1315 if not success: 1316 error(msg) 1317 return daemon
Read a Daemon's pickle file and return the Daemon.
1319 @property 1320 def properties(self) -> Dict[str, Any]: 1321 """ 1322 Return the contents of the properties JSON file. 1323 """ 1324 try: 1325 _file_properties = self.read_properties() or {} 1326 except Exception: 1327 traceback.print_exc() 1328 _file_properties = {} 1329 1330 if not self._properties: 1331 self._properties = _file_properties 1332 1333 if self._properties is None: 1334 self._properties = {} 1335 1336 if ( 1337 self._properties.get('result', None) is None 1338 and _file_properties.get('result', None) is not None 1339 ): 1340 _ = self._properties.pop('result', None) 1341 1342 if _file_properties is not None: 1343 self._properties = apply_patch_to_config( 1344 _file_properties, 1345 self._properties, 1346 ) 1347 1348 return self._properties
Return the contents of the properties JSON file.
1357 def write_properties(self) -> SuccessTuple: 1358 """Write the properties dictionary to the properties JSON file 1359 (only if self.properties exists). 1360 """ 1361 from meerschaum.utils.misc import generate_password 1362 success, msg = ( 1363 False, 1364 f"No properties to write for daemon '{self.daemon_id}'." 1365 ) 1366 backup_path = self.properties_path.parent / (generate_password(8) + '.json') 1367 props = self.properties 1368 if props is not None: 1369 try: 1370 self.path.mkdir(parents=True, exist_ok=True) 1371 if self.properties_path.exists(): 1372 self.properties_path.rename(backup_path) 1373 with open(self.properties_path, 'w+', encoding='utf-8') as properties_file: 1374 json.dump(props, properties_file) 1375 success, msg = True, 'Success' 1376 except Exception as e: 1377 success, msg = False, str(e) 1378 1379 try: 1380 if backup_path.exists(): 1381 if not success: 1382 backup_path.rename(self.properties_path) 1383 else: 1384 backup_path.unlink() 1385 except Exception as e: 1386 success, msg = False, str(e) 1387 1388 return success, msg
Write the properties dictionary to the properties JSON file (only if self.properties exists).
1390 def write_pickle(self) -> SuccessTuple: 1391 """Write the pickle file for the daemon.""" 1392 import pickle 1393 import traceback 1394 from meerschaum.utils.misc import generate_password 1395 1396 if not self.pickle: 1397 return True, "Success" 1398 1399 from meerschaum._internal.entry import _shells 1400 if _shells: 1401 from meerschaum._internal.shell.Shell import revert_input 1402 revert_input() 1403 1404 backup_path = self.pickle_path.parent / (generate_password(7) + '.pkl') 1405 try: 1406 self.path.mkdir(parents=True, exist_ok=True) 1407 if self.pickle_path.exists(): 1408 self.pickle_path.rename(backup_path) 1409 with open(self.pickle_path, 'wb+') as pickle_file: 1410 pickle.dump(self, pickle_file) 1411 success, msg = True, "Success" 1412 except Exception as e: 1413 success, msg = False, str(e) 1414 traceback.print_exception(type(e), e, e.__traceback__) 1415 try: 1416 if backup_path.exists(): 1417 if not success: 1418 backup_path.rename(self.pickle_path) 1419 else: 1420 backup_path.unlink() 1421 except Exception as e: 1422 success, msg = False, str(e) 1423 return success, msg
Write the pickle file for the daemon.
1453 def cleanup(self, keep_logs: bool = False) -> SuccessTuple: 1454 """ 1455 Remove a daemon's directory after execution. 1456 1457 Parameters 1458 ---------- 1459 keep_logs: bool, default False 1460 If `True`, skip deleting the daemon's log files. 1461 1462 Returns 1463 ------- 1464 A `SuccessTuple` indicating success. 1465 """ 1466 if self.path.exists(): 1467 try: 1468 shutil.rmtree(self.path) 1469 except Exception as e: 1470 msg = f"Failed to clean up '{self.daemon_id}':\n{e}" 1471 warn(msg) 1472 return False, msg 1473 if not keep_logs: 1474 self.rotating_log.delete() 1475 try: 1476 if self.log_offset_path.exists(): 1477 self.log_offset_path.unlink() 1478 except Exception as e: 1479 msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}" 1480 warn(msg) 1481 return False, msg 1482 return True, "Success"
Remove a daemon's directory after execution.
Parameters
- keep_logs (bool, default False):
If
True, skip deleting the daemon's log files.
Returns
- A
SuccessTupleindicating success.
1485 def get_timeout_seconds(self, timeout: Union[int, float, None] = None) -> Union[int, float]: 1486 """ 1487 Return the timeout value to use. Use `--timeout-seconds` if provided, 1488 else the configured default (8). 1489 """ 1490 if isinstance(timeout, (int, float)): 1491 return timeout 1492 return get_config('jobs', 'timeout_seconds')
Return the timeout value to use. Use --timeout-seconds if provided,
else the configured default (8).
1495 def get_check_timeout_interval_seconds( 1496 self, 1497 check_timeout_interval: Union[int, float, None] = None, 1498 ) -> Union[int, float]: 1499 """ 1500 Return the interval value to check the status of timeouts. 1501 """ 1502 if isinstance(check_timeout_interval, (int, float)): 1503 return check_timeout_interval 1504 return get_config('jobs', 'check_timeout_interval_seconds')
Return the interval value to check the status of timeouts.
1506 @property 1507 def target_args(self) -> Union[Tuple[Any], None]: 1508 """ 1509 Return the positional arguments to pass to the target function. 1510 """ 1511 target_args = ( 1512 self.__dict__.get('_target_args', None) 1513 or self.properties.get('target', {}).get('args', None) 1514 ) 1515 if target_args is None: 1516 return tuple([]) 1517 1518 return tuple(target_args)
Return the positional arguments to pass to the target function.
1520 @property 1521 def target_kw(self) -> Union[Dict[str, Any], None]: 1522 """ 1523 Return the keyword arguments to pass to the target function. 1524 """ 1525 target_kw = ( 1526 self.__dict__.get('_target_kw', None) 1527 or self.properties.get('target', {}).get('kw', None) 1528 ) 1529 if target_kw is None: 1530 return {} 1531 1532 return {key: val for key, val in target_kw.items()}
Return the keyword arguments to pass to the target function.