restart_or_exit spawned a replacement process and exited, with no attempt limit
and no backoff:
subprocess.Popen([sys.executable, os.path.abspath(sys.argv[0])])
os._exit(0)
On 2026-08-28 a 25-minute scheduled break left D2R on a screen the bot could
not re-enter:
=== BOT START ===
select_char: Could not find online/offline tabs
Restarting bot — game kept running
Because the failure was persistent, this span up a new process roughly every 20
seconds. Instances stacked (4 observed) and then refused taskkill.
The counter has to survive the exec — each restart is a NEW PROCESS, so an
in-memory counter cannot bound the chain. It lives in log/.restart_count,
is checked BEFORE spawning a replacement, and after 5 consecutive restarts the
bot stops with a Discord alert instead of looping, telling the user to return
D2R to the main menu.
A 5s-per-attempt backoff (capped at 60s) stops a fast failure spinning CPU or
stacking processes faster than they exit.
The count is cleared on reaching town, not at game end: the loop failed at
select_char, well before town, so reaching town is what proves recovery — and a
healthy bot never accumulates toward the cap.
Verified by falsification: restoring the unbounded restart makes the suite fail
with "restart loop is unbounded".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
67 lines
2.2 KiB
Python
67 lines
2.2 KiB
Python
"""A persistent failure must not restart the bot forever.
|
|
|
|
2026-08-28: a 25-minute scheduled break left D2R on a screen the bot could not
|
|
re-enter ("select_char: Could not find online/offline tabs"). restart_or_exit
|
|
spawned a replacement process and exited — with no attempt limit and no
|
|
backoff — so it span up a new process roughly every 20 seconds. Instances
|
|
stacked and then refused to die.
|
|
|
|
The counter MUST survive the exec: each restart is a new process, so an
|
|
in-memory counter cannot bound the chain.
|
|
"""
|
|
import os
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_counter():
|
|
from bot import Bot
|
|
|
|
Bot._reset_restart_count()
|
|
yield Bot
|
|
Bot._reset_restart_count()
|
|
|
|
|
|
def test_restart_count_persists_across_processes(clean_counter):
|
|
"""The whole point: an in-memory counter cannot stop a chain of execs."""
|
|
Bot = clean_counter
|
|
assert Bot._bump_restart_count() == 1
|
|
assert Bot._bump_restart_count() == 2
|
|
# Simulates a fresh process reading the same on-disk state.
|
|
assert Bot._bump_restart_count() == 3
|
|
assert os.path.exists(Bot._RESTART_COUNT_FILE)
|
|
|
|
|
|
def test_reaching_town_clears_the_count(clean_counter):
|
|
Bot = clean_counter
|
|
Bot._bump_restart_count()
|
|
Bot._bump_restart_count()
|
|
Bot._reset_restart_count()
|
|
assert Bot._bump_restart_count() == 1, "a recovered bot still counts toward the cap"
|
|
|
|
|
|
def test_cap_is_bounded_and_reachable(clean_counter):
|
|
Bot = clean_counter
|
|
assert 1 <= Bot._MAX_CONSECUTIVE_RESTARTS <= 20
|
|
|
|
|
|
def test_restart_path_has_a_cap_and_a_backoff():
|
|
import inspect
|
|
from bot import Bot
|
|
|
|
src = inspect.getsource(Bot.restart_or_exit)
|
|
assert "_MAX_CONSECUTIVE_RESTARTS" in src, "restart loop is unbounded"
|
|
assert "time.sleep(" in src, "no backoff — a fast failure can stack processes"
|
|
cap_at = src.index("_MAX_CONSECUTIVE_RESTARTS")
|
|
spawn_at = src.index("subprocess.Popen")
|
|
assert cap_at < spawn_at, "the cap must be checked BEFORE spawning a replacement"
|
|
|
|
|
|
def test_missing_counter_file_is_treated_as_zero(clean_counter):
|
|
"""First run has no file; that must not raise."""
|
|
Bot = clean_counter
|
|
if os.path.exists(Bot._RESTART_COUNT_FILE):
|
|
os.remove(Bot._RESTART_COUNT_FILE)
|
|
assert Bot._bump_restart_count() == 1
|