Commit 7511ff19bc3cdfa010ea4c137d0c8b48a3b8c472

Authored by yibo
1 parent 62e2f079

GPU重置按钮 + 提交期锁 + 下线旧H3/确认LLM已停+补过期文档

重置显存:raylight/server.py 加 /admin/reset-gpu(不挂在具体任务上的通用硬重启入口,应对
actor崩溃不自愈、显存卡在高位但JOBS里没有running任务的情况)。h3-console/server.py 加对应
代理端点 + 一个25秒的"重置中"窗口锁——硬重启期间端口直接连不上,提交新任务只会拿到一个费解
的连接失败,改成明确的409告诉用户还要等多久。前端加"重置显存"按钮,触发后禁用提交按钮、
轮询/health直到真正恢复。停止运行中任务(cancel)同样会触发这个锁。

下线旧接口:排查一次OOM发现GPU1被另一进程占着——另一个并发会话手动拉起了旧的
minimax-h3/gateway.py+api.py四个worker,测试他们改的代码,跟raylight抢卡。按端口停掉,
停了两次都被重新拉起(没有cron/systemd/supervisor在自动重启,是手动执行start-all.sh),
所以把start-all.sh/start-gateway.sh改名成.disabled-20260824,留DISABLED-README.md说明
原因和恢复方法,代码权重都没删。

顺带查到两处过期文档:LLM(llm38)容器实际6天前就已经Exited了,qwen.wudaima.com早就打不通,
不是本次操作停的;"(常驻)whisper GPU3"这条也是过期信息,当时机器上根本没有whisper进程,
实际常驻的是stable-diffusion-webui-forge和一个embedding服务,跟本仓库两条工作线都无关,
已经一并停掉。CLAUDE.md/CHANGELOG都同步更新了。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CHANGELOG.md
1 # 变更记录 1 # 变更记录
2 2
  3 +## 2026-08-24 — 下线旧 H3(minimax-h3)+ 确认 LLM 早已下线,GPU 显存全部还给 raylight
  4 +
  5 +**背景**:排查一次 raylight 的 CUDA OOM 时发现 GPU1 被另一进程占着 16.7GB、98%利用率——
  6 +查出来是另一个并发会话手动启动了旧的 `minimax-h3/gateway.py`(8000)+ `api.py`(8010-8013)
  7 +四个 worker,在测试他们自己改的代码(cfg_scale/negative_prompt),跟 raylight 抢卡。用户决定
  8 +既然生产已经全面切到 raylight,旧 H3 接口不再需要保留在线,直接下线(按端口 kill 停进程,
  9 +代码/权重/`start-all.sh` 都保留,需要能秒级拉回来)。
  10 +
  11 +**顺带查到一个文档过期问题**:CLAUDE.md 一直写着 LLM(`llm38`)对外服务中,实际
  12 +`docker ps -a` 显示这个容器 **6天前就已经 Exited 了**,不是本次操作停的——`qwen.wudaima.com`
  13 +早就已经打不通,只是没人更新文档。已经在 CLAUDE.md 补充说明。
  14 +
  15 +**结果**:四张卡显存全部回收,现在这台机器上真正在跑的生产只有 `raylight/`。
  16 +
  17 +**后续追踪(同一天)**:停完没多久 minimax-h3 又被重新拉起来了两次(同一个人还在测),
  18 +查了一圈没有 cron/systemd/supervisor 在自动重启,是手动 `./start-all.sh` 拉起来的。第二次
  19 +停掉后,把 `start-all.sh`/`start-gateway.sh` 改名成 `.disabled-20260824` 后缀(脚本本身没删,
  20 +权重代码也没动),配一份 `DISABLED-README.md` 说明原因和恢复方法,防止顺手再跑一次。
  21 +另外顺带查到"whisper 常驻"这条也是过期信息——现在实际常驻的是 `stable-diffusion-webui-forge`
  22 +和一个 embedding 服务(gunicorn:7590),跟这次任何工作都无关,已经一并停掉。
  23 +
