动手前先修掉了两处「死机制」,新增的三个系统各自激活一个: * 天气:小麦 2 天成熟、初始水分 2、每天只掉 1 点,等于永远不需要浇水。 现在水分消耗由天气决定(晴 1 / 多云 0 / 干旱 2,雨天反补 3),而 `state.forecast` 把明天的天气提前告诉你——「今晚浇不浇」成了能算的决策。 5 种天气、权重合计 100、同种不连出;暴风雨会毁未熟作物,温室免疫。 * 谷仓:市场波动原本纯装饰(收获当场按市价变现,没有择时空间)。现在收获与 收取只进 `state.inventory`,靠新增的 `sell` 换金币,价格波动才真正参与决策。 仓满时 `_store()` 抛 `granary_full` 且不改状态,作物原样留在地里。 * 建筑:新增 `build`,买的是「以后不用天天手动做」——水井自动浇干地、 喂食器自动喂食(只吃库存饲料,绝不自动花钱)、温室、谷仓扩容。 每天在 `advance_day` 里先于生长执行,同类越造越贵(×1.6)。 存档升到 `SAVE_VERSION=2`,配 `MIN_SAVE_VERSION=1` 向后兼容,旧档缺的新字段 一律走默认值,不作废玩家手里的档。`advance_day(weather=...)` 是留给测试的 确定性入口。 顺带修两个真 bug: * `daemon.py` 三处 `makedirs(os.path.dirname(path))`:`--pid-file pid.txt` 这种只给文件名的用法会让 dirname 变成空串,启动阶段直接崩。抽出 `ensure_parent_dir()` 兜底。 * `RemoteFarm.snapshot()` 是本地缓存,非实时客户端轮询会一直读到旧数据, 看起来像服务器卡死。补 docstring 指明要自己调 `refresh()`。 质量门禁:ruff format/check 全绿、pyright 0 错误、503 项测试 OK(skip 35)、 bench 无回归(brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。 另起真实服务器(--tick 3 + 预置存档)跑了 38 项端到端验收,全过。
394 lines
15 KiB
Python
394 lines
15 KiB
Python
"""守护进程管理:把服务器丢到后台跑,然后像服务一样 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 socket
|
||
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
|
||
|
||
|
||
def port_busy(host: str, port: int, timeout: float = 0.6) -> bool:
|
||
"""端口上已经有程序在监听了吗。
|
||
|
||
用"能不能连上"判断,而不是"能不能 bind":Windows 上只要两边都设了
|
||
``SO_REUSEADDR``(uvicorn 会设),第二个进程也能绑上同一个
|
||
端口、然后抢走新连接——于是 bind 探测会给出一个自信的错误答案。
|
||
"""
|
||
|
||
if host in ("0.0.0.0", ""):
|
||
target = "127.0.0.1"
|
||
elif host == "::":
|
||
target = "::1"
|
||
else:
|
||
target = host
|
||
try:
|
||
with socket.create_connection((target, port), timeout=timeout):
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def ensure_parent_dir(path: str) -> None:
|
||
"""确保 ``path`` 所在的目录存在之后再动手写文件。
|
||
|
||
路径可能只给一个文件名(``--pid-file pid.txt``),此时
|
||
``os.path.dirname`` 返回空串,直接 ``makedirs`` 会以 WinError 3 /
|
||
FileNotFoundError 收场——启动阶段就崩,报错信息还只说"找不到路径"。
|
||
所以空目录部分要当成"当前目录",什么都不用建。
|
||
"""
|
||
parent = os.path.dirname(path)
|
||
if parent:
|
||
os.makedirs(parent, exist_ok=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:
|
||
ensure_parent_dir(self.pid_file)
|
||
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 _get_json(self, path: str, timeout: float, token: bool = False) -> dict[str, Any] | None:
|
||
"""访问自己这个服务的某个接口;连不上或回包不是 JSON 就返回 None。
|
||
|
||
一律走 :func:`farm.net.open_url`:本地回环请求不能被系统代理截走,
|
||
否则在设了 ``HTTP_PROXY`` 的机器上会得到莫名其妙的 502。
|
||
|
||
末尾那道 ``isinstance`` 是为了让返回类型名副其实——回包万一是个数组,
|
||
调用方的 ``.get(...)`` 会直接崩在属性访问上。
|
||
"""
|
||
url = f"http://{self.host}:{self.port}{path}"
|
||
if token and self.token:
|
||
url += "?token=" + urllib.parse.quote(self.token)
|
||
try:
|
||
with net.open_url(url, timeout=timeout) as response:
|
||
payload = json.loads(response.read().decode("utf-8"))
|
||
except (urllib.error.URLError, OSError, ValueError):
|
||
return None
|
||
return payload if isinstance(payload, dict) else None
|
||
|
||
def probe(self, timeout: float = 1.5) -> dict[str, Any] | None:
|
||
"""访问 /api/health,成功说明服务真的在监听。"""
|
||
return self._get_json("/api/health", timeout)
|
||
|
||
def world(self, timeout: float = 2.0) -> dict[str, Any] | None:
|
||
return self._get_json("/api/world", timeout, token=True)
|
||
|
||
def port_busy(self) -> bool:
|
||
"""端口被别的程序占了吗(用于给出"换端口"的提示)。"""
|
||
return port_busy(self.host, self.port)
|
||
|
||
# ------------------------------------------------------------ 启动
|
||
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"
|
||
|
||
ensure_parent_dir(self.log_file)
|
||
ensure_parent_dir(self.pid_file)
|
||
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" 停止 uv run 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 []
|