Mini Shell
"""Classes for restic subprocesses"""
import logging
import threading
import os
import time
import signal
import shlex
from typing import TYPE_CHECKING, Union, List
from subprocess import Popen, PIPE, CalledProcessError, CompletedProcess
import psutil
from .errors import ResticError
if TYPE_CHECKING:
from .base import Restic
# since the processes we're pausing use network connections, they need to be
# intermittently resumed and stopped again to keep their sessions alive
SLEEP_SECS = 8 # how long to pause a process when load is high
RUN_SECS = 1 # how long to run before pausing again
POLL_INTERVAL = 1 # how often to check load and pausing logic
def join_cmd(split_command):
"""Return a shell-escaped string from *split_command*."""
# copied from Python3.9's shlex.join
return ' '.join(shlex.quote(arg) for arg in split_command)
class ProcMon(threading.Thread):
"""Thread that monitors server load and pauses/resumes a subprocess"""
def __init__(self, proc: Popen, limit: float):
super().__init__(target=self._mainloop, daemon=True)
self.limit = limit
self.proc = proc
# last_time is the time last_state was changed
self.last_time = time.time()
# last_state holds the last state change made to the process.
# This is not necessarily the current state.
self.last_state = psutil.STATUS_RUNNING
self.start()
def _mainloop(self) -> None:
"""Runs and monitors load until the process ends"""
while self.proc.returncode is None:
time.sleep(POLL_INTERVAL)
try:
self._poll_load()
except (OSError, psutil.NoSuchProcess):
return # process ended
def _change_state(self, state: str, signum: int) -> None:
"""Send a signal to the process and update last_state/last_time"""
self.proc.send_signal(signum)
self.last_state = state
self.last_time = time.time()
def _poll_load(self) -> None:
"""Repeatedly runs to pause/resume the process"""
# if load is not too high
if self.limit > os.getloadavg()[0]:
# if the process is currently paused
if psutil.Process(self.proc.pid).status() == psutil.STATUS_STOPPED:
self._change_state(psutil.STATUS_RUNNING, signal.SIGCONT)
return
# if we reach here, load is high
secs = time.time() - self.last_time # since last state change
# if we recently sent kill -STOP
if self.last_state == psutil.STATUS_STOPPED:
# if it's been sleeping as long as allowed
if secs >= SLEEP_SECS:
self._change_state(psutil.STATUS_RUNNING, signal.SIGCONT)
return
# It can sleep longer. If not already sleeping (sometimes D-state
# eats SIGSTOP) then send SIGSTOP again, but don't update last_time
if psutil.Process(self.proc.pid).status() != psutil.STATUS_STOPPED:
self.proc.send_signal(signal.SIGSTOP)
elif secs >= RUN_SECS:
# self.last_state == psutil.STATUS_RUNNING and it's been running
# at high load as long as allowed
self._change_state(psutil.STATUS_STOPPED, signal.SIGSTOP)
class ResticProc(Popen):
"""subprocess.Popen subclass for use running restic"""
def __init__(
self,
args,
*,
restic: 'Restic',
limit: int,
encoding='utf-8',
stdout=PIPE,
stderr=PIPE,
**kwargs,
):
super().__init__(
args,
stdout=stdout,
stderr=stderr,
shell=False,
encoding=encoding,
**kwargs,
)
logging.debug('%d: %s', self.pid, join_cmd(args))
self.restic = restic
self.mon = ProcMon(self, limit) if limit else None
def complete(
self,
*,
check: bool,
ok_codes: Union[List[int], None] = None,
timeout: Union[float, None] = None,
) -> CompletedProcess:
"""Wait for the process to complete
Args:
check (bool): if True, raise ResticError on error status codes
ok_codes (list[int], optional): defines exit codes considered
suceessful. Defaults to [0]
timeout (float, optional): if set, kill the process and raise an
error if it takes longer than this in seconds. Defaults to None
Raises:
ResticError: if check=True and exit code was not in ok_codes
subprocess.TimeoutExpired: if a timeout was set and exceeded
Returns:
subprocess.CompletedProcess: process result
"""
if ok_codes is None:
ok_codes = [0]
try:
stdout, stderr = self.communicate(timeout=timeout)
except: # Includes KeyboardInterrupt; communicate handled that
self.kill()
self.wait()
raise
# communicate() already called self.wait()
if check and self.returncode not in ok_codes:
exc = CalledProcessError(
self.returncode, self.args, output=stdout, stderr=stderr
)
raise ResticError(exc, self.restic)
return CompletedProcess(self.args, self.returncode, stdout, stderr)
Zerion Mini Shell 1.0