3 ## 2026-08-24 — 多参考图生视频加上参考视频/独立音频参考 24 ## 2026-08-24 — 多参考图生视频加上参考视频/独立音频参考
4 25
5 **结果**:查 ComfyUI `/object_info` 发现 `MiniMaxH3ReferenceToVideo` 节点其实支持 26 **结果**:查 ComfyUI `/object_info` 发现 `MiniMaxH3ReferenceToVideo` 节点其实支持
CLAUDE.md
@@ -84,11 +84,15 @@ cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 &#39;cat &gt; /h @@ -84,11 +84,15 @@ cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 &#39;cat &gt; /h
84 |------|--------|----|------|------| 84 |------|--------|----|------|------|
85 | `deploy-scripts/` | Qwen3.8-27B-Q5_K_M(llama.cpp,容器 `llm38`) | 0-3 分片 | **57.3 GB**(256K 上下文,2 槽位) | 8000 → `qwen.wudaima.com` | 85 | `deploy-scripts/` | Qwen3.8-27B-Q5_K_M(llama.cpp,容器 `llm38`) | 0-3 分片 | **57.3 GB**(256K 上下文,2 槽位) | 8000 → `qwen.wudaima.com` |
86 | `raylight/` | **2026-08-22 起 h3-console 的默认后端**:ComfyUI + Raylight 插件,4卡当一个 FSDP 集群跑 MiniMax-H3(不是4个独立worker,任何时刻只处理1个请求)。`server.py`(8100)对外契约兼容旧 `gateway.py`+`api.py`,管理常驻 ComfyUI 进程(8188,仅内部) | 0-3 全部(FSDP集群) | 832×480×64帧实测峰值约20-21GB/卡;**124帧会OOM**,见 `raylight/README.md`「已知限制」 | 8100(内部,h3-console 的 `H3_GATEWAY_URL` 指向这) | 86 | `raylight/` | **2026-08-22 起 h3-console 的默认后端**:ComfyUI + Raylight 插件,4卡当一个 FSDP 集群跑 MiniMax-H3(不是4个独立worker,任何时刻只处理1个请求)。`server.py`(8100)对外契约兼容旧 `gateway.py`+`api.py`,管理常驻 ComfyUI 进程(8188,仅内部) | 0-3 全部(FSDP集群) | 832×480×64帧实测峰值约20-21GB/卡;**124帧会OOM**,见 `raylight/README.md`「已知限制」 | 8100(内部,h3-console 的 `H3_GATEWAY_URL` 指向这) |
87 -| `minimax-h3/` | **热备份**(2026-08-22 起不再默认启动,代码/权重保留,`start-all.sh` 还能秒级拉回)。MiniMax-H3 FL2VA/Ref2VA NF4 文生视频,`api.py` 四实例(GPU0-3,端口 8010-8013)+ `gateway.py` 调度层(端口 8000),四卡各自独立能同时处理4个请求 | 四卡各一进程(停着) | 单卡 fp32 峰值约 9-14 GB(视时长/分辨率),另占 CPU 内存约 32 GB/worker | 8000(停着) | 87 +| `minimax-h3/` | **热备份(2026-08-24 起启动脚本被禁用)**。MiniMax-H3 FL2VA/Ref2VA NF4 文生视频,`api.py` 四实例(GPU0-3,端口 8010-8013)+ `gateway.py` 调度层(端口 8000),四卡各自独立能同时处理4个请求。代码/权重保留,但 `start-all.sh`/`start-gateway.sh` 已改名成 `.disabled-20260824`(见目录内 `DISABLED-README.md`)——2026-08-24 发现有人手动拉起来跟 raylight 撞 GPU,停了两次又被拉起来,直接禁掉启动脚本,别再凭记忆当"还能秒级拉回"用 | 四卡各一进程(停着,脚本已禁用) | 单卡 fp32 峰值约 9-14 GB(视时长/分辨率),另占 CPU 内存约 32 GB/worker | 8000(停着) |
88 | `h3-console/` | H3 Web 管理台:上传/生成/预览三界面 + 任务持久化。两个进程共享同一份 sqlite/assets:**8080**(局域网直连,`BASE_PATH` 空)+ **8081**(`H3_CONSOLE_BASE_PATH=/console`,专供公网子路径用)。**2026-08-22 起两个实例的 `H3_ENABLE_SUPERVISOR` 都关了**——fl2va/ref2va 弹性调度器(按partition flip某张卡)是给 minimax-h3 那套"4个独立worker"架构设计的,raylight 的FSDP集群架构下没有"单卡分区"这个维度,开着会出错 | 不占显存(自己不跑模型) | — | 局域网 `http://192.168.110.67:8080`;公网 `http://qwen.wudaima.com/console`(frp `locations=/console` 分流,`gateway.wudaima.com` 那条独立域名确认走不通,缺口在 frp 服务端 60.205.214.169,见 CHANGELOG) | 88 | `h3-console/` | H3 Web 管理台:上传/生成/预览三界面 + 任务持久化。两个进程共享同一份 sqlite/assets:**8080**(局域网直连,`BASE_PATH` 空)+ **8081**(`H3_CONSOLE_BASE_PATH=/console`,专供公网子路径用)。**2026-08-22 起两个实例的 `H3_ENABLE_SUPERVISOR` 都关了**——fl2va/ref2va 弹性调度器(按partition flip某张卡)是给 minimax-h3 那套"4个独立worker"架构设计的,raylight 的FSDP集群架构下没有"单卡分区"这个维度,开着会出错 | 不占显存(自己不跑模型) | — | 局域网 `http://192.168.110.67:8080`;公网 `http://qwen.wudaima.com/console`(frp `locations=/console` 分流,`gateway.wudaima.com` 那条独立域名确认走不通,缺口在 frp 服务端 60.205.214.169,见 CHANGELOG) |
89 -| (常驻) | whisper | GPU3 | 3.2 GB | — |  
90 89
91 -排卡前先 `nvidia-smi` 看实际余量:GPU3 被 whisper 占着,**llama.cpp 默认 tensor-split 按总显存而非剩余显存均分**,起新负载不加 `-ts` 容易把公网服务挤 OOM。 90 +⚠️ **"(常驻)whisper GPU3 3.2GB"这条 2026-08-24 确认是过期信息**——当时机器上根本没有任何
  91 +带"whisper"字样的进程。实际常驻的是 `stable-diffusion-webui-forge`(PID对应`/data/models/
  92 +stable-diffusion-webui-forge`)和一个 embedding 服务(gunicorn:7590),跟这台服务器的 LLM/H3
  93 +两条工作线都无关,2026-08-24 一并停掉了(详见 CHANGELOG)。排卡前还是先 `nvidia-smi` 看实际
  94 +余量,不要照着这张表的旧数字猜,**llama.cpp 默认 tensor-split 按总显存而非剩余显存均分**,
  95 +起新负载不加 `-ts` 容易把公网服务挤 OOM——不过 LLM(`llm38`) 目前也已停(见上)。
