prevent concurrent bot instances
This commit is contained in:
parent
e7ca24e676
commit
fef8cfdbb8
2 changed files with 126 additions and 22 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,5 +13,6 @@ schedule_client.py
|
||||||
config.py
|
config.py
|
||||||
.env
|
.env
|
||||||
.venv
|
.venv
|
||||||
|
.bot.lock
|
||||||
/schedule_requests.json*
|
/schedule_requests.json*
|
||||||
/navigator_requests.json*
|
/navigator_requests.json*
|
||||||
|
|
|
||||||
147
main.py
147
main.py
|
|
@ -1,28 +1,131 @@
|
||||||
if __name__ == '__main__':
|
from __future__ import annotations
|
||||||
import os, sys, event, bot
|
|
||||||
from multiprocessing import Process
|
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
import models.logger
|
import os
|
||||||
# Reuse the interpreter that launched this process. Calling ``uv`` from
|
import signal
|
||||||
# a child shell breaks when uv was installed outside PATH (and also makes
|
import sys
|
||||||
# ``uv run main.py`` recurse through a second environment).
|
from multiprocessing import Process, get_context
|
||||||
CMD = 'python3.13.exe' if sys.platform == 'win32' else f'"{sys.executable}"'
|
from pathlib import Path
|
||||||
PROCESSES = [
|
from collections.abc import Callable
|
||||||
Process(target=bot.run if sys.platform == 'win32' else os.system, args=() if sys.platform == 'win32' else (f'{CMD} -c "import bot; bot.run()"',)),
|
from typing import BinaryIO
|
||||||
# Process(target=bot.run),
|
|
||||||
Process(target=event.run),
|
|
||||||
]
|
|
||||||
|
|
||||||
os.system('cls' if sys.platform == 'win32' else 'clear')
|
|
||||||
models.logger.log('Main', 'Startup...')
|
LOCK_PATH = Path(__file__).resolve().with_name(".bot.lock")
|
||||||
|
SHUTDOWN_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_instance_lock() -> BinaryIO:
|
||||||
|
"""Hold a process lock for this checkout until the last worker exits."""
|
||||||
|
lock_file = LOCK_PATH.open("a+b")
|
||||||
try:
|
try:
|
||||||
[process.start() for process in PROCESSES]
|
if os.name == "nt":
|
||||||
[process.join() for process in PROCESSES]
|
import msvcrt
|
||||||
|
|
||||||
|
lock_file.seek(0)
|
||||||
|
if lock_file.read(1) == b"":
|
||||||
|
lock_file.write(b"\0")
|
||||||
|
lock_file.flush()
|
||||||
|
lock_file.seek(0)
|
||||||
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except (BlockingIOError, OSError):
|
||||||
|
lock_file.close()
|
||||||
|
raise RuntimeError(f"another bot instance already holds {LOCK_PATH}") from None
|
||||||
|
|
||||||
|
lock_file.seek(0)
|
||||||
|
lock_file.truncate()
|
||||||
|
lock_file.write(f"{os.getpid()}\n".encode())
|
||||||
|
lock_file.flush()
|
||||||
|
return lock_file
|
||||||
|
|
||||||
|
|
||||||
|
def stop_processes(processes: list[Process]) -> None:
|
||||||
|
started = [process for process in processes if process.pid is not None]
|
||||||
|
for process in started:
|
||||||
|
if process.is_alive():
|
||||||
|
process.terminate()
|
||||||
|
for process in started:
|
||||||
|
process.join(SHUTDOWN_TIMEOUT)
|
||||||
|
for process in started:
|
||||||
|
if process.is_alive():
|
||||||
|
process.kill()
|
||||||
|
process.join()
|
||||||
|
|
||||||
|
|
||||||
|
def run_worker(target: Callable[[], None], lock_fd: int) -> None:
|
||||||
|
# Keep an explicit duplicate alive in the worker. A Linux flock belongs to
|
||||||
|
# the shared open-file description, so it remains held if the parent dies.
|
||||||
|
with os.fdopen(os.dup(lock_fd), "rb"):
|
||||||
|
target()
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> int:
|
||||||
|
# Import only after taking the lock so a rejected duplicate has no bot/event
|
||||||
|
# side effects. Linux workers explicitly retain the lock descriptor: if the
|
||||||
|
# supervisor dies unexpectedly, an orphan cannot poll alongside a replacement.
|
||||||
|
instance_lock = acquire_instance_lock()
|
||||||
|
import bot
|
||||||
|
import event
|
||||||
|
import models.logger
|
||||||
|
|
||||||
|
context = get_context("spawn" if sys.platform == "win32" else "fork")
|
||||||
|
if sys.platform == "win32":
|
||||||
|
processes = [
|
||||||
|
context.Process(target=bot.run, name="bot"),
|
||||||
|
context.Process(target=event.run, name="events"),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
processes = [
|
||||||
|
context.Process(
|
||||||
|
target=run_worker,
|
||||||
|
args=(bot.run, instance_lock.fileno()),
|
||||||
|
name="bot",
|
||||||
|
),
|
||||||
|
context.Process(
|
||||||
|
target=run_worker,
|
||||||
|
args=(event.run, instance_lock.fileno()),
|
||||||
|
name="events",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def request_shutdown(_signum, _frame) -> None:
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, request_shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, request_shutdown)
|
||||||
|
|
||||||
|
os.system("cls" if sys.platform == "win32" else "clear")
|
||||||
|
models.logger.log("Main", "Startup...")
|
||||||
|
try:
|
||||||
|
for process in processes:
|
||||||
|
process.start()
|
||||||
|
|
||||||
|
while all(process.is_alive() for process in processes):
|
||||||
|
for process in processes:
|
||||||
|
process.join(timeout=0.5)
|
||||||
|
|
||||||
|
stopped = next(process for process in processes if not process.is_alive())
|
||||||
|
models.logger.error(
|
||||||
|
"Main",
|
||||||
|
f"{stopped.name} process exited unexpectedly with code {stopped.exitcode}",
|
||||||
|
)
|
||||||
|
return 1
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
[process.terminate() for process in PROCESSES]
|
models.logger.error("Main", "Stopping...")
|
||||||
[process.join() for process in PROCESSES]
|
return 0
|
||||||
|
except Exception as error:
|
||||||
|
models.logger.error("Main", f"{error}")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
stop_processes(processes)
|
||||||
|
instance_lock.close()
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
models.logger.error('Main', f'{e}')
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(run())
|
||||||
|
except RuntimeError as error:
|
||||||
|
print(f"Bot startup refused: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue