Environment Setup: Two CARLA Servers on One Machine, Part 2
CARLA server bind 失败时的崩溃码 0xe06d7363 有确定含义:它是 MSVC 抛出的 C++ 异常的固定异常码(低三字节 0x6D7363 即 ASCII “msc”)。看到它等价于”某个 C++ 异常一路抛到顶层无人 catch,进程被终止”。这条信息本身不告诉你哪个异常,只告诉你”有异常没接住”——结合上下文(换端口偶尔能起),嫌疑指向 socket 层。
Shipping 版二进制的崩溃 dump 里只有裸地址,如 CarlaUnreal.exe+0x1a3f2c。要把地址翻译成人能读的函数名,需要配套的 PDB(Program Database)符号文件做符号化(symbolication):把地址减去模块加载基址得到 RVA(相对虚拟地址),再查 PDB 里的符号表映射回函数名与行号。本项目用 tools/t05_symbolize.py 完成这一步,崩溃栈一路定位到 socket 的 bind 调用,抛出的错误是 WSAEACCES。
两个 Winsock 错误码常被混为一谈,但指向完全不同的排查方向:
| 错误码 | 数值 | 含义 | 典型原因 |
|---|---|---|---|
WSAEADDRINUSE | 10048 | 地址被占用 | 另一个进程已 bind 同一端口 |
WSAEACCES | 10013 | 权限拒绝 | 端口落在系统保留段 / 防火墙策略 |
占用(INUSE)意味着”有个进程能被你找到并杀掉”;拒绝(ACCES)意味着”系统层面不允许你绑”,netstat 里查不到任何占用者。本项目拿到的正是 ACCES——这一下就把嫌疑从”谁占了端口”转到”端口本身有什么特殊”,为下一步 netsh 排查定对了方向。
同案的另一个教训:CARLA 崩溃 XML 里的 RHI.AdapterName 字段会在 bind 崩溃案里把人引向”显卡不对”的方向,白绕一大圈。后来固化的纪律是反过来的:server 启动即崩,先查端口保留段,再查显卡。
netsh 排除端口段netsh int ipv4 show excludedportrange tcp
它列出的是 TCP 排除端口段:被系统保留、普通进程无权 bind 的端口区间。当时实测输出的关键行:
Start Port End Port
---------- --------
1921 2020 <-- 整整 100 个,原规划 2000/2010 段全在里面
2269 4289
50000 50059
原规划的 UE5 2000–2002、UE4 2010–2012 六個端口,一个不漏全躺在 1921–2020 这一段里。
这是最容易误判的概念差:
netstat -ano 能查到 PID,杀掉即可。netstat 里空无一物,但任何进程 bind 即被内核拒绝,返回 WSAEACCES。所以”端口没被占用却绑不上”不是灵异事件,是排除段在起作用。排查清单的顺序应该是:netstat 查占用 → 查不到就 netsh ... excludedportrange 查保留,两步缺一不可。
排除段内的端口没有”解除保留给自己用”的可靠途径(可以试着 netsh int ipv4 add excludedportrange 反方向管理,但动系统保留等于和系统抢地盘)。正确做法是整体搬迁到保留段之外的间隙:本项目首迁到 UE5 2021–2023 / UE4 2031–2033,并约定每次大重启后复查该清单——因为保留段会漂移(详见 winNAT 根因篇)。
排除段的管理者是 winNAT 服务(WinNAT,Windows NAT 服务)。它为 Hyper-V 虚拟机、WSL2、Windows 容器的虚拟网络做地址转换。虚拟交换机工作时要把 VM 的连接映射到宿主协议栈,为避免 NAT 使用的临时端口撞车,winNAT 会向 TCP/IP 栈成段地动态预留端口,这些段就出现在 netsh ... excludedportrange 清单里。
装了 Hyper-V / WSL2 / Docker Desktop 的机器上,排除段是常态而不是异常。
WSAEACCES。端口规划三迁后的最终落位(写进 AGENTS.md 与 watchdog 配置):UE5 server 2161–2163,UE4 主实例 2171–2173,双实例并发时的 UE4 第二实例 2151 一组——落在已知保留段的间隙里。配套纪律:
netsh int ipv4 show excludedportrange tcp;-graphicsadapter 索引:两代引擎两套枚举CARLA server 的 -graphicsadapter=N 参数,选的是 DXGI 适配器枚举(IDXGIFactory::EnumAdapters)返回列表里的第 N 项。这个列表的顺序不是硬件拓扑顺序,而是枚举 API 的发现顺序——枚举范围内有什么、排第几,两代引擎口径不同:
-graphicsadapter=2 选中 RTX 5090(IddCx 类虚拟显示适配器不进它的 DXGI 枚举)。=0 才是 5090。同一套启动参数在两侧不能共用:把 UE5 侧验证过的 =2 照搬到 UE4,选中的就不是 5090。
选错卡不会报错,server 照常启动、照常出图,只是渲染落到了核显/虚拟卡上。实测指纹:
丢帧意味着仿真时钟在同步模式下大量等待,采出来的数据时间轴是坏的——安静地作废,事后才发现。
本机在册三块远程虚拟显示适配器(Todesk、向日葵 OrayIddDriver、Microsoft Remote Display)加一块 Intel 核显。更糟的是机器常经远程桌面访问,虚拟卡随会话插拔——RDP 连上/断开一次,枚举集合和顺序都可能变。索引在这种机器上天然是漂浮的,固定写死任何一个数字都只是在赌。
把 =0、=2 分别记给 UE4/UE5 两侧,是不是就稳了?CP0 复测否定了这个想法:WSL 重启后再起 UE5 server,同一个 =2 选到了 Microsoft Basic Render Driver(软件渲染兜底设备),直接崩溃(访问违例,异常偏移 0x18);改成 =0 才选中 5090。同样的参数,昨天对、今天错——adapter 索引是环境状态,不是常量,任何把索引当配置项写死的方案都在埋雷。
渲染是重负载,server 真在 5090 上跑,5090 的显存占用必然大涨——这是无法伪造的物理证据。启动流程因此改成”枚举 + 测量”闭环:
baseline;ADAPTER_CANDIDATES = [0, 2, 1, 3] 轮询拉起 server;used >= baseline + 4000(MB) 判选对了卡,否则杀掉换下一个索引。真实实现(tools/server_watchdog.py)的核心片段:
GPU_DELTA_MB = 4000 # 启动后 5090 显存须比基线多这么多,否则判选错卡
def launch_once(side, adapter):
baseline = gpu_used_mb() # 起 server 前的显存基线
ps(f"Start-Process ... '{cfg['cmd'].format(adapter=adapter)}' ...")
...
if server_alive(cfg["port"]):
used = gpu_used_mb()
if used < 0 or baseline < 0:
time.sleep(5); continue # 显存探测失败:重试,不误判
if used >= baseline + GPU_DELTA_MB:
return True # 增量达标,确认选中 5090
return False # RPC 通但显存没涨 → 选错卡
4GB 阈值来自实测:UE5 server Epic 720p 显存约 +9GB,UE4 约 +8.1GB,4GB 留足区分度又不误杀。注意一个双实例下的细节:watchdog 另有”显存绝对值 ≥5000MB”的 floor 校验,但双 UE4 实例同机时,另一侧的显存会把 floor 顶过线,掩盖本侧选错卡——所以 launch 时的增量校验才是主判据,绝对值只能当辅助。
方法论一句话:判卡靠测量,不靠猜。环境状态(索引)只用来发起尝试,裁决永远交给物理证据。
tools/wait_server.py 全文不到 30 行,但每一行都对应一个实测坑。完整代码:
#!/usr/bin/env python3
"""等 CARLA server RPC 就绪 + 校验 5090 被占用。用法: wait_server.py <port> [timeout_s]"""
import subprocess, sys, time
port = int(sys.argv[1]); timeout = float(sys.argv[2]) if len(sys.argv) > 2 else 180
host = subprocess.check_output("ip route show | awk '/default/{print $3}'",
shell=True).decode().strip() # ① 宿主 IP 动态获取
import carla
t0 = time.time()
while time.time() - t0 < timeout:
try:
c = carla.Client(host, port); c.set_timeout(5.0)
c.get_world().get_actors() # ② 真发一次 RPC,而不是只测端口通不通
break
except Exception:
time.sleep(5)
else:
print("TIMEOUT waiting RPC"); sys.exit(1)
# ③ 5090 占用校验:server 渲染后显存应明显超基线
out = subprocess.check_output(
["powershell.exe", "nvidia-smi.exe",
"--query-gpu=memory.used", "--format=csv,noheader"],
text=True).strip()
print(f"RPC ready in {time.time()-t0:.1f}s; nvidia-smi mem.used={out}")
used_mb = int(out.replace("MiB", "").strip())
if used_mb < 4000:
print("WARN: 显存未明显上升,可能选错卡(5090 未被 server 占用)")
sys.exit(2)
sys.exit(0)
① 宿主 IP 动态获取(行 6)。WSL2 NAT 下 localhost 连不到 Windows 侧的 server,且宿主 IP 随重启漂移,必须每次现取(取法与原因见宿主 IP 篇)。硬编码是被明令禁止的。
② 就绪判据是一次真 RPC(行 13)。get_world().get_actors() 失败(连接被拒、超时、world 未加载完)就重试到 180s 超时,退出码 1。只测 TCP 端口开放是不够的——端口通了 world 未必可用。
③ 显存校验与退出码 2(行 21–28)。从 WSL 里直接调 powershell.exe nvidia-smi.exe 查的是宿主侧的 GPU(WSL 里的 nvidia-smi 能力受限)。显存 < 4000MB 说明渲染没发生在 5090 上,退出码 2 让调用方能区分”超时”与”选错卡”两种失败。
0.10 的 Shipping 版 server 根本不写日志:加 -log 参数也无效,Saved 目录躺在 %LOCALAPPDATA%\CarlaUnreal\Saved 里也不含有效运行日志(UE4 0.9.15 窗口模式同样不写 CarlaUE4.log)。server 有没有选对卡、渲染健不健康,日志这条路被堵死,显存突变成了唯一可靠的判据。这个脚本的存在本身就是对”Shipping 版可观测性为零”的工程回应。
内存门禁测试的第一选择是 stress-ng(标准压力测试工具),但它需要 sudo 安装/运行,而本机的 sudo 要交互输密码——自动化链路里不能有任何等待人工输入的环节。于是改用 25 行 Python 兜底(tools/t05_ram_gate.py),效果等价。
nbytes = int(args.gb * (1 << 30))
buf = bytearray(nbytes) # 分配 N GiB
step = 4096 # 4K = 一个内存页
mv = memoryview(buf)
for off in range(0, nbytes, step):
mv[off] = 1 # 每页写一字节,强制 commit
核心不是 bytearray(nbytes),而是后面那个 4K 步进循环。现代 OS 的内存分配是惰性的:malloc/mmap 只在页表上登记虚拟地址,物理页要等第一次访问触发缺页异常才真正分配。如果只分配不触碰,30GB 可能根本没占物理内存,压测就压了个空。循环每隔 4096 字节写一次,保证每个物理页都被 commit——bytearray 的 zero-fill 初始化理论上已经触碰过,这里再走一遍是防御惰性分配语义的保险。
之后驻留 --hold-s 秒(默认 600),让内存在峰值状态保持住,而不是一闪而过。
测试编排:WSL 侧用脚本压满 28–30GB,同时在 Windows 侧起 UE5 server,盯宿主空闲内存,门禁线 6GB。语义是”把最坏情形人为制造出来,看宿主会不会贫血”,而不是测平时值。
实测两口径的结果:
| 口径 | 现场 | 宿主空闲 | 判定 |
|---|---|---|---|
.wslconfig 56GB | WSL 压 30GB + UE5 server | 2.9–3.2 GiB | FAIL(击穿 6GB 线) |
| 降档 32GB | WSL 压 28GB 驻留 4min + UE5 server | 12.9–14.5 GB | PASS,server 全程存活 |
降档后释压 60 秒再做冒烟回归:同步 41.9 FPS、RGB 零丢帧——证明降档只动内存预算,没碰伤仿真性能。规划期的纸面算术(56GB 不闭合)至此完成实测闭环。
第一代方案选了最顺手的 cron,结果三建三失灵:锚点异常(时间对不上)、到点触发不执行、最后调度器自身卡死。cron 在 WSL2 里依赖一个并不总可靠的调度守护进程,属于平台级黑盒——出了问题查不动、修不了。教训:关键链路的自愈不能依赖你控制不了的黑盒组件。第二代改为主会话内的 watcher 循环,逻辑全部可见可控。
| 层 | 监视对象 | 判据 |
|---|---|---|
| 第一层 | 总控任务 | 完成 or 死亡,终态唤醒主会话 |
| 第二层 | 结果产出 | 产物停滞即报警——不看进程看输出 |
| 第三层 | 守护链自身 | watcher 自己活着才算数(谁看门看门狗) |
配套的执行层是 guardian 脚本,做”停滞就杀”的粗活。
tools/t12_eval_guardian.sh 的核心逻辑:
latest=$(ls -t "$dir"/eval_attempt*.log 2>/dev/null | head -1)
idle=$(( $(date +%s) - $(stat -c %Y "$latest") )) # 最新 eval 日志的"未写时长"
if [ "$idle" -gt 1500 ]; then # 25min 无写 → 判停滞
pids=$(pgrep -f "leaderboard_evaluator.py.*--port=$port" | grep -vx $$)
kill $pids 2>/dev/null; sleep 5; kill -9 $pids 2>/dev/null
fi
三个数字都有出处:
conda run 的包装进程 cmdline 里也含 evaluator 参数串,pgrep -f 会双双匹配;只杀 head -1 会杀到包装壳,真 evaluator 漏杀、tee 管道不关,harness 反被卡死。grep -vx $$ 排除自己:guardian 脚本自己的 cmdline 也含模式串,不排除会把看门狗一起杀了。这套脚本的直接由来:CARLA server 挂起后 evaluator 撞 600s tick 超时,偶发死锁在清理段(futex_wait,进程不退)。harness 的自愈循环只在 evaluator 进程退出后才触发——进程不退,自愈永远不启动,一次白停 3.5 小时。guardian 的职责就是把”进程不肯自己死”这种情形外力终结,让自愈链条重新转起来。
“日志 25 分钟无写就杀”抓住的是死透了的情形。但 AutoMoT 全量评测时出了另一种死法:server 劣化后单条路线卡住,evaluator 的 stuck 提示一直在往日志里刷屏——进程活着、日志活跃、按静滞标准完全健康,实际上一条路线卡了 8.8 个小时,当批数据作废。
这暴露了一类检测盲区:活性(liveness)与进展(progress)是两回事。日志在写只证明进程没死,证明不了工作在推进。
第三代 guardian 把”进展”定义为可计数的业务事件——evaluator 每开始一条新路线,日志里会打一行 Preparing RouteScenario。数这个行数就是进展计数器:
# tools/t30_guardian.sh 核心逻辑
cnt=$(grep -c "Preparing RouteScenario" "$latest" ...) # 已开始的路线数
if [ -f "$STATE" ]; then
read last_cnt last_ts < "$STATE"
if [ "$cnt" = "$last_cnt" ] \
&& [ $(( now - last_ts )) -ge 3600 ] \ # 路线数 1h 没涨
&& [ "$age" -lt 600 ]; then # 且日志仍在活跃写入
# 活跃但停滞:杀 evaluator 触发自愈
kill_evals
elif [ "$cnt" != "$last_cnt" ]; then
echo "$cnt $now" > "$STATE" # 有进展:重置计时
fi
fi
三个条件是与的关系,缺一不可:
cnt 一小时没增长(无进展);age < 600 即日志最近 10 分钟内还在写(排除已经死透的——那种归静滞检测管);最终 guardian 是两条独立的检测并联:
| 检测 | 条件 | 抓的死法 |
|---|---|---|
| 静滞 | 最新日志 >25min 无写 | 进程死锁/挂起(futex_wait 类) |
| 活跃停滞 | 日志在写,但路线数 >1h 未涨 | server 劣化、单条卡死(stuck 刷屏类) |
方法论层面值得记住的一点:监控指标必须对齐”业务进展”,而不是”进程活性”。CPU 在跑、日志在写、心跳在跳,都可以与”一厘米没往前走”同时成立。