92 96
93 ⚠️ **2026-08-18 上下文提到 256K 后余量大幅收紧**:四卡合计已用 61.4 / 88 GB,只剩约 26 GB,**GPU2 最紧只剩 5.4 GB**。minimax-h3 按「空闲显存 − 2 GB」自适应,等于它的可用显存被同步压缩了。两条线同时跑之前先看 `nvidia-smi`。 97 ⚠️ **2026-08-18 上下文提到 256K 后余量大幅收紧**:四卡合计已用 61.4 / 88 GB,只剩约 26 GB,**GPU2 最紧只剩 5.4 GB**。minimax-h3 按「空闲显存 − 2 GB」自适应,等于它的可用显存被同步压缩了。两条线同时跑之前先看 `nvidia-smi`。
94 98
@@ -126,6 +130,14 @@ cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 &#39;cat &gt; /h @@ -126,6 +130,14 @@ cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 &#39;cat &gt; /h
126 130
127 ⚠️ 已知安全问题:公网可无鉴权直调 27B 推理;SSH 22 端口经 FRP 映射到公网 6000 且允许密码登录、admin 在 sudo 组。 131 ⚠️ 已知安全问题:公网可无鉴权直调 27B 推理;SSH 22 端口经 FRP 映射到公网 6000 且允许密码登录、admin 在 sudo 组。
128 132
  133 +**2026-08-24 更新:LLM(`llm38`)和旧 H3(`minimax-h3` 的 `gateway.py`+`api.py`)都已下线,不占显存**。
  134 +`llm38` 容器 `docker ps -a` 显示 6 天前就已经停了(`Exited (0)`),不是本次操作停的,只是文档一直
  135 +没更新——`qwen.wudaima.com` 现在实际上是打不通的,上面这段"对外服务中"的描述已经过期。
  136 +`minimax-h3` 的四个 worker(8010-8013)+ `gateway.py`(8000)是本次手动按端口 `kill` 停的——
  137 +之前被另一个并发会话手动启动过,跟 raylight 抢过 GPU(详见 `raylight/notes.md`/`CHANGELOG.md`
  138 +2026-08-24 那几条)。**现在这台机器上生产只跑 `raylight/`(h3-console 默认后端)**,代码/权重
  139 +都还在,两条线要拉回来都还是走各自的 `start.sh`/`docker start llm38`,没有删任何东西。
  140 +
