refactor(companion): Spell out abbreviated identifiers

No behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tanmoy Sarkar 2026-06-09 22:03:13 +05:30
parent 5db503295c
commit 9f3762d6b6
6 changed files with 321 additions and 317 deletions

View File

@ -25,19 +25,19 @@ def decode_command(line):
JSON object carrying a string ``cmd``; anything else is a ``CommandError``. JSON object carrying a string ``cmd``; anything else is a ``CommandError``.
""" """
try: try:
obj = json.loads(line) command = json.loads(line)
except (ValueError, TypeError): except (ValueError, TypeError):
raise CommandError("invalid JSON") raise CommandError("invalid JSON")
if not isinstance(obj, dict): if not isinstance(command, dict):
raise CommandError("request must be a JSON object") raise CommandError("request must be a JSON object")
if not isinstance(obj.get("cmd"), str): if not isinstance(command.get("cmd"), str):
raise CommandError("missing 'cmd'") raise CommandError("missing 'cmd'")
return obj return command
def encode_response(obj): def encode_response(response):
"""Encode a response dict as one newline-terminated JSON line of bytes.""" """Encode a response dict as one newline-terminated JSON line of bytes."""
return (json.dumps(obj) + "\n").encode("utf-8") return (json.dumps(response) + "\n").encode("utf-8")
class ControlServer: class ControlServer:
@ -58,7 +58,7 @@ class ControlServer:
self.mode = mode self.mode = mode
self.log = log self.log = log
self.backlog = backlog self.backlog = backlog
self.sock = None self.listener = None
def create(self): def create(self):
"""Bind and listen on the Unix socket, replacing any stale one. """Bind and listen on the Unix socket, replacing any stale one.
@ -69,18 +69,18 @@ class ControlServer:
""" """
if os.path.exists(self.path): if os.path.exists(self.path):
os.unlink(self.path) os.unlink(self.path)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(self.path) listener.bind(self.path)
os.chmod(self.path, self.mode) os.chmod(self.path, self.mode)
sock.listen(self.backlog) listener.listen(self.backlog)
self.sock = sock self.listener = listener
return sock return listener
def close(self): def close(self):
"""Close the listening socket and remove its file.""" """Close the listening socket and remove its file."""
if self.sock is not None: if self.listener is not None:
self.sock.close() self.listener.close()
self.sock = None self.listener = None
if os.path.exists(self.path): if os.path.exists(self.path):
os.unlink(self.path) os.unlink(self.path)
@ -93,25 +93,25 @@ class ControlServer:
""" """
try: try:
response = self.dispatch(decode_command(line)) response = self.dispatch(decode_command(line))
except CommandError as e: except CommandError as error:
response = {"ok": False, "error": str(e)} response = {"ok": False, "error": str(error)}
return encode_response(response) return encode_response(response)
def serve_connection(self, conn): def serve_connection(self, connection):
"""Serve newline-delimited requests on one accepted connection. """Serve newline-delimited requests on one accepted connection.
Reads until the client hangs up, buffering partial reads and answering Reads until the client hangs up, buffering partial reads and answering
each complete line as it arrives. A trailing fragment without a newline each complete line as it arrives. A trailing fragment without a newline
is ignored. is ignored.
""" """
buf = b"" buffer = b""
with conn: with connection:
while True: while True:
chunk = conn.recv(65536) chunk = connection.recv(65536)
if not chunk: if not chunk:
break break
buf += chunk buffer += chunk
while b"\n" in buf: while b"\n" in buffer:
line, buf = buf.split(b"\n", 1) line, buffer = buffer.split(b"\n", 1)
if line.strip(): if line.strip():
conn.sendall(self.handle_line(line)) connection.sendall(self.handle_line(line))

View File

