Files
SimpleFarm/farm/server/daemon.py
T
snowy-yang fcd4cb4649 style: 引入 ruff,474 项告警清零
配置见 pyproject.toml:
- line-length=100:贴着现有代码定。若用默认的 88 会重排 122 行,
  噪音淹没真正有价值的修复。
- 启用 E/W/F/I/UP/B/C4/SIM/RET/PERF/RUF;**不**启用 PTH。因为本项目的路径
  多是"字符串身份"——要塞进 subprocess 命令行、写进 JSON 存档、打印给用户,
  全换 pathlib 只是包一层再拆一层,换来 70 处纯风格改动。
- 忽略 RUF001/002/003:中文全角标点是正确写法,不是"歧义字符"。
- 忽略 PERF203:剩下两处都是"逐项容错"语义(逐个牧场存档要立刻上报失败、
  收获全部要跳过失败地块)。把 try 提到循环外会毁掉"一项失败不影响其它项"
  这个行为,是错的优化。

自动修复 358 项:Dict/List/Optional 换成 PEP 585/604 写法、导入排序、
失效的 noqa、空占位 f-string 等。

手工修复的(这些是真问题,不是风格偏好):
- B904 x9:except 中 raise 补异常链。刻意区分——网络/IO 失败用 from exc 保留
  根因;参数解析失败用 from None,因为底层 ValueError 对玩家只是噪音。
- RUF012 x2:StateView 的 plots/animals 原本是**类级共享可变列表**,
  改成在 __init__ 里初始化(apply({}) 会提前返回,所以必须显式摆好)。
- F841:TUI 里一个赋值后再没用过的死变量。
- SIM105 x11:try/except/pass 统一改成 contextlib.suppress。
- SIM115:日志句柄改用 with。Popen 已把 fd 复制给子进程,父进程可安全关闭。
- E501 x7:拆超长行;main.py 那处顺手提取变量,避免超长嵌套表达式。
- E741:把 l/r 改成 left_line/right_line。
- 其余:RUF046 / PERF401 / RET504 / RUF005 / UP031 / B007。

ruff format 重排 24 个文件。

验证:97 项测试全绿。
2026-09-18 21:18:22 +08:00