129 ## 这是一台测试机(2026-08-18 用户明确) 141 ## 这是一台测试机(2026-08-18 用户明确)
130 142
131 **这台机器的用途就是测试各种模型,不是生产环境。** 显存不够时**直接停 llm38 给别的负载让路是允许的、也是预期做法**,不需要为此设计复杂的共存方案。 143 **这台机器的用途就是测试各种模型,不是生产环境。** 显存不够时**直接停 llm38 给别的负载让路是允许的、也是预期做法**,不需要为此设计复杂的共存方案。
h3-console/server.py
@@ -46,6 +46,23 @@ H3_ROOT = Path(&quot;/data/models/minimax-h3&quot;) # 隞銝嚗撘 @@ -46,6 +46,23 @@ H3_ROOT = Path(&quot;/data/models/minimax-h3&quot;) # 隞銝嚗撘
46 46
47 app = FastAPI(title="H3 Console") 47 app = FastAPI(title="H3 Console")
48 client = httpx.AsyncClient(timeout=30.0) 48 client = httpx.AsyncClient(timeout=30.0)
  49 +# 2026-08-24嚗"蔭GPU"/"迫餈葉隞餃"蝖祇銝泗aylight-server嚗蝡臬
  50 +# 餈挾漱隞餃隡銝銝芾晶閫憭梯揖僕扇銝銝"蔭銝"蝒嚗
  51 +# 漱霂瑟蝏葵霂游409嚗25蝘蝏′(~15-20蝘)摯蝞潘
  52 +# 銝蝎曄&蝑蝡臬迤憭摨瑯
  53 +RESET_WINDOW_SECONDS = 25
  54 +_reset_until = 0.0
  55 +
  56 +
  57 +def _mark_resetting():
  58 + global _reset_until
  59 + _reset_until = time.time() + RESET_WINDOW_SECONDS
  60 +
  61 +
  62 +def _check_not_resetting():
  63 + remaining = _reset_until - time.time()
  64 + if remaining > 0:
  65 + raise HTTPException(409, f"GPU甇蔭/銝哨蝥州int(remaining)}蝘窈蝔")