@ -35,7 +35,7 @@ class CompanionManager:
# validates companion config, returning a fresh CompanionConfig list. # validates companion config, returning a fresh CompanionConfig list.
self.config_loader = None self.config_loader = None
def handle_command(self, obj: dict) -> dict: def handle_command(self, command: dict) -> dict:
"""Route a decoded control command to its action. """Route a decoded control command to its action.
This is the ``dispatch`` the control socket calls. ``status`` returns a This is the ``dispatch`` the control socket calls. ``status`` returns a
@ -44,37 +44,37 @@ class CompanionManager:
commands need a string ``name``, and anything else raises ``CommandError`` so the commands need a string ``name``, and anything else raises ``CommandError`` so the
socket replies with an error envelope. socket replies with an error envelope.
""" """
cmd = obj["cmd"] command_name = command["cmd"]
if cmd == "status": if command_name == "status":
return {"ok": True, "companions": self.status()} return {"ok": True, "companions": self.status()}
if cmd == "reread": if command_name == "reread":
if self.config_loader is None: if self.config_loader is None:
raise CommandError("reread not configured") raise CommandError("reread not configured")
try: try:
new_configs = self.config_loader() new_configs = self.config_loader()
except Exception as e: except Exception as error:
return {"ok": False, "error": "invalid config: %s" % e, return {"ok": False, "error": "invalid config: %s" % error,
"kept_old_config": True} "kept_old_config": True}
return self.reread_config(new_configs) return self.reread_config(new_configs)
# Every remaining command acts on one named companion. # Every remaining command acts on one named companion.
name = obj.get("name") name = command.get("name")
if not isinstance(name, str): if not isinstance(name, str):
raise CommandError("'%s' requires a 'name'" % cmd) raise CommandError("'%s' requires a 'name'" % command_name)
if cmd == "start": if command_name == "start":
ok, message = self.start_process(name) ok, message = self.start_process(name)
elif cmd == "stop": elif command_name == "stop":
ok, message = self.stop_process(name) ok, message = self.stop_process(name)
elif cmd == "restart": elif command_name == "restart":
ok, message = self.restart_process(name) ok, message = self.restart_process(name)
else: else:
raise CommandError("unknown command %r" % cmd) raise CommandError("unknown command %r" % command_name)
return {"ok": ok, "message": message} return {"ok": ok, "message": message}
def status(self, now: float = None) -> list: def status(self, now: float = None) -> list:
"""Status entry for every companion, for the ``status`` command.""" """Status entry for every companion, for the ``status`` command."""
now = now or time.time() now = now or time.time()
return [proc.status_dict(now) for proc in self.processes.values()] return [process.status_dict(now) for process in self.processes.values()]
def reread_config(self, new_configs) -> dict: def reread_config(self, new_configs) -> dict:
"""Transactionally apply a fresh set of companion configs. """Transactionally apply a fresh set of companion configs.
@ -101,18 +101,18 @@ class CompanionManager:
removed.append(name) removed.append(name)
for name in new_names - old_names: for name in new_names - old_names:
proc = CompanionProcess(new_by_name[name]) process = CompanionProcess(new_by_name[name])
self.processes[name] = proc self.processes[name] = process
self.spawn_process(proc) self.spawn_process(process)
added.append(name) added.append(name)
for name in new_names & old_names: for name in new_names & old_names:
proc = self.processes[name] process = self.processes[name]
if proc.config.config_hash == new_by_name[name].config_hash: if process.config.config_hash == new_by_name[name].config_hash:
unchanged.append(name) unchanged.append(name)
continue continue
proc.config = new_by_name[name] process.config = new_by_name[name]
if proc.manual_stop: if process.manual_stop:
unchanged.append(name) unchanged.append(name)
else: else:
self.restart_process(name) self.restart_process(name)
@ -132,7 +132,7 @@ class CompanionManager:
by_name[config.name] = config by_name[config.name] = config
return by_name return by_name
def spawn_process(self, proc: CompanionProcess) -> int: def spawn_process(self, process: CompanionProcess) -> int:
"""Fork one companion. """Fork one companion.
Parent records the pid and moves the companion to STARTING. Child Parent records the pid and moves the companion to STARTING. Child
@ -146,21 +146,21 @@ class CompanionManager:
""" """
pid = os.fork() pid = os.fork()
if pid != 0: if pid != 0:
proc.pid = pid process.pid = pid
proc.state = State.STARTING process.state = State.STARTING
proc.started_at = time.time() process.started_at = time.time()
self.log.info("companion %s started (pid %s)", proc.name, pid) self.log.info("companion %s started (pid %s)", process.name, pid)
return pid return pid
try: try:
self._apply_environment(proc.config) self._apply_environment(process.config)
self._redirect_output(proc.config) self._redirect_output(process.config)
target = self._resolve_target(proc.config.target) target = self._resolve_target(process.config.target)
target() target()
except SystemExit: except SystemExit:
raise raise
except BaseException: except BaseException:
self.log.exception("companion %s crashed", proc.name) self.log.exception("companion %s crashed", process.name)
os._exit(1) os._exit(1)
os._exit(0) os._exit(0)
@ -173,16 +173,16 @@ class CompanionManager:
without doing anything. STOPPING is rejected so the caller polls status without doing anything. STOPPING is rejected so the caller polls status
and retries once the old child is gone. Returns ``(ok, message)``. and retries once the old child is gone. Returns ``(ok, message)``.
""" """
proc = self.processes.get(name) process = self.processes.get(name)
if proc is None: if process is None:
return False, "unknown companion %s" % name return False, "unknown companion %s" % name
if proc.state in (State.RUNNING, State.STARTING): if process.state in (State.RUNNING, State.STARTING):
return True, "%s already %s" % (name, proc.state.lower()) return True, "%s already %s" % (name, process.state.lower())
if proc.state == State.STOPPING: if process.state == State.STOPPING:
return False, "%s is stopping; retry" % name return False, "%s is stopping; retry" % name
proc.manual_stop = False process.manual_stop = False
proc.next_retry_at = None process.next_retry_at = None
self.spawn_process(proc) self.spawn_process(process)
return True, "%s started" % name return True, "%s started" % name
def stop_process(self, name: str, now: float = None): def stop_process(self, name: str, now: float = None):
@ -195,21 +195,21 @@ class CompanionManager:
settles in STOPPED. STOPPED and STOPPING are already-there success settles in STOPPED. STOPPED and STOPPING are already-there success
no-ops. Returns ``(ok, message)``. no-ops. Returns ``(ok, message)``.
""" """
proc = self.processes.get(name) process = self.processes.get(name)
if proc is None: if process is None:
return False, "unknown companion %s" % name return False, "unknown companion %s" % name
proc.manual_stop = True process.manual_stop = True
if proc.state in (State.STOPPED, State.STOPPING): if process.state in (State.STOPPED, State.STOPPING):
return True, "%s already %s" % (name, proc.state.lower()) return True, "%s already %s" % (name, process.state.lower())
if proc.state == State.BACKOFF: if process.state == State.BACKOFF:
proc.next_retry_at = None process.next_retry_at = None
proc.state = State.STOPPED process.state = State.STOPPED
return True, "%s stopped" % name return True, "%s stopped" % name
now = now or time.time() now = now or time.time()
os.kill(proc.pid, self._signal_number(proc.config.stop_signal)) os.kill(process.pid, self._signal_number(process.config.stop_signal))
proc.state = State.STOPPING process.state = State.STOPPING
proc.stop_deadline = now + proc.config.stop_timeout process.stop_deadline = now + process.config.stop_timeout
self.log.info("companion %s stopping (pid %s)", name, proc.pid) self.log.info("companion %s stopping (pid %s)", name, process.pid)
return True, "%s stopping" % name return True, "%s stopping" % name
def restart_process(self, name: str, now: float = None): def restart_process(self, name: str, now: float = None):
@ -222,26 +222,26 @@ class CompanionManager:
STOPPED start again immediately. STOPPING is rejected so the caller STOPPED start again immediately. STOPPING is rejected so the caller
retries. This never rereads config. Returns ``(ok, message)``. retries. This never rereads config. Returns ``(ok, message)``.
""" """
proc = self.processes.get(name) process = self.processes.get(name)
if proc is None: if process is None:
return False, "unknown companion %s" % name return False, "unknown companion %s" % name
if proc.state == State.STOPPING: if process.state == State.STOPPING:
return False, "%s is stopping; retry" % name return False, "%s is stopping; retry" % name
proc.manual_stop = False process.manual_stop = False
if proc.state in (State.RUNNING, State.STARTING): if process.state in (State.RUNNING, State.STARTING):
now = now or time.time() now = now or time.time()
proc.restart_pending = True process.restart_pending = True
os.kill(proc.pid, self._signal_number(proc.config.stop_signal)) os.kill(process.pid, self._signal_number(process.config.stop_signal))
proc.state = State.STOPPING process.state = State.STOPPING
proc.stop_deadline = now + proc.config.reload_timeout process.stop_deadline = now + process.config.reload_timeout
self.log.info("companion %s restarting (pid %s)", name, proc.pid) self.log.info("companion %s restarting (pid %s)", name, process.pid)
return True, "%s restarting" % name return True, "%s restarting" % name
proc.next_retry_at = None process.next_retry_at = None
self.spawn_process(proc) self.spawn_process(process)
return True, "%s started" % name return True, "%s started" % name
@staticmethod @staticmethod
def _signal_number(sig) -> int: def _signal_number(stop_signal) -> int:
"""Resolve a stop signal to its number, e.g. ``"SIGTERM"`` -> 15. """Resolve a stop signal to its number, e.g. ``"SIGTERM"`` -> 15.
Accepts a signal name or a raw number and validates both against the Accepts a signal name or a raw number and validates both against the
@ -249,9 +249,11 @@ class CompanionManager:
than silently sending the wrong signal (or none). than silently sending the wrong signal (or none).
""" """
try: try:
return signal.Signals[sig] if isinstance(sig, str) else signal.Signals(sig) if isinstance(stop_signal, str):
return signal.Signals[stop_signal]
return signal.Signals(stop_signal)
except (KeyError, ValueError): except (KeyError, ValueError):
raise ValueError("unknown stop signal %r" % (sig,)) raise ValueError("unknown stop signal %r" % (stop_signal,))
def reap_processes(self) -> list: def reap_processes(self) -> list:
"""Reap any companions that have exited and record their exit info. """Reap any companions that have exited and record their exit info.
@ -282,14 +284,14 @@ class CompanionManager:
break break
if pid == 0: if pid == 0:
break break
proc = self._process_by_pid(pid) process = self._process_by_pid(pid)
if proc is not None: if process is not None:
self._record_exit(proc, status) self._record_exit(process, status)
self.handle_exit(proc) self.handle_exit(process)
reaped.append(proc) reaped.append(process)
return reaped return reaped
def handle_exit(self, proc: CompanionProcess, now: float = None) -> None: def handle_exit(self, process: CompanionProcess, now: float = None) -> None:
"""Decide a companion's fate after it exits: restart, stop, or back off. """Decide a companion's fate after it exits: restart, stop, or back off.
A pending restart wins: the old child was asked to stop only so a fresh A pending restart wins: the old child was asked to stop only so a fresh
@ -300,19 +302,19 @@ class CompanionManager:
backoff, no retry cap). backoff, no retry cap).
""" """
now = now or time.time() now = now or time.time()
if proc.restart_pending: if process.restart_pending:
proc.restart_pending = False process.restart_pending = False
proc.restart_count += 1 process.restart_count += 1
self.spawn_process(proc) self.spawn_process(process)
return return
if proc.manual_stop: if process.manual_stop:
proc.state = State.STOPPED process.state = State.STOPPED
proc.next_retry_at = None process.next_retry_at = None
return return
proc.state = State.BACKOFF process.state = State.BACKOFF
proc.next_retry_at = now + proc.restart_delay process.next_retry_at = now + process.restart_delay
self.log.info("companion %s exited, retrying in %ss", self.log.info("companion %s exited, retrying in %ss",
proc.name, proc.restart_delay) process.name, process.restart_delay)
def retry_backoff(self, now: float = None) -> list: def retry_backoff(self, now: float = None) -> list:
"""Respawn BACKOFF companions whose fixed retry delay has elapsed. """Respawn BACKOFF companions whose fixed retry delay has elapsed.
@ -322,14 +324,14 @@ class CompanionManager:
""" """
now = now or time.time() now = now or time.time()
retried = [] retried = []
for proc in self.processes.values(): for process in self.processes.values():
if proc.state != State.BACKOFF or proc.next_retry_at is None: if process.state != State.BACKOFF or process.next_retry_at is None:
continue continue
if now >= proc.next_retry_at: if now >= process.next_retry_at:
proc.restart_count += 1 process.restart_count += 1
proc.next_retry_at = None process.next_retry_at = None
self.spawn_process(proc) self.spawn_process(process)
retried.append(proc) retried.append(process)
return retried return retried
def promote_running(self, now: float = None) -> list: def promote_running(self, now: float = None) -> list:
@ -341,23 +343,23 @@ class CompanionManager:
""" """
now = now or time.time() now = now or time.time()
promoted = [] promoted = []
for proc in self.processes.values(): for process in self.processes.values():
if proc.state != State.STARTING or proc.started_at is None: if process.state != State.STARTING or process.started_at is None:
continue continue
if now - proc.started_at >= proc.config.startsecs: if now - process.started_at >= process.config.startsecs:
proc.state = State.RUNNING process.state = State.RUNNING
self.log.info("companion %s running (pid %s)", proc.name, proc.pid) self.log.info("companion %s running (pid %s)", process.name, process.pid)
promoted.append(proc) promoted.append(process)
return promoted return promoted
def _process_by_pid(self, pid: int): def _process_by_pid(self, pid: int):
for proc in self.processes.values(): for process in self.processes.values():
if proc.pid == pid: if process.pid == pid:
return proc return process
return None return None
@staticmethod @staticmethod
def _record_exit(proc: CompanionProcess, status: int) -> None: def _record_exit(process: CompanionProcess, status: int) -> None:
"""Store how a companion died: signal number or exit code, plus time. """Store how a companion died: signal number or exit code, plus time.
``status`` is the packed value from ``waitpid``. ``WIFSIGNALED`` tells ``status`` is the packed value from ``waitpid``. ``WIFSIGNALED`` tells
@ -366,14 +368,14 @@ class CompanionManager:
code. Only one of the two is ever set, so the other is cleared. code. Only one of the two is ever set, so the other is cleared.
""" """
if os.WIFSIGNALED(status): if os.WIFSIGNALED(status):
proc.last_exit_signal = os.WTERMSIG(status) process.last_exit_signal = os.WTERMSIG(status)
proc.last_exit_code = None process.last_exit_code = None
else: else:
proc.last_exit_code = os.WEXITSTATUS(status) process.last_exit_code = os.WEXITSTATUS(status)
proc.last_exit_signal = None process.last_exit_signal = None
proc.exited_at = time.time() process.exited_at = time.time()
proc.exit_count += 1 process.exit_count += 1
proc.pid = None process.pid = None
@staticmethod @staticmethod
def _apply_environment(config: CompanionConfig) -> None: def _apply_environment(config: CompanionConfig) -> None:
@ -397,15 +399,15 @@ class CompanionManager:
we append the output there instead. For stderr you can also pass we append the output there instead. For stderr you can also pass
``"stdout"`` to fold the two streams into one file. ``"stdout"`` to fold the two streams into one file.
""" """
out = CompanionManager._open_output(config.stdout) stdout_fd = CompanionManager._open_output(config.stdout)
if out is not None: if stdout_fd is not None:
os.dup2(out, 1) os.dup2(stdout_fd, 1)
if config.stderr == "stdout": if config.stderr == "stdout":
os.dup2(1, 2) os.dup2(1, 2)
else: else:
err = CompanionManager._open_output(config.stderr) stderr_fd = CompanionManager._open_output(config.stderr)
if err is not None: if stderr_fd is not None:
os.dup2(err, 2) os.dup2(stderr_fd, 2)
@staticmethod @staticmethod
def _open_output(value): def _open_output(value):
@ -424,7 +426,7 @@ class CompanionManager:
""" """
if callable(target): if callable(target):
return target return target
module, sep, attr = target.partition(":") module_name, separator, attribute = target.partition(":")
if not sep: if not separator:
raise ValueError("companion target %r must be 'module:callable'" % target) raise ValueError("companion target %r must be 'module:callable'" % target)
return getattr(importlib.import_module(module), attr) return getattr(importlib.import_module(module_name), attribute)

View File

@ -78,15 +78,15 @@ class CompanionConfig:
# target has no stable repr across runs, so use its qualified name. # target has no stable repr across runs, so use its qualified name.
data = self.to_dict() data = self.to_dict()
data["target"] = self._target_key(self.target) data["target"] = self._target_key(self.target)
blob = json.dumps(data, sort_keys=True, default=str) payload = json.dumps(data, sort_keys=True, default=str)
return hashlib.sha256(blob.encode("utf-8")).hexdigest() return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@staticmethod @staticmethod
def _target_key(target): def _target_key(target):
if callable(target): if callable(target):
mod = getattr(target, "__module__", "") module = getattr(target, "__module__", "")
qual = getattr(target, "__qualname__", repr(target)) qualified_name = getattr(target, "__qualname__", repr(target))
return "%s:%s" % (mod, qual) return "%s:%s" % (module, qualified_name)
return str(target) return str(target)
def __repr__(self): def __repr__(self):
@ -146,8 +146,8 @@ class CompanionProcess:
format_uptime(self.uptime(now) or 0), format_uptime(self.uptime(now) or 0),
) )
if self.state == State.BACKOFF and self.next_retry_at is not None: if self.state == State.BACKOFF and self.next_retry_at is not None:
left = max(0, int(self.next_retry_at - now)) seconds_left = max(0, int(self.next_retry_at - now))
return "exited with %s, retrying in %ds" % (self._exit_status(), left) return "exited with %s, retrying in %ds" % (self._exit_status(), seconds_left)
if self.state == State.BACKOFF: if self.state == State.BACKOFF:
return "exited with %s" % self._exit_status() return "exited with %s" % self._exit_status()
if self.state == State.STOPPED and self.manual_stop: if self.state == State.STOPPED and self.manual_stop:

