mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-17 10:56:49 +00:00
* Scheduler+API Bug - if an invalid timezone was set (through edit of watch or API) it could have crashed the scheduler, Added `timezone` to the official API docs * adding missing files
133 lines
4.9 KiB
Python
133 lines
4.9 KiB
Python
"""
|
|
Keep critical background threads alive.
|
|
|
|
A threading.Thread cannot be restarted. Once its target returns, the thread is
|
|
finished and calling start() again raises RuntimeError("threads can only be
|
|
started once"). There is no built-in "respawn on death" option. So recovery has
|
|
to be built into the target itself.
|
|
|
|
This matters because a background loop can die in two ways that look identical
|
|
from outside, and both are silent:
|
|
|
|
- it raises, and the traceback goes nowhere because nobody joins a daemon thread
|
|
- it simply `return`s, e.g. a stray `return False` deep inside the loop body
|
|
|
|
The second is how the ticker thread stopped scheduling every watch in the app:
|
|
one watch with an unresolvable timezone hit `return False`, the whole loop
|
|
exited, and no watch was ever checked again until the process restarted. Nothing
|
|
was logged above ERROR, and the process stayed up and healthy-looking.
|
|
|
|
supervise() wraps a target so that any exit which is NOT a real shutdown is
|
|
logged CRITICAL and the target is re-entered, with exponential backoff so a
|
|
target that fails instantly cannot spin the CPU.
|
|
"""
|
|
|
|
import time
|
|
|
|
from loguru import logger
|
|
|
|
# Back off 1, 2, 4 ... up to this many seconds between restarts.
|
|
DEFAULT_MAX_BACKOFF_SECONDS = 60
|
|
|
|
# A target that stayed up at least this long is considered to have recovered,
|
|
# so its backoff resets rather than continuing to grow.
|
|
HEALTHY_RUNTIME_SECONDS = 60
|
|
|
|
|
|
def supervise(target, name, exit_event, is_shutting_down=None,
|
|
max_backoff=DEFAULT_MAX_BACKOFF_SECONDS,
|
|
healthy_after=HEALTHY_RUNTIME_SECONDS,
|
|
_on_restart=None):
|
|
"""
|
|
Run `target` forever, restarting it if it ever returns or raises.
|
|
|
|
Never restarts once shutdown has begun - an exiting target is expected then,
|
|
and respawning it would fight the shutdown path and delay process exit.
|
|
|
|
Args:
|
|
target: zero-arg callable expected to loop until shutdown
|
|
name: thread name, used in log messages
|
|
exit_event: threading.Event - the primary shutdown signal
|
|
is_shutting_down: optional extra zero-arg predicate returning True during
|
|
shutdown. sigshutdown_handler() sets both app.config.exit
|
|
and datastore.stop_thread, so pass the latter here to be
|
|
certain a restart can never race a shutdown.
|
|
max_backoff: ceiling for the restart delay, in seconds
|
|
healthy_after: runtime after which the backoff resets to 1s
|
|
_on_restart: test hook, called with (restart_count, reason) before each retry
|
|
|
|
Returns:
|
|
None - only once shutdown is signalled
|
|
"""
|
|
def stopping():
|
|
if exit_event.is_set():
|
|
return True
|
|
if is_shutting_down is not None:
|
|
try:
|
|
return bool(is_shutting_down())
|
|
except Exception:
|
|
# A broken predicate must not keep a healthy thread from restarting
|
|
return False
|
|
return False
|
|
|
|
backoff = 1
|
|
restarts = 0
|
|
|
|
while not stopping():
|
|
started = time.monotonic()
|
|
reason = None
|
|
|
|
try:
|
|
target()
|
|
# Falling out of the target is only legitimate during shutdown.
|
|
if stopping():
|
|
break
|
|
reason = "returned unexpectedly (a stray `return` inside its loop?)"
|
|
logger.critical(
|
|
f"{name} {reason} - this thread must run until shutdown. Restarting it."
|
|
)
|
|
except Exception as e:
|
|
if stopping():
|
|
break
|
|
reason = f"crashed: {type(e).__name__}: {e}"
|
|
logger.opt(exception=True).critical(f"{name} {reason} - restarting it.")
|
|
|
|
ran_for = time.monotonic() - started
|
|
backoff = 1 if ran_for >= healthy_after else min(backoff * 2, max_backoff)
|
|
restarts += 1
|
|
|
|
if _on_restart:
|
|
_on_restart(restarts, reason)
|
|
|
|
logger.critical(
|
|
f"{name} restart #{restarts} in {backoff}s (previous run lasted {ran_for:.1f}s)"
|
|
)
|
|
# wait() returns True immediately once shutdown is requested
|
|
if exit_event.wait(backoff) or stopping():
|
|
break
|
|
|
|
logger.info(f"{name} supervisor exiting - shutdown requested "
|
|
f"(after {restarts} restart(s))")
|
|
|
|
|
|
def start_supervised_thread(target, name, exit_event, is_shutting_down=None, **kwargs):
|
|
"""
|
|
Start `target` in a daemon thread wrapped in supervise().
|
|
|
|
Returns:
|
|
threading.Thread: the started thread (unlike Thread(...).start(), which
|
|
returns None - assigning that to a module global is why nothing could
|
|
ever check the ticker thread's is_alive()).
|
|
"""
|
|
import threading
|
|
|
|
thread = threading.Thread(
|
|
target=supervise,
|
|
args=(target, name, exit_event),
|
|
kwargs={'is_shutting_down': is_shutting_down, **kwargs},
|
|
daemon=True,
|
|
name=name,
|
|
)
|
|
thread.start()
|
|
return thread
|