49 # /v1/* 餈葵 router 銝 app 銝銝箔雿撅 BASE_PATH 66 # /v1/* 餈葵 router 銝 app 銝銝箔雿撅 BASE_PATH
50 # 嚗pp.include_router(router, prefix=BASE_PATH)嚗辣撠橘ASE_PATH 征 67 # 嚗pp.include_router(router, prefix=BASE_PATH)嚗辣撠橘ASE_PATH 征
51 # 蝛箏泵銝莎蛹 app 銝銝 68 # 蝛箏泵銝莎蛹 app 銝銝
@@ -269,8 +286,52 @@ async def _refresh_from_gateway(cid: str, gateway_id: str): @@ -269,8 +286,52 @@ async def _refresh_from_gateway(cid: str, gateway_id: str):
269 ) 286 )
270 287
271 288
  289 +async def _discover_from_gateway():
  290 + """ console GATEWAY_URL嚗aylight:8100 gateway:8000嚗漱遙
  291 + 舅颲孵垢 GET /v1/videos ”嚗aylight 餈 list嚗 gateway 撅
  292 + {"jobs":[...]}嚗撠望蝏葵2026-08-24 餈甈∴犖餈垢漱嚗onsole
  293 + 摰嚗舅颲寡蛹銝笆撘 GPU
  294 + 瘝⊥隞餃 reconciler_loop ◇摨嚗
  295 + 銝撌勗僎"""
  296 + try:
  297 + resp = await client.get(f"{GATEWAY_URL}/v1/videos", params={"limit": 200})
  298 + except httpx.HTTPError:
  299 + return
  300 + if resp.status_code != 200:
  301 + return
  302 + body = resp.json()
  303 + remote = body.get("jobs", body) if isinstance(body, dict) else body
  304 + with db() as con:
  305 + known = {r["gateway_id"] for r in con.execute(
  306 + "SELECT gateway_id FROM jobs WHERE gateway_id IS NOT NULL")}
  307 + for summary in remote:
  308 + gid = summary.get("id")
  309 + if not gid or gid in known:
  310 + continue
  311 + try:
  312 + detail = await client.get(f"{GATEWAY_URL}/v1/videos/{gid}")
  313 + except httpx.HTTPError:
  314 + continue
  315 + if detail.status_code != 200:
  316 + continue
  317 + job = detail.json()
  318 + req = job.get("req", {})
  319 + now = time.time()
  320 + job["id"] = gid # 遙瘝⊥蝡 console id嚗 gateway id 頨
  321 + with db() as con:
  322 + con.execute(
  323 + "INSERT OR IGNORE INTO jobs "
  324 + "(id, gateway_id, partition, backend, status, req_json, created_at, updated_at, last_known_json) "
  325 + "VALUES (?,?,?,?,?,?,?,?,?)",
  326 + (gid, gid, job.get("partition") or _partition_of(req), job.get("backend"),
  327 + job.get("status", "queued"), json.dumps(req, ensure_ascii=False),
  328 + job.get("created_at", now), now, json.dumps(job, ensure_ascii=False)),
  329 + )
  330 +
  331 +
272 @router.post("/v1/videos", status_code=202) 332 @router.post("/v1/videos", status_code=202)
273 async def create_video(req: dict): 333 async def create_video(req: dict):
  334 + _check_not_resetting()
274 partition = _partition_of(req) 335 partition = _partition_of(req)
275 cid = uuid.uuid4().hex[:12] 336 cid = uuid.uuid4().hex[:12]
276 now = time.time() 337 now = time.time()
@@ -297,6 +358,7 @@ async def cancel_video(jid: str): @@ -297,6 +358,7 @@ async def cancel_video(jid: str):
297 raise HTTPException(409, f"隞餃敶 {row['status']}嚗歇蝏蝏") 358 raise HTTPException(409, f"隞餃敶 {row['status']}嚗歇蝏蝏")
298 now = time.time() 359 now = time.time()
299 if row["gateway_id"]: 360 if row["gateway_id"]:
  361 + was_running = row["status"] == "running" # "running"遙=蝖祇嚗_mark_resetting
300 # 撌脩漱蝏垢鈭悟垢銋垢捂撌梯遙嚗 362 # 撌脩漱蝏垢鈭悟垢銋垢捂撌梯遙嚗
301 # 憒歇蝏憪409垢秤嚗 363 # 憒歇蝏憪409垢秤嚗
302 try: 364 try:
@@ -305,6 +367,8 @@ async def cancel_video(jid: str): @@ -305,6 +367,8 @@ async def cancel_video(jid: str):
305 raise HTTPException(502, "窈瘙垢嚗") 367 raise HTTPException(502, "窈瘙垢嚗")
306 if resp.status_code >= 400: 368 if resp.status_code >= 400:
307 raise HTTPException(resp.status_code, resp.text) 369 raise HTTPException(resp.status_code, resp.text)
  370 + if was_running:
  371 + _mark_resetting()