363 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""守护进程管理:把服务器丢到后台跑,然后像服务一样 start / stop / status。
为什么不用 nohup 或 systemd
---------------------------
需求是"允许后台运行",而这个项目要能在 Windows / macOS / Linux 上都直接跑,
不能依赖某个平台的服务管理器。所以这里用最朴素也最通用的办法:
* ``subprocess.Popen`` 拉起一个**脱离父进程**的子进程(POSIX 用新会话,
Windows 用 DETACHED_PROCESS),父进程退出后它继续活着;
* 用 ``run/server.pid`` 记住它的 pid,用 ``run/server.log`` 收走它的输出;
* 停的时候先试着走 HTTP 优雅停机(能落盘),失败再发信号。
这套做法不依赖任何第三方库,也不需要管理员权限。
"""
from __future__ import annotations
import contextlib
import json
import os
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
from .. import net
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
RUN_DIR = os.path.join(PROJECT_ROOT, "run")
DEFAULT_PID_FILE = os.path.join(RUN_DIR, "server.pid")
DEFAULT_LOG_FILE = os.path.join(RUN_DIR, "server.log")
_STILL_ACTIVE = 259
def pid_alive(pid: int) -> bool:
"""跨平台地判断一个 pid 是否还活着。
注意:Windows 上 ``os.kill(pid, 0)`` 会**真的**把进程杀掉
(CPython 用 TerminateProcess 实现),所以这里必须走 Win32 API。
"""
if pid <= 0:
return False
if os.name == "nt":
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
if not handle:
return False
try:
code = ctypes.c_ulong()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
return False
return code.value == _STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
except (OSError, ProcessLookupError):
return False
return True
class DaemonManager:
"""负责在后台拉起/停止 ``farm.server`` 进程。"""
def __init__(
self,
host: str = "127.0.0.1",
port: int = 8765,
token: str | None = None,
tick: float = 15.0,
save_dir: str = "",
pid_file: str = DEFAULT_PID_FILE,
log_file: str = DEFAULT_LOG_FILE,
python: str | None = None,
) -> None:
self.host = host
self.port = port
self.token = token
self.tick = tick
self.save_dir = save_dir or os.path.join(PROJECT_ROOT, "saves")
# 防御式默认:调用方传 None 时用标准位置,绝不让 pid_file 变成 None
self.pid_file = pid_file or DEFAULT_PID_FILE
self.log_file = log_file or DEFAULT_LOG_FILE
self.python = python or sys.executable
# ------------------------------------------------------------ pid 文件
def read_pid(self) -> int | None:
try:
with open(self.pid_file, encoding="utf-8") as handle:
return int(handle.read().strip())
except (OSError, ValueError):
return None
def write_pid(self, pid: int) -> None:
os.makedirs(os.path.dirname(self.pid_file), exist_ok=True)
with open(self.pid_file, "w", encoding="utf-8") as handle:
handle.write(str(pid))
def clear_pid(self) -> None:
with contextlib.suppress(OSError):
os.remove(self.pid_file)
def current(self) -> int | None:
"""返回正在运行的服务器 pid;pid 文件过期则顺手清理。"""
pid = self.read_pid()
if pid is None:
return None
if pid_alive(pid):
return pid
self.clear_pid()
return None
# ------------------------------------------------------------ 探测
def probe(self, timeout: float = 1.5) -> dict[str, Any] | None:
"""访问 /api/health,成功说明服务真的在监听。
用 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
否则在设了 ``HTTP_PROXY`` 的机器上会得到莫名其妙的 502。
"""
url = f"http://{self.host}:{self.port}/api/health"
try:
with net.open_url(url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError):
return None
def world(self, timeout: float = 2.0) -> dict[str, Any] | None:
url = f"http://{self.host}:{self.port}/api/world"
if self.token:
url += "?token=" + urllib.parse.quote(self.token)
try:
with net.open_url(url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError):
return None
def port_busy(self) -> bool:
"""端口被别的程序占了吗(用于给出"换端口"的提示)。"""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.6)
return (
sock.connect_ex((self.host if self.host != "0.0.0.0" else "127.0.0.1", self.port))
== 0
)
# ------------------------------------------------------------ 启动
def command(self) -> list[str]:
args = [
self.python,
"-m",
"farm.server",
"--host",
self.host,
"--port",
str(self.port),
"--tick",
str(self.tick),
"--save-dir",
self.save_dir,
# 关键:pid 文件由**服务进程自己**写。
# Windows 上 venv 的 python.exe 可能是个转发器,父进程拿到的 pid
# 未必是真正在监听端口的那个进程,写错 pid 会导致 stop 杀错人。
"--pid-file",
self.pid_file,
]
if self.token:
args += ["--token", self.token]
return args
def start(self, wait: float = 12.0) -> tuple[bool, str]:
existing = self.current()
if existing is not None:
return False, f"服务器已经在运行(pid {existing}),无需重复启动"
if self.probe() is not None:
return False, f"端口 {self.port} 上已经有一个 SimpleFarm 服务器在跑"
if self.port_busy():
return False, f"端口 {self.port} 被其它程序占用,请改用 --port 8899"
os.makedirs(os.path.dirname(self.log_file), exist_ok=True)
os.makedirs(os.path.dirname(self.pid_file), exist_ok=True)
self.clear_pid() # 清掉上一次留下的陈旧 pid
env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONPATH"] = PROJECT_ROOT + os.pathsep + env.get("PYTHONPATH", "")
kwargs: dict[str, Any] = {
"cwd": PROJECT_ROOT,
"env": env,
"stdin": subprocess.DEVNULL,
}
if os.name == "nt":
# DETACHED_PROCESS:父进程退出后子进程继续活;
# CREATE_NEW_PROCESS_GROUP:方便单独给它发 Ctrl+Break
kwargs["creationflags"] = (
subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
else:
kwargs["start_new_session"] = True
# 日志交给子进程自己写文件:父进程退出后文件描述符依然有效。
# 用 with 管理句柄:Popen 已经把 fd 复制给子进程,父进程这边可以安全关闭。
with open(self.log_file, "a", encoding="utf-8") as log_handle:
try:
process = subprocess.Popen(
self.command(),
stdout=log_handle,
stderr=log_handle,
**kwargs,
)
except OSError as exc:
return False, f"启动失败:{exc}"
# 等它真的能响应,而不是"进程起来了但端口还没监听"
deadline = time.time() + wait
while time.time() < deadline:
if self.probe() is not None:
pid = self.current() or process.pid
return True, (
f"服务器已在后台启动(pid {pid})\n"
f" 网页 http://{self.host}:{self.port}/\n"
f" 日志 {self.log_file}\n"
f" 停止 python main.py stop"
)
if process.poll() is not None and self.read_pid() is None:
return False, (
f"服务器进程启动后立刻退出(退出码 {process.returncode})。"
f"日志:{self.log_file}"
)
time.sleep(0.25)
return False, (f"服务器进程已启动但 {wait:.0f} 秒内没响应。请查看日志:{self.log_file}")
# ------------------------------------------------------------ 停止
def stop(self, force: bool = False) -> tuple[bool, str]:
pid = self.current()
online = self.probe() is not None
if pid is None and not online:
self.clear_pid()
return True, "服务器当前没有运行"
# 先请它自己优雅退出:这条路会把所有牧场写进存档
if online and self._request_shutdown() and self._wait_offline(pid, 8.0):
self.clear_pid()
return True, f"服务器已优雅停机(pid {pid or '未知'},所有牧场已存档)"
if pid is None:
return False, (
f"服务器仍在 {self.host}:{self.port} 上响应,但 pid 文件里没有记录,"
"无法确认该结束哪个进程。请手动处理,或用 docker/任务管理器结束它。"
)
try:
os.kill(pid, signal.SIGTERM)
except OSError as exc:
self.clear_pid()
return False, f"发送停止信号失败:{exc}"
if self._wait_offline(pid, 5.0):
self.clear_pid()
return True, f"服务器已停止(pid {pid})"
if force and os.name == "nt":
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True, check=False
)
if self._wait_offline(pid, 3.0):
self.clear_pid()
return True, f"服务器已强制结束(pid {pid})"
elif force:
with contextlib.suppress(OSError):
os.kill(pid, signal.SIGKILL)
if self._wait_offline(pid, 3.0):
self.clear_pid()
return True, f"服务器已强制结束(pid {pid})"
return False, (
f"进程 {pid} 没有在超时内退出。"
+ ("" if force else "可加 --force 强制结束。")
+ f" 日志:{self.log_file}"
)
def restart(self) -> tuple[bool, str]:
stopped, message = self.stop()
if not stopped:
return False, message
return self.start()
def _request_shutdown(self) -> bool:
url = f"http://{self.host}:{self.port}/api/admin/shutdown"
if self.token:
url += "?token=" + urllib.parse.quote(self.token)
request = urllib.request.Request(
url, data=b"{}", method="POST", headers={"Content-Type": "application/json"}
)
try:
with net.open_url(request, timeout=3.0) as response:
return response.status == 200
except (urllib.error.URLError, OSError):
return False
@staticmethod
def _wait_gone(pid: int, seconds: float) -> bool:
deadline = time.time() + seconds
while time.time() < deadline:
if not pid_alive(pid):
return True
time.sleep(0.2)
return not pid_alive(pid)
def _wait_offline(self, pid: int | None, seconds: float) -> bool:
"""等到进程消失 **且** 端口不再响应。
两件事都要等:进程可能已经死了但端口还被 TIME_WAIT 占着,
也可能 pid 文件缺失(拿不到 pid),那就只看端口。
"""
deadline = time.time() + seconds
while time.time() < deadline:
process_gone = pid is None or not pid_alive(pid)
if process_gone and self.probe(timeout=0.6) is None:
return True
time.sleep(0.25)
process_gone = pid is None or not pid_alive(pid)
return process_gone and self.probe(timeout=0.6) is None
# ------------------------------------------------------------ 状态
def status(self) -> dict[str, Any]:
pid = self.current()
health = self.probe()
info: dict[str, Any] = {
"pid": pid,
"running": pid is not None or health is not None,
"host": self.host,
"port": self.port,
"log_file": self.log_file,
"pid_file": self.pid_file,
"health": health,
}
if health:
info["url"] = f"http://{self.host}:{self.port}/"
info["farms"] = health.get("farms")
info["clients"] = health.get("clients")
info["uptime"] = health.get("uptime")
return info
def tail(self, lines: int = 40) -> list[str]:
try:
with open(self.log_file, encoding="utf-8", errors="replace") as handle:
return handle.read().splitlines()[-lines:]
except OSError:
return []