"""``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())