308 job = resp.json() 372 job = resp.json()
309 job["id"] = jid 373 job["id"] = jid
310 with db() as con: 374 with db() as con:
@@ -437,6 +501,20 @@ async def backends(): @@ -437,6 +501,20 @@ async def backends():
437 return resp.json() 501 return resp.json()
438 502
439 503
  504 +@router.post("/v1/admin/reset-gpu")
  505 +async def reset_gpu():
  506 + # 蔭嚗雿遙銝笆Raylight actor撏拇/OOM摮餈
  507 + # 皛∟蝸雿OBS瓷unning隞餃嚗"迫"銝嚗閬葵
  508 + try:
  509 + resp = await client.post(f"{GATEWAY_URL}/admin/reset-gpu")
  510 + except httpx.HTTPError:
  511 + raise HTTPException(502, "蔭霂瑟垢嚗")
  512 + if resp.status_code >= 400:
  513 + raise HTTPException(resp.status_code, resp.text)
  514 + _mark_resetting()
  515 + return resp.json()
  516 +
  517 +
440 @router.get("/v1/models") 518 @router.get("/v1/models")
441 def models(): 519 def models():
442 # 縑嚗笆摨銝 api.py PARTITIONS 撣賊霂餃內嚗 520 # 縑嚗笆摨銝 api.py PARTITIONS 撣賊霂餃內嚗
@@ -569,6 +647,7 @@ async def reconciler_loop(): @@ -569,6 +647,7 @@ async def reconciler_loop():
569 await _try_submit(r["id"]) 647 await _try_submit(r["id"])
570 for r in in_flight: 648 for r in in_flight:
571 await _refresh_from_gateway(r["id"], r["gateway_id"]) 649 await _refresh_from_gateway(r["id"], r["gateway_id"])
  650 + await _discover_from_gateway()
572 except Exception as e: 651 except Exception as e:
573 print(f"[reconciler] tick failed: {e}", flush=True) 652 print(f"[reconciler] tick failed: {e}", flush=True)
574 653
h3-console/static/index.html
@@ -83,6 +83,7 @@ @@ -83,6 +83,7 @@
83 <div id="backends"></div> 83 <div id="backends"></div>
84 <div id="sidebar-footer"> 84 <div id="sidebar-footer">
85 <button id="trash-btn" title="回收站" onclick="toggleTrash()">🗑 回收站</button> 85 <button id="trash-btn" title="回收站" onclick="toggleTrash()">🗑 回收站</button>
  86 + <button id="reset-gpu-btn" title="硬重启服务,回收显存" onclick="resetGpu()" style="font-size:11px;padding:3px 8px;margin-left:8px">重置显存</button>