View File

@ -652,10 +652,10 @@ def unquote_to_wsgi_str(string):
def format_uptime(seconds): def format_uptime(seconds):
"""Render a duration like supervisor: ``2 days, 03:12:44`` or ``0:05:12``.""" """Render a duration like supervisor: ``2 days, 03:12:44`` or ``0:05:12``."""
seconds = int(seconds) seconds = int(seconds)
days, rem = divmod(seconds, 86400) days, remainder = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600) hours, remainder = divmod(remainder, 3600)
minutes, secs = divmod(rem, 60) minutes, remaining_seconds = divmod(remainder, 60)
if days: if days:
unit = "day" if days == 1 else "days" unit = "day" if days == 1 else "days"
return "%d %s, %02d:%02d:%02d" % (days, unit, hours, minutes, secs) return "%d %s, %02d:%02d:%02d" % (days, unit, hours, minutes, remaining_seconds)
return "%d:%02d:%02d" % (hours, minutes, secs) return "%d:%02d:%02d" % (hours, minutes, remaining_seconds)

View File

@ -35,51 +35,53 @@ def test_decode_command_missing_cmd():
def test_encode_response_newline_terminated(): def test_encode_response_newline_terminated():
out = encode_response({"ok": True}) response = encode_response({"ok": True})
assert out.endswith(b"\n") assert response.endswith(b"\n")
assert json.loads(out) == {"ok": True} assert json.loads(response) == {"ok": True}
def test_handle_line_dispatches(): def test_handle_line_dispatches():
server = ControlServer(dispatch=lambda obj: {"ok": True, "echo": obj["cmd"]}, server = ControlServer(
path="/tmp/x.sock") dispatch=lambda command: {"ok": True, "echo": command["cmd"]},
out = server.handle_line('{"cmd": "status"}') path="/tmp/x.sock")
assert json.loads(out) == {"ok": True, "echo": "status"} response = server.handle_line('{"cmd": "status"}')
assert json.loads(response) == {"ok": True, "echo": "status"}
def test_handle_line_bad_json_error_envelope(): def test_handle_line_bad_json_error_envelope():
server = ControlServer(dispatch=lambda obj: {"ok": True}, path="/tmp/x.sock") server = ControlServer(dispatch=lambda command: {"ok": True}, path="/tmp/x.sock")
out = json.loads(server.handle_line("garbage")) response = json.loads(server.handle_line("garbage"))
assert out["ok"] is False and "JSON" in out["error"] assert response["ok"] is False and "JSON" in response["error"]
def test_handle_line_dispatch_command_error(): def test_handle_line_dispatch_command_error():
def dispatch(obj): def dispatch(command):
raise CommandError("unknown command") raise CommandError("unknown command")
server = ControlServer(dispatch=dispatch, path="/tmp/x.sock") server = ControlServer(dispatch=dispatch, path="/tmp/x.sock")
out = json.loads(server.handle_line('{"cmd": "bogus"}')) response = json.loads(server.handle_line('{"cmd": "bogus"}'))
assert out["ok"] is False and out["error"] == "unknown command" assert response["ok"] is False and response["error"] == "unknown command"
def test_create_unlinks_stale_and_chmods(): def test_create_unlinks_stale_and_chmods():
server = ControlServer(dispatch=lambda o: {}, path="/tmp/x.sock", mode=0o600) server = ControlServer(dispatch=lambda command: {}, path="/tmp/x.sock",
sock = mock.Mock() mode=0o600)
listener = mock.Mock()
with mock.patch("os.path.exists", return_value=True), \ with mock.patch("os.path.exists", return_value=True), \
mock.patch("os.unlink") as unlink, \ mock.patch("os.unlink") as unlink, \
mock.patch("socket.socket", return_value=sock), \ mock.patch("socket.socket", return_value=listener), \
mock.patch("os.chmod") as chmod: mock.patch("os.chmod") as chmod:
server.create() server.create()
unlink.assert_called_once_with("/tmp/x.sock") unlink.assert_called_once_with("/tmp/x.sock")
sock.bind.assert_called_once_with("/tmp/x.sock") listener.bind.assert_called_once_with("/tmp/x.sock")
chmod.assert_called_once_with("/tmp/x.sock", 0o600) chmod.assert_called_once_with("/tmp/x.sock", 0o600)
sock.listen.assert_called_once() listener.listen.assert_called_once()
def test_close_unlinks(): def test_close_unlinks():
server = ControlServer(dispatch=lambda o: {}, path="/tmp/x.sock") server = ControlServer(dispatch=lambda command: {}, path="/tmp/x.sock")
server.sock = mock.Mock() server.listener = mock.Mock()
with mock.patch("os.path.exists", return_value=True), \ with mock.patch("os.path.exists", return_value=True), \
mock.patch("os.unlink") as unlink: mock.patch("os.unlink") as unlink:
server.close() server.close()
unlink.assert_called_once_with("/tmp/x.sock") unlink.assert_called_once_with("/tmp/x.sock")
assert server.sock is None assert server.listener is None

