Files
SimpleFarm/farm/server/__main__.py
T
snowy-yang f8e093c98f feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径
三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。

实时通道:WebSocket -> SSE
- hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye,
  250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。
- 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 +
  客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。
- 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。

服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2
- 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式
  http="zttp",否则 Config.load() 直接 raise。
- 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖),
  不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。
- 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例
  「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。
- 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被
  except OSError 吞掉,导致每次正常退出都留 stale pid 文件。

节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」
- DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。
- engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉,
  时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。
- 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。

键盘:修复方向键/空格/Tab/ESC 全部静默失效
- keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串
  (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。
- 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。

测试:重建 500 项
- test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 /
  test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑,
  所以是全绿而不是靠跳过伪装。
- 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」
  (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要
  预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。

优化:先量后改,依据与前后数字都在 tools/bench.py
- 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化
  整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、
  GET /api/world -31%、POST /api/actions?full=1 -16%。
- 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、
  build_parser 与 available_actions 分组、__main__.main 160->7 行。
- 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的
  broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json;
  main.py 的 _add_endpoint_args/_leaderboard_lines。
- tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。

门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
2026-09-19 12:55:45 +08:00

315 lines
13 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.
"""``uv run python -m farm.server`` —— 服务器进程入口(前台运行)。
ASGI 服务器用 uvicorn,并打开它的 **HTTP/2** 支持(``http="zttp"`` +
``http2=True``):同一个端口既说 HTTP/1.1 也说 HTTP/2(TLS 走 ALPN,明文走
h2c prior-knowledge),`curl`、浏览器、AI 脚本各取所需,应用代码一行都不用改。
为什么 HTTP/2 要选 ``zttp``:uvicorn 自己不再内置 h2,它的 HTTP/2 实现建立在
``zttp`` 这个包上(见 ``uvicorn/protocols/http/zttp_h2_impl.py``)。装上之后
``AutoZttpProtocol`` 会在一条监听上自动分流两种协议,所以选它**不会**丢掉
HTTP/1.1 能力。
守护进程模式会由 :class:`farm.server.daemon.DaemonManager` 用同样的命令行
再拉起一个本模块的实例,只是不带 ``--daemon``。
"""
from __future__ import annotations
import argparse
import os
import sys
from types import FrameType
from .. import config
from .daemon import (
DEFAULT_LOG_FILE,
DEFAULT_PID_FILE,
PROJECT_ROOT,
DaemonManager,
port_busy,
)
# 优雅停机的上限(秒)。SSE 是**永不自己结束**的长连接,停机时我们会先用
# hub.close_all() 让每个流自己收尾;这个值只是兜底:万一有客户端赖着不走
# (比如浏览器标签页没关),到点就取消剩下的连接任务,绝不让 stop 卡死。
GRACE_TIMEOUT = 5.0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="uv run python -m farm.server",
description="SimpleFarm 游戏服务器:HTTP 接口给 AI/脚本,SSE 给实时界面。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--host", default="127.0.0.1", help="监听地址,默认 127.0.0.1;局域网联机用 0.0.0.0"
)
parser.add_argument("--port", type=int, default=8765, help="监听端口,默认 8765")
parser.add_argument("--token", default=None, help="访问令牌,设置后所有接口都要带")
parser.add_argument(
"--tick",
type=float,
default=config.DEFAULT_TICK_SECONDS,
help=f"每多少秒推进一天,默认 {config.DEFAULT_TICK_SECONDS:.0f} 秒",
)
parser.add_argument("--save-dir", default=None, help="存档目录,默认 <项目根>/saves")
parser.add_argument(
"--save", dest="save_file", default=None, help="兼容旧参数:指定 default 牧场的存档文件"
)
parser.add_argument("--max-sessions", type=int, default=64, help="同时在线的牧场上限")
parser.add_argument(
"--idle-timeout",
type=float,
default=1800.0,
help="无人访问多少秒后把牧场移出内存(存档保留),默认 1800",
)
parser.add_argument("--world", default="SimpleFarm 公共农场", help="世界名称")
parser.add_argument(
"--log-level", default="info", choices=["critical", "error", "warning", "info", "debug"]
)
parser.add_argument("--access-log", action="store_true", help="打印每个 HTTP 请求")
parser.add_argument("--no-remote-control", action="store_true", help="禁止通过接口停机")
parser.add_argument(
"--daemon", action="store_true", help="转到后台运行(等价于 uv run main.py serve --daemon)"
)
parser.add_argument(
"--pid-file", default=None, help="把本进程 pid 写到该文件(守护进程模式会自动指定)"
)
parser.add_argument("--log-file", default=DEFAULT_LOG_FILE, help=argparse.SUPPRESS)
return parser
def clamp_tick(value: float) -> float:
return max(config.MIN_TICK_SECONDS, min(config.MAX_TICK_SECONDS, float(value)))
def dependency_hint() -> str:
return (
"缺少运行服务器所需的依赖。请先安装:\n"
" uv sync\n"
"如果不想用 uv,也可以用 pip:\n"
" pip install 'uvicorn[standard]' fastapi zttp\n"
f"\n当前解释器:{sys.executable}"
)
def clear_pid_file(path: str | None) -> None:
"""删掉自己写的 pid 文件,避免下次 status 看到"幽灵进程"。
只删内容确实是本进程 pid 的那个:万一文件已经被别的实例接管,
删掉它等于把别人的记录抹了。
顺序有讲究:必须**先读完、关掉句柄,再 os.remove**。Windows 上删一个还开着的
文件会抛 ``PermissionError``(WinError 32),而它会被下面的 ``except OSError``
吞掉——结果就是 pid 文件永远留在磁盘上(这个坑在本项目里真实存在过,
做端到端停机验证时才被发现)。
"""
if not path:
return
try:
if not os.path.exists(path):
return
with open(path, encoding="utf-8") as handle:
content = handle.read().strip()
if content == str(os.getpid()):
os.remove(path)
except OSError:
pass
def _resolve_save_dir(args) -> str:
"""存档目录:显式 --save-dir 优先,其次老参数 --save 所在目录,最后项目根/saves。"""
save_dir = args.save_dir or os.path.join(PROJECT_ROOT, "saves")
if args.save_file:
save_dir = os.path.dirname(os.path.abspath(args.save_file)) or save_dir
return save_dir
def _start_daemon(args, save_dir: str) -> int:
"""把服务丢到后台,父进程立刻返回(真正在监听的是子进程)。"""
manager = DaemonManager(
host=args.host,
port=args.port,
token=args.token,
tick=clamp_tick(args.tick),
save_dir=save_dir,
pid_file=args.pid_file or DEFAULT_PID_FILE,
log_file=args.log_file or DEFAULT_LOG_FILE,
)
ok, message = manager.start()
print(message)
return 0 if ok else 1
def _print_startup_banner(cfg, save_dir: str) -> None:
print(f"SimpleFarm 服务器启动中(pid {os.getpid()})…")
print(f" 监听 http://{cfg.host}:{cfg.port}/")
print(f" 存档目录 {save_dir}")
print(f" 实时通道 http://{cfg.host}:{cfg.port}/api/stream?farm=<牧场名>(SSE)")
print(" ASGI uvicorn(同一端口协商 HTTP/1.1 与 HTTP/2,HTTP/2 由 zttp 提供)")
if cfg.token:
print(" 已启用访问令牌:请求需带 X-API-Token 头或 ?token= 参数")
def _ensure_port_free(host: str, port: int) -> int | None:
"""端口自检:可用返回 ``None``,被别人占着则打印提示并返回退出码 3。
必须用"能不能连上"来判断,而不是"能不能 bind":Windows 上只要两边都设了
SO_REUSEADDR(uvicorn 会设),第二个进程也能在已被监听的端口上"绑定成功"
(然后抢走新连接),于是静默把第一个实例顶掉——这种事故比启动失败难查得多。
"""
if not port_busy(host, port):
return None
print(
f"启动失败:{host}:{port} 已经有程序在监听(可能是另一个 SimpleFarm 实例)。\n"
f" 换一个端口试试:--port {port + 1}",
file=sys.stderr,
)
return 3
def _run_foreground(args, save_dir: str) -> int:
"""在本进程里把 ASGI 服务器跑到退出,返回进程退出码。"""
# uvicorn 只在真的要跑服务时才需要——`main.py solo` 这类离线玩法不该被迫装它,
# 所以延迟到这一步才导入,失败时给出可照抄的安装命令。
try:
from uvicorn import Config as UvicornConfig
from uvicorn import Server as UvicornServer
except ImportError:
print(dependency_hint(), file=sys.stderr)
return 2
from .app import ServerConfig, ServerHandle, as_asgi, create_app
cfg = ServerConfig(
host=args.host,
port=args.port,
token=args.token,
tick_seconds=clamp_tick(args.tick),
save_dir=save_dir,
max_sessions=max(1, args.max_sessions),
idle_timeout=max(60.0, args.idle_timeout),
world_name=args.world,
allow_remote_control=not args.no_remote_control,
)
# 先看端口有没有人占着。
busy_code = _ensure_port_free(cfg.host, cfg.port)
if busy_code is not None:
return busy_code
app = create_app(cfg)
handle = app.state.handle
uv_config = UvicornConfig(
as_asgi(app),
host=cfg.host,
port=cfg.port,
log_level=args.log_level,
access_log=args.access_log,
# HTTP/2 必须在配置里显式打开:http="zttp" 选出 AutoZttpProtocol,
# http2=True 才让它真的启用 h2(uvicorn 只认 zttp 这个后端)。
http="zttp",
http2=True,
# 与 graceful shutdown 的语义一致:等连接自己收尾的最长时间
timeout_graceful_shutdown=int(GRACE_TIMEOUT),
)
# 服务器子类就地定义:覆盖 handle_exit 得继承 uvicorn.Server,而 uvicorn 是
# 延迟导入的(见上),写在模块顶层会让离线玩法也依赖它。
class FarmServer(UvicornServer):
"""uvicorn 服务器 + SimpleFarm 的停机语义。
唯一改动是 :meth:`handle_exit`(uvicorn 通过 ``capture_signals`` 把它接到
SIGINT / SIGTERM / SIGBREAK 上)。默认实现只置 ``should_exit``,那样 SSE
长连接收不到 ``bye``,只能干等 ``timeout_graceful_shutdown`` 到点被强杀——
实测白等满 5 秒。这里改成先 ``handle.request_stop()``:每个流立刻自己收尾,
再由 ``on_stop`` 回调置位 ``should_exit``。于是「接口停机」与「信号停机」
走的是同一条路径。
"""
# 由下面在实例化之后注入(放在这里是为了不动基类 __init__ 的签名)
handle: ServerHandle
def __init__(self, config: UvicornConfig) -> None:
super().__init__(config)
self._interrupts = 0
def handle_exit(self, sig: int, frame: FrameType | None) -> None:
self._interrupts += 1
if self._interrupts > 1:
# 第二次信号:用户不想再等了,放弃优雅收尾,直接退
print("\n再次收到中断信号,强制退出。", file=sys.stderr, flush=True)
os._exit(130)
# 故意不调 super():uvicorn 的版本只置 should_exit,而我们的停机钩子
# 会做同样的事,还要额外让 SSE 流主动收尾。另外保持 _captured_signals
# 为空,退出时就不会把信号重放一遍。
self.handle.request_stop()
server = FarmServer(uv_config)
server.handle = handle
# 两条停机路径汇到同一个地方:
# • POST /api/admin/shutdown → handle.request_stop()
# • Ctrl+C / SIGTERM / SIGBREAK → FarmServer.handle_exit → handle.request_stop()
# request_stop() 既让 SSE 流自己收尾(生成器看 handle.stopping),
# 也经由这个回调把 should_exit 置位,uvicorn 于是开始优雅退出。
handle.on_stop(lambda: setattr(server, "should_exit", True))
# pid 文件由**本进程**写:父进程(守护进程管理器)拿到的 pid 未必是真正
# 在监听端口的那个(Windows 上 venv 的 python.exe 可能是转发器)。
# uvicorn 的顺序是"先跑 lifespan(也就是这个钩子)、后 bind 端口",
# 所以这里只保证"服务已启动",不保证"端口已监听";万一 bind 失败,
# 下面的 finally 会把 pid 文件删掉,不留幽灵。
def write_pid_file() -> None:
if not args.pid_file:
return
try:
os.makedirs(os.path.dirname(args.pid_file) or ".", exist_ok=True)
with open(args.pid_file, "w", encoding="utf-8") as file_handle:
file_handle.write(str(os.getpid()))
except OSError as exc:
print(f"⚠ 无法写入 pid 文件 {args.pid_file}:{exc}", file=sys.stderr)
handle.on_start(write_pid_file)
_print_startup_banner(cfg, save_dir)
status = 0
try:
server.run()
except OSError as exc:
print(
f"启动失败:{exc}\n"
f" 端口 {cfg.port} 可能已被占用。换一个端口试试:--port {cfg.port + 1}",
file=sys.stderr,
)
status = 3
except SystemExit as exc:
# uvicorn 的 lifespan 跑在 bind 之前,监听失败时它自己 sys.exit(3)
code = exc.code
status = code if isinstance(code, int) and code else 3
print(
f"启动失败:无法监听 {cfg.host}:{cfg.port}(细节见上面的日志)。\n"
f" 换一个端口试试:--port {cfg.port + 1}",
file=sys.stderr,
)
finally:
clear_pid_file(args.pid_file)
if status == 0:
print("服务器已停止。")
return status
def main(argv=None) -> int:
"""入口:要么转后台(父进程立刻返回),要么就在本进程里把服务跑起来。"""
args = build_parser().parse_args(argv)
save_dir = _resolve_save_dir(args)
if args.daemon:
return _start_daemon(args, save_dir)
return _run_foreground(args, save_dir)
if __name__ == "__main__":
sys.exit(main())