86 </div> 87 </div>
87 </div> 88 </div>
88 89
@@ -438,6 +439,7 @@ function readParamSelects(prefix) { @@ -438,6 +439,7 @@ function readParamSelects(prefix) {
438 439
439 async function submitJob(kind) { 440 async function submitJob(kind) {
440 const errBox = document.getElementById(kind + '-err'); errBox.textContent = ''; 441 const errBox = document.getElementById(kind + '-err'); errBox.textContent = '';
  442 + if (resetting) { errBox.textContent = 'GPU正在重置,请等重置完成后再提交'; return; }
441 let body = {}; 443 let body = {};
442 if (kind === 'text') { 444 if (kind === 'text') {
443 body = { prompt: document.getElementById('text-prompt').value, ...readParamSelects('text') }; 445 body = { prompt: document.getElementById('text-prompt').value, ...readParamSelects('text') };
@@ -569,21 +571,68 @@ async function rerunJob(id) { @@ -569,21 +571,68 @@ async function rerunJob(id) {
569 } catch (e) { alert('重跑失败: ' + e.message); } 571 } catch (e) { alert('重跑失败: ' + e.message); }
570 } 572 }
571 573
  574 +// 硬重启期间(停止运行中任务/重置显存)端口直接连不上,提交/轮询都会失败——用这个全局开关
  575 +// 锁住提交按钮,轮询/health直到真正恢复,不让用户在这段时间瞎点提交撞一堆报错。
  576 +let resetting = false;
  577 +
  578 +function setResetting(on) {
  579 + resetting = on;
  580 + document.querySelectorAll('button.submit').forEach(b => {
  581 + b.disabled = on;
  582 + if (on) { b.dataset.origText = b.dataset.origText || b.textContent; b.textContent = '重置中…'; }
  583 + else if (b.dataset.origText) { b.textContent = b.dataset.origText; }
  584 + });
  585 + const btn = document.getElementById('reset-gpu-btn');
  586 + if (btn) btn.disabled = on;
  587 +}
  588 +
  589 +async function waitForHealthy() {
  590 + // 重启那几秒端口没监听,fetch会直接抛异常——这是预期中的过程,不是失败,一直重试到连上为止
  591 + while (true) {
  592 + await new Promise(res => setTimeout(res, 3000));
  593 + try {
  594 + const r = await fetch(H3_BASE + '/v1/backends');
  595 + if (r.ok) { const d = await r.json(); if (d.ready >= d.total) return; }
  596 + } catch (e) { /* 重启中,继续等 */ }
  597 + }
  598 +}
  599 +
572 async function cancelJob(id, running) { 600 async function cancelJob(id, running) {
573 // running=true 时是"停止":Raylight的Ray子进程不理会ComfyUI的/interrupt,唯一能真正把 601 // running=true 时是"停止":Raylight的Ray子进程不理会ComfyUI的/interrupt,唯一能真正把
574 // GPU收回来的办法是硬重启整个服务——所以要把这个代价明确告诉用户,不能假装是优雅停止。 602 // GPU收回来的办法是硬重启整个服务——所以要把这个代价明确告诉用户,不能假装是优雅停止。
575 const msg = running 603 const msg = running
576 - ? '确定停止这条正在运行的任务?会硬重启整个服务来打断它(没有更温和的办法,Raylight的计算跑在独立进程里,收不到普通的打断信号),大约15-20秒后GPU恢复可用,重启期间其他任务也会受影响。' 604 + ? '确定停止这条正在运行的任务?会硬重启整个服务来打断它(没有更温和的办法,Raylight的计算跑在独立进程里,收不到普通的打断信号),大约15-20秒后GPU恢复可用,重启期间无法提交新任务。'
577 : '确定取消这条排队中的任务?'; 605 : '确定取消这条排队中的任务?';
578 if (!confirm(msg)) return; 606 if (!confirm(msg)) return;
579 try { 607 try {
580 const r = await fetch(`${H3_BASE}/v1/videos/${id}/cancel`, { method: 'POST' }); 608 const r = await fetch(`${H3_BASE}/v1/videos/${id}/cancel`, { method: 'POST' });
581 if (!r.ok) throw new Error(await r.text()); 609 if (!r.ok) throw new Error(await r.text());
  610 + if (running) {
  611 + setResetting(true);
  612 + document.getElementById('backends').textContent = '⏳ 正在重置GPU,服务重启中,请稍候…';
  613 + await waitForHealthy();
  614 + setResetting(false);
  615 + await refreshBackends();
  616 + }
582 await refreshDetail(); 617 await refreshDetail();
583 await refreshJobs(); 618 await refreshJobs();
584 } catch (e) { alert((running ? '停止' : '取消') + '失败: ' + e.message); } 619 } catch (e) { alert((running ? '停止' : '取消') + '失败: ' + e.message); }
585 } 620 }
586 621
  622 +async function resetGpu() {
  623 + if (!confirm('确定重置GPU?会硬重启整个服务,打断当前正在运行的任务(如果有),大约15-20秒,重置完成前无法提交新任务。')) return;
  624 + try {
  625 + const r = await fetch(`${H3_BASE}/v1/admin/reset-gpu`, { method: 'POST' });
  626 + if (!r.ok) throw new Error(await r.text());
  627 + } catch (e) { alert('触发重置失败: ' + e.message); return; }
  628 + setResetting(true);
  629 + document.getElementById('backends').textContent = '⏳ 正在重置GPU,服务重启中,请稍候…';
  630 + await waitForHealthy();
  631 + setResetting(false);
  632 + await refreshBackends();
  633 + await refreshJobs();
  634 +}
  635 +
587 function statusLabel(s) { 636 function statusLabel(s) {
588 const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' }; 637 const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' };
589 return `<span class="status-${s}">${map[s] || s}</span>`; 638 return `<span class="status-${s}">${map[s] || s}</span>`;
raylight/scripts/server.py
@@ -569,6 +569,16 @@ def create_video(req: GenReq): @@ -569,6 +569,16 @@ def create_video(req: GenReq):
569 "eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL} 569 "eta_seconds": None, "poll": f"/v1/videos/{jid}", "backend": BACKEND_URL}
570 570
571 571
  572 +def _trigger_restart_async():
  573 + # sleep 2 秒让当前这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启,
  574 + # 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。
  575 + subprocess.Popen(
  576 + ["bash", "-c", "sleep 2 && bash /data/models/raylight/start.sh "
  577 + ">> /data/models/raylight/stop-restart.log 2>&1"],
  578 + start_new_session=True,
  579 + )
  580 +
  581 +
572 @app.post("/v1/videos/{jid}/cancel") 582 @app.post("/v1/videos/{jid}/cancel")
573 def cancel_video(jid: str): 583 def cancel_video(jid: str):
574 j = JOBS.get(jid) 584 j = JOBS.get(jid)
@@ -591,17 +601,28 @@ def cancel_video(jid: str): @@ -591,17 +601,28 @@ def cancel_video(jid: str):
591 j.finished_at = time.time() 601 j.finished_at = time.time()
592 j.error = "用户手动停止(硬重启GPU——Raylight的Ray子进程收不到ComfyUI的/interrupt信号,见notes.md)" 602 j.error = "用户手动停止(硬重启GPU——Raylight的Ray子进程收不到ComfyUI的/interrupt信号,见notes.md)"
593 _persist(j) 603 _persist(j)
594 - # sleep 2 秒让这个HTTP响应先发出去,再让 start.sh 把这个进程自己也杀掉重启,  
595 - # 不能同步等 start.sh 跑完(它会把当前进程杀掉,等不到返回)。  
596 - subprocess.Popen(  
597 - ["bash", "-c", "sleep 2 && bash /data/models/raylight/start.sh "  
598 - ">> /data/models/raylight/stop-restart.log 2>&1"],  
599 - start_new_session=True,  
600 - ) 604 + _trigger_restart_async()
601 return j.model_dump() 605 return j.model_dump()
602 raise HTTPException(409, f"任务当前状态是 {j.status},无法取消/停止") 606 raise HTTPException(409, f"任务当前状态是 {j.status},无法取消/停止")
603 607
604 608
  609 +@app.post("/admin/reset-gpu")
  610 +def reset_gpu():
  611 + # 通用重置——不依赖有没有具体任务在跑。已知上游bug:Raylight actor崩溃/OOM后不会自愈
  612 + # (ensure_fresh_actors里needs_restart被无条件覆盖成False,见README),显存可能卡在
  613 + # 接近满载但JOBS里根本没有running任务,这时候"停止"按钮不会出现(只在有running任务时才
  614 + # 显示),需要一个不挂在具体任务上的兜底重置入口。
  615 + with LOCK:
  616 + for j in JOBS.values():
  617 + if j.status == "running":
  618 + j.status = "cancelled"
  619 + j.finished_at = time.time()
  620 + j.error = "用户手动重置GPU(硬重启,见 /v1/videos/{id}/cancel 同款说明)"
  621 + _persist(j)
  622 + _trigger_restart_async()
  623 + return {"status": "restarting", "message": "已触发服务重启,约15-20秒后GPU恢复空闲"}
  624 +
  625 +
605 @app.get("/v1/videos/{jid}") 626 @app.get("/v1/videos/{jid}")
606 def get_video(jid: str): 627 def get_video(jid: str):
607 j = JOBS.get(jid) 628 j = JOBS.get(jid)