View File

@ -19,9 +19,9 @@ def make_manager(*names):
def test_manager_builds_one_process_per_config(): def test_manager_builds_one_process_per_config():
mgr = make_manager("rq", "scheduler") manager = make_manager("rq", "scheduler")
assert set(mgr.processes) == {"rq", "scheduler"} assert set(manager.processes) == {"rq", "scheduler"}
assert mgr.processes["rq"].state == State.STOPPED assert manager.processes["rq"].state == State.STOPPED
def test_resolve_target_accepts_callable(): def test_resolve_target_accepts_callable():
@ -63,10 +63,10 @@ def test_open_output_inherit_returns_none():
def test_open_output_path_opens_append(): def test_open_output_path_opens_append():
with mock.patch("os.open", return_value=9) as op: with mock.patch("os.open", return_value=9) as open_mock:
fd = CompanionManager._open_output("/var/log/rq.log") fd = CompanionManager._open_output("/var/log/rq.log")
assert fd == 9 assert fd == 9
flags = op.call_args.args[1] flags = open_mock.call_args.args[1]
assert flags & os.O_APPEND and flags & os.O_CREAT assert flags & os.O_APPEND and flags & os.O_CREAT
@ -98,12 +98,12 @@ def test_redirect_output_inherit_noop():
def test_reap_records_exit_code(): def test_reap_records_exit_code():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.pid = 4321 proc.pid = 4321
# exit code 1 -> status 1<<8; second call drains the queue. # exit code 1 -> status 1<<8; second call drains the queue.
with mock.patch("os.waitpid", side_effect=[(4321, 1 << 8), (0, 0)]): with mock.patch("os.waitpid", side_effect=[(4321, 1 << 8), (0, 0)]):
reaped = mgr.reap_processes() reaped = manager.reap_processes()
assert reaped == [proc] assert reaped == [proc]
assert proc.last_exit_code == 1 assert proc.last_exit_code == 1
assert proc.last_exit_signal is None assert proc.last_exit_signal is None
@ -112,229 +112,229 @@ def test_reap_records_exit_code():
def test_reap_records_signal(): def test_reap_records_signal():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.pid = 4321 proc.pid = 4321
with mock.patch("os.waitpid", side_effect=[(4321, 9), (0, 0)]): with mock.patch("os.waitpid", side_effect=[(4321, 9), (0, 0)]):
mgr.reap_processes() manager.reap_processes()
assert proc.last_exit_signal == 9 assert proc.last_exit_signal == 9
assert proc.last_exit_code is None assert proc.last_exit_code is None
def test_reap_no_children(): def test_reap_no_children():
mgr = make_manager("rq") manager = make_manager("rq")
with mock.patch("os.waitpid", side_effect=ChildProcessError): with mock.patch("os.waitpid", side_effect=ChildProcessError):
assert mgr.reap_processes() == [] assert manager.reap_processes() == []
def test_status_lists_all_companions(): def test_status_lists_all_companions():
mgr = make_manager("rq", "scheduler") manager = make_manager("rq", "scheduler")
entries = mgr.status(now=100.0) entries = manager.status(now=100.0)
assert {e["name"] for e in entries} == {"rq", "scheduler"} assert {e["name"] for e in entries} == {"rq", "scheduler"}
assert all("state" in e and "description" in e for e in entries) assert all("state" in e and "description" in e for e in entries)
def test_handle_command_status(): def test_handle_command_status():
mgr = make_manager("rq") manager = make_manager("rq")
resp = mgr.handle_command({"cmd": "status"}) resp = manager.handle_command({"cmd": "status"})
assert resp["ok"] is True assert resp["ok"] is True
assert resp["companions"][0]["name"] == "rq" assert resp["companions"][0]["name"] == "rq"
def test_handle_command_start_routes(): def test_handle_command_start_routes():
mgr = make_manager("rq") manager = make_manager("rq")
with mock.patch.object(mgr, "start_process", with mock.patch.object(manager, "start_process",
return_value=(True, "rq started")) as sp: return_value=(True, "rq started")) as start_mock:
resp = mgr.handle_command({"cmd": "start", "name": "rq"}) resp = manager.handle_command({"cmd": "start", "name": "rq"})
sp.assert_called_once_with("rq") start_mock.assert_called_once_with("rq")
assert resp == {"ok": True, "message": "rq started"} assert resp == {"ok": True, "message": "rq started"}
def test_handle_command_stop_and_restart_route(): def test_handle_command_stop_and_restart_route():
mgr = make_manager("rq") manager = make_manager("rq")
with mock.patch.object(mgr, "stop_process", return_value=(True, "s")) as st, \ with mock.patch.object(manager, "stop_process", return_value=(True, "s")) as stop_mock, \
mock.patch.object(mgr, "restart_process", return_value=(True, "r")) as rt: mock.patch.object(manager, "restart_process", return_value=(True, "r")) as restart_mock:
mgr.handle_command({"cmd": "stop", "name": "rq"}) manager.handle_command({"cmd": "stop", "name": "rq"})
mgr.handle_command({"cmd": "restart", "name": "rq"}) manager.handle_command({"cmd": "restart", "name": "rq"})
st.assert_called_once_with("rq") stop_mock.assert_called_once_with("rq")
rt.assert_called_once_with("rq") restart_mock.assert_called_once_with("rq")
def test_handle_command_missing_name(): def test_handle_command_missing_name():
mgr = make_manager("rq") manager = make_manager("rq")
with pytest.raises(CommandError): with pytest.raises(CommandError):
mgr.handle_command({"cmd": "start"}) manager.handle_command({"cmd": "start"})
def test_handle_command_unknown(): def test_handle_command_unknown():
mgr = make_manager("rq") manager = make_manager("rq")
with pytest.raises(CommandError): with pytest.raises(CommandError):
mgr.handle_command({"cmd": "reread"}) manager.handle_command({"cmd": "reread"})
def cfg(name, **kw): def make_config(name, **kwargs):
return CompanionConfig(name=name, target=lambda: None, **kw) return CompanionConfig(name=name, target=lambda: None, **kwargs)
def test_reread_adds_new(): def test_reread_adds_new():
mgr = make_manager("rq") manager = make_manager("rq")
new = [cfg("rq"), cfg("scheduler")] new = [make_config("rq"), make_config("scheduler")]
with mock.patch("os.fork", return_value=10): with mock.patch("os.fork", return_value=10):
result = mgr.reread_config(new) result = manager.reread_config(new)
assert result["added"] == ["scheduler"] assert result["added"] == ["scheduler"]
assert "scheduler" in mgr.processes assert "scheduler" in manager.processes
assert mgr.processes["scheduler"].state == State.STARTING assert manager.processes["scheduler"].state == State.STARTING
def test_reread_removes_missing(): def test_reread_removes_missing():
mgr = make_manager("rq", "scheduler") manager = make_manager("rq", "scheduler")
mgr.processes["scheduler"].state = State.RUNNING manager.processes["scheduler"].state = State.RUNNING
mgr.processes["scheduler"].pid = 11 manager.processes["scheduler"].pid = 11
with mock.patch("os.kill"): with mock.patch("os.kill"):
result = mgr.reread_config([cfg("rq")]) result = manager.reread_config([make_config("rq")])
assert result["removed"] == ["scheduler"] assert result["removed"] == ["scheduler"]
assert "scheduler" not in mgr.processes assert "scheduler" not in manager.processes
def test_reread_restarts_changed(): def test_reread_restarts_changed():
mgr = make_manager("rq") manager = make_manager("rq")
mgr.processes["rq"].state = State.RUNNING manager.processes["rq"].state = State.RUNNING
mgr.processes["rq"].pid = 12 manager.processes["rq"].pid = 12
changed = cfg("rq", env={"X": "1"}) # different hash changed = make_config("rq", env={"X": "1"}) # different hash
with mock.patch("os.kill"): with mock.patch("os.kill"):
result = mgr.reread_config([changed]) result = manager.reread_config([changed])
assert result["restarted"] == ["rq"] assert result["restarted"] == ["rq"]
assert mgr.processes["rq"].config is changed assert manager.processes["rq"].config is changed
assert mgr.processes["rq"].state == State.STOPPING assert manager.processes["rq"].state == State.STOPPING
def test_reread_changed_manual_stop_keeps_stopped(): def test_reread_changed_manual_stop_keeps_stopped():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.manual_stop = True proc.manual_stop = True
proc.state = State.STOPPED proc.state = State.STOPPED
changed = cfg("rq", env={"X": "1"}) changed = make_config("rq", env={"X": "1"})
result = mgr.reread_config([changed]) result = manager.reread_config([changed])
assert result["unchanged"] == ["rq"] assert result["unchanged"] == ["rq"]
assert proc.config is changed and proc.state == State.STOPPED assert proc.config is changed and proc.state == State.STOPPED
def test_reread_unchanged_noop(): def test_reread_unchanged_noop():
mgr = make_manager("rq") manager = make_manager("rq")
same = mgr.processes["rq"].config same = manager.processes["rq"].config
result = mgr.reread_config([same]) result = manager.reread_config([same])
assert result["unchanged"] == ["rq"] assert result["unchanged"] == ["rq"]
assert result["restarted"] == [] assert result["restarted"] == []
def test_reread_duplicate_name_keeps_old(): def test_reread_duplicate_name_keeps_old():
mgr = make_manager("rq") manager = make_manager("rq")
result = mgr.reread_config([cfg("rq"), cfg("rq")]) result = manager.reread_config([make_config("rq"), make_config("rq")])
assert result["ok"] is False and result["kept_old_config"] is True assert result["ok"] is False and result["kept_old_config"] is True
assert "duplicate" in result["error"] assert "duplicate" in result["error"]
def test_handle_command_reread_no_loader(): def test_handle_command_reread_no_loader():
mgr = make_manager("rq") manager = make_manager("rq")
with pytest.raises(CommandError): with pytest.raises(CommandError):
mgr.handle_command({"cmd": "reread"}) manager.handle_command({"cmd": "reread"})
def test_handle_command_reread_runs_loader(): def test_handle_command_reread_runs_loader():
mgr = make_manager("rq") manager = make_manager("rq")
mgr.config_loader = lambda: [mgr.processes["rq"].config] manager.config_loader = lambda: [manager.processes["rq"].config]
resp = mgr.handle_command({"cmd": "reread"}) resp = manager.handle_command({"cmd": "reread"})
assert resp["ok"] is True and resp["unchanged"] == ["rq"] assert resp["ok"] is True and resp["unchanged"] == ["rq"]
def test_handle_command_reread_bad_config(): def test_handle_command_reread_bad_config():
mgr = make_manager("rq") manager = make_manager("rq")
def boom(): def boom():
raise ValueError("duplicate companion name rq") raise ValueError("duplicate companion name rq")
mgr.config_loader = boom manager.config_loader = boom
resp = mgr.handle_command({"cmd": "reread"}) resp = manager.handle_command({"cmd": "reread"})
assert resp["ok"] is False and resp["kept_old_config"] is True assert resp["ok"] is False and resp["kept_old_config"] is True
def test_start_process_stopped_spawns(): def test_start_process_stopped_spawns():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
with mock.patch("os.fork", return_value=70) as fork: with mock.patch("os.fork", return_value=70) as fork:
ok, _ = mgr.start_process("rq") ok, _ = manager.start_process("rq")
fork.assert_called_once() fork.assert_called_once()
assert ok and proc.state == State.STARTING and proc.manual_stop is False assert ok and proc.state == State.STARTING and proc.manual_stop is False
def test_start_process_backoff_cancels_retry(): def test_start_process_backoff_cancels_retry():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.BACKOFF proc.state = State.BACKOFF
proc.next_retry_at = 999.0 proc.next_retry_at = 999.0
proc.manual_stop = True proc.manual_stop = True
with mock.patch("os.fork", return_value=71): with mock.patch("os.fork", return_value=71):
ok, _ = mgr.start_process("rq") ok, _ = manager.start_process("rq")
assert ok and proc.state == State.STARTING assert ok and proc.state == State.STARTING
assert proc.next_retry_at is None and proc.manual_stop is False assert proc.next_retry_at is None and proc.manual_stop is False
def test_start_process_running_is_noop(): def test_start_process_running_is_noop():
mgr = make_manager("rq") manager = make_manager("rq")
mgr.processes["rq"].state = State.RUNNING manager.processes["rq"].state = State.RUNNING
with mock.patch("os.fork") as fork: with mock.patch("os.fork") as fork:
ok, _ = mgr.start_process("rq") ok, _ = manager.start_process("rq")
assert ok assert ok
fork.assert_not_called() fork.assert_not_called()
def test_start_process_stopping_rejected(): def test_start_process_stopping_rejected():
mgr = make_manager("rq") manager = make_manager("rq")
mgr.processes["rq"].state = State.STOPPING manager.processes["rq"].state = State.STOPPING
ok, msg = mgr.start_process("rq") ok, msg = manager.start_process("rq")
assert not ok and "stopping" in msg assert not ok and "stopping" in msg
def test_start_process_unknown(): def test_start_process_unknown():
mgr = make_manager("rq") manager = make_manager("rq")
ok, _ = mgr.start_process("nope") ok, _ = manager.start_process("nope")
assert not ok assert not ok
def test_stop_process_running_signals_and_stopping(): def test_stop_process_running_signals_and_stopping():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.RUNNING proc.state = State.RUNNING
proc.pid = 80 proc.pid = 80
proc.config.stop_timeout = 60 proc.config.stop_timeout = 60
with mock.patch("os.kill") as kill: with mock.patch("os.kill") as kill:
ok, _ = mgr.stop_process("rq", now=200.0) ok, _ = manager.stop_process("rq", now=200.0)
kill.assert_called_once_with(80, signal.SIGTERM) kill.assert_called_once_with(80, signal.SIGTERM)
assert ok and proc.state == State.STOPPING assert ok and proc.state == State.STOPPING
assert proc.manual_stop is True and proc.stop_deadline == 260.0 assert proc.manual_stop is True and proc.stop_deadline == 260.0
def test_stop_process_backoff_to_stopped(): def test_stop_process_backoff_to_stopped():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.BACKOFF proc.state = State.BACKOFF
proc.next_retry_at = 999.0 proc.next_retry_at = 999.0
with mock.patch("os.kill") as kill: with mock.patch("os.kill") as kill:
ok, _ = mgr.stop_process("rq") ok, _ = manager.stop_process("rq")
kill.assert_not_called() kill.assert_not_called()
assert ok and proc.state == State.STOPPED assert ok and proc.state == State.STOPPED
assert proc.next_retry_at is None and proc.manual_stop is True assert proc.next_retry_at is None and proc.manual_stop is True
def test_stop_process_already_stopped(): def test_stop_process_already_stopped():
mgr = make_manager("rq") manager = make_manager("rq")
with mock.patch("os.kill") as kill: with mock.patch("os.kill") as kill:
ok, _ = mgr.stop_process("rq") ok, _ = manager.stop_process("rq")
kill.assert_not_called() kill.assert_not_called()
assert ok and mgr.processes["rq"].manual_stop is True assert ok and manager.processes["rq"].manual_stop is True
def test_stop_process_unknown(): def test_stop_process_unknown():
mgr = make_manager("rq") manager = make_manager("rq")
ok, _ = mgr.stop_process("nope") ok, _ = manager.stop_process("nope")
assert not ok assert not ok
@ -349,14 +349,14 @@ def test_signal_number_rejects_bad():
def test_restart_process_running_stops_with_reload_timeout(): def test_restart_process_running_stops_with_reload_timeout():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.RUNNING proc.state = State.RUNNING
proc.pid = 90 proc.pid = 90
proc.config.reload_timeout = 30 proc.config.reload_timeout = 30
proc.manual_stop = True proc.manual_stop = True
with mock.patch("os.kill") as kill: with mock.patch("os.kill") as kill:
ok, _ = mgr.restart_process("rq", now=300.0) ok, _ = manager.restart_process("rq", now=300.0)
kill.assert_called_once_with(90, signal.SIGTERM) kill.assert_called_once_with(90, signal.SIGTERM)
assert ok and proc.state == State.STOPPING assert ok and proc.state == State.STOPPING
assert proc.restart_pending is True and proc.stop_deadline == 330.0 assert proc.restart_pending is True and proc.stop_deadline == 330.0
@ -364,14 +364,14 @@ def test_restart_process_running_stops_with_reload_timeout():
def test_restart_pending_reap_respawns_immediately(): def test_restart_pending_reap_respawns_immediately():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.STOPPING proc.state = State.STOPPING
proc.restart_pending = True proc.restart_pending = True
proc.pid = 91 proc.pid = 91
with mock.patch("os.waitpid", side_effect=[(91, 0), (0, 0)]), \ with mock.patch("os.waitpid", side_effect=[(91, 0), (0, 0)]), \
mock.patch("os.fork", return_value=92): mock.patch("os.fork", return_value=92):
mgr.reap_processes() manager.reap_processes()
assert proc.state == State.STARTING assert proc.state == State.STARTING
assert proc.pid == 92 assert proc.pid == 92
assert proc.restart_pending is False assert proc.restart_pending is False
@ -379,90 +379,90 @@ def test_restart_pending_reap_respawns_immediately():
def test_restart_process_stopped_starts_now(): def test_restart_process_stopped_starts_now():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
with mock.patch("os.fork", return_value=93), mock.patch("os.kill") as kill: with mock.patch("os.fork", return_value=93), mock.patch("os.kill") as kill:
ok, _ = mgr.restart_process("rq") ok, _ = manager.restart_process("rq")
kill.assert_not_called() kill.assert_not_called()
assert ok and proc.state == State.STARTING assert ok and proc.state == State.STARTING
def test_restart_process_backoff_starts_now(): def test_restart_process_backoff_starts_now():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.BACKOFF proc.state = State.BACKOFF
proc.next_retry_at = 999.0 proc.next_retry_at = 999.0
with mock.patch("os.fork", return_value=94): with mock.patch("os.fork", return_value=94):
ok, _ = mgr.restart_process("rq") ok, _ = manager.restart_process("rq")
assert ok and proc.state == State.STARTING and proc.next_retry_at is None assert ok and proc.state == State.STARTING and proc.next_retry_at is None
def test_restart_process_stopping_rejected(): def test_restart_process_stopping_rejected():
mgr = make_manager("rq") manager = make_manager("rq")
mgr.processes["rq"].state = State.STOPPING manager.processes["rq"].state = State.STOPPING
ok, msg = mgr.restart_process("rq") ok, msg = manager.restart_process("rq")
assert not ok and "stopping" in msg assert not ok and "stopping" in msg
def test_manual_stop_preserved_through_exit(): def test_manual_stop_preserved_through_exit():
# stop a running companion, then reap its child: it must settle in STOPPED # stop a running companion, then reap its child: it must settle in STOPPED
# with manual_stop still set so it is not auto-restarted. # with manual_stop still set so it is not auto-restarted.
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.RUNNING proc.state = State.RUNNING
proc.pid = 60 proc.pid = 60
with mock.patch("os.kill"): with mock.patch("os.kill"):
mgr.stop_process("rq", now=10.0) manager.stop_process("rq", now=10.0)
with mock.patch("os.waitpid", side_effect=[(60, 0), (0, 0)]), \ with mock.patch("os.waitpid", side_effect=[(60, 0), (0, 0)]), \
mock.patch("os.fork") as fork: mock.patch("os.fork") as fork:
mgr.reap_processes() manager.reap_processes()
fork.assert_not_called() fork.assert_not_called()
assert proc.state == State.STOPPED and proc.manual_stop is True assert proc.state == State.STOPPED and proc.manual_stop is True
def test_start_clears_manual_stop(): def test_start_clears_manual_stop():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.manual_stop = True proc.manual_stop = True
with mock.patch("os.fork", return_value=61): with mock.patch("os.fork", return_value=61):
mgr.start_process("rq") manager.start_process("rq")
assert proc.manual_stop is False assert proc.manual_stop is False
def test_spawn_does_not_touch_manual_stop(): def test_spawn_does_not_touch_manual_stop():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.manual_stop = True proc.manual_stop = True
with mock.patch("os.fork", return_value=62): with mock.patch("os.fork", return_value=62):
mgr.spawn_process(proc) manager.spawn_process(proc)
assert proc.manual_stop is True assert proc.manual_stop is True
def test_handle_exit_unexpected_backoff(): def test_handle_exit_unexpected_backoff():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.restart_delay = 5 proc.restart_delay = 5
mgr.handle_exit(proc, now=100.0) manager.handle_exit(proc, now=100.0)
assert proc.state == State.BACKOFF assert proc.state == State.BACKOFF
assert proc.next_retry_at == 105.0 assert proc.next_retry_at == 105.0
def test_handle_exit_manual_stop_stays_stopped(): def test_handle_exit_manual_stop_stays_stopped():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.manual_stop = True proc.manual_stop = True
mgr.handle_exit(proc, now=100.0) manager.handle_exit(proc, now=100.0)
assert proc.state == State.STOPPED assert proc.state == State.STOPPED
assert proc.next_retry_at is None assert proc.next_retry_at is None
def test_retry_backoff_respawns_when_due(): def test_retry_backoff_respawns_when_due():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.BACKOFF proc.state = State.BACKOFF
proc.next_retry_at = 100.0 proc.next_retry_at = 100.0
with mock.patch("os.fork", return_value=555): with mock.patch("os.fork", return_value=555):
retried = mgr.retry_backoff(now=101.0) retried = manager.retry_backoff(now=101.0)
assert retried == [proc] assert retried == [proc]
assert proc.restart_count == 1 assert proc.restart_count == 1
assert proc.state == State.STARTING assert proc.state == State.STARTING
@ -470,59 +470,59 @@ def test_retry_backoff_respawns_when_due():
def test_retry_backoff_waits_until_due(): def test_retry_backoff_waits_until_due():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.BACKOFF proc.state = State.BACKOFF
proc.next_retry_at = 100.0 proc.next_retry_at = 100.0
assert mgr.retry_backoff(now=99.0) == [] assert manager.retry_backoff(now=99.0) == []
assert proc.state == State.BACKOFF assert proc.state == State.BACKOFF
def test_reap_unexpected_exit_enters_backoff(): def test_reap_unexpected_exit_enters_backoff():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.pid = 4321 proc.pid = 4321
with mock.patch("os.waitpid", side_effect=[(4321, 1 << 8), (0, 0)]): with mock.patch("os.waitpid", side_effect=[(4321, 1 << 8), (0, 0)]):
mgr.reap_processes() manager.reap_processes()
assert proc.state == State.BACKOFF assert proc.state == State.BACKOFF
assert proc.next_retry_at is not None assert proc.next_retry_at is not None
def test_promote_running_after_startsecs(): def test_promote_running_after_startsecs():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.config.startsecs = 1 proc.config.startsecs = 1
proc.state = State.STARTING proc.state = State.STARTING
proc.started_at = 100.0 proc.started_at = 100.0
promoted = mgr.promote_running(now=101.5) promoted = manager.promote_running(now=101.5)
assert promoted == [proc] assert promoted == [proc]
assert proc.state == State.RUNNING assert proc.state == State.RUNNING
def test_promote_running_too_early(): def test_promote_running_too_early():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.config.startsecs = 5 proc.config.startsecs = 5
proc.state = State.STARTING proc.state = State.STARTING
proc.started_at = 100.0 proc.started_at = 100.0
assert mgr.promote_running(now=102.0) == [] assert manager.promote_running(now=102.0) == []
assert proc.state == State.STARTING assert proc.state == State.STARTING
def test_promote_running_ignores_non_starting(): def test_promote_running_ignores_non_starting():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
proc.state = State.BACKOFF proc.state = State.BACKOFF
proc.started_at = 100.0 proc.started_at = 100.0
assert mgr.promote_running(now=999.0) == [] assert manager.promote_running(now=999.0) == []
assert proc.state == State.BACKOFF assert proc.state == State.BACKOFF
def test_spawn_parent_records_pid_and_starting(): def test_spawn_parent_records_pid_and_starting():
mgr = make_manager("rq") manager = make_manager("rq")
proc = mgr.processes["rq"] proc = manager.processes["rq"]
with mock.patch("os.fork", return_value=4321): with mock.patch("os.fork", return_value=4321):
pid = mgr.spawn_process(proc) pid = manager.spawn_process(proc)
assert pid == 4321 assert pid == 4321
assert proc.pid == 4321 assert proc.pid == 4321
assert proc.state == State.STARTING assert proc.state == State.STARTING