Commit 7511ff19bc3cdfa010ea4c137d0c8b48a3b8c472
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>
Showing
5 changed files
with
193 additions
and
11 deletions
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 | 24 | ## 2026-08-24 — 多参考图生视频加上参考视频/独立音频参考 |
| 4 | 25 | |
| 5 | 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 'cat > /h |
| 84 | 84 | |------|--------|----|------|------| |
| 85 | 85 | | `deploy-scripts/` | Qwen3.8-27B-Q5_K_M(llama.cpp,容器 `llm38`) | 0-3 分片 | **57.3 GB**(256K 上下文,2 槽位) | 8000 → `qwen.wudaima.com` | |
| 86 | 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 | 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 | 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 'cat > /h |
| 126 | 130 | |
| 127 | 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 | 141 | ## 这是一台测试机(2026-08-18 用户明确) |
| 130 | 142 | |
| 131 | 143 | **这台机器的用途就是测试各种模型,不是生产环境。** 显存不够时**直接停 llm38 给别的负载让路是允许的、也是预期做法**,不需要为此设计复杂的共存方案。 | ... | ... |
h3-console/server.py
| ... | ... | @@ -46,6 +46,23 @@ H3_ROOT = Path("/data/models/minimax-h3") # 隞銝嚗撘 |
| 46 | 46 | |
| 47 | 47 | app = FastAPI(title="H3 Console") |
| 48 | 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 | 66 | # /v1/* 餈葵 router 銝 app 銝銝箔雿撅 BASE_PATH |
| 50 | 67 | # 嚗pp.include_router(router, prefix=BASE_PATH)嚗辣撠橘ASE_PATH 征 |
| 51 | 68 | # 蝛箏泵銝莎蛹 app 銝銝 |
| ... | ... | @@ -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 | 332 | @router.post("/v1/videos", status_code=202) |
| 273 | 333 | async def create_video(req: dict): |
| 334 | + _check_not_resetting() | |
| 274 | 335 | partition = _partition_of(req) |
| 275 | 336 | cid = uuid.uuid4().hex[:12] |
| 276 | 337 | now = time.time() |
| ... | ... | @@ -297,6 +358,7 @@ async def cancel_video(jid: str): |
| 297 | 358 | raise HTTPException(409, f"隞餃敶 {row['status']}嚗歇蝏蝏") |
| 298 | 359 | now = time.time() |
| 299 | 360 | if row["gateway_id"]: |
| 361 | + was_running = row["status"] == "running" # "running"遙=蝖祇嚗_mark_resetting | |
| 300 | 362 | # 撌脩漱蝏垢鈭悟垢銋垢捂撌梯遙嚗 |
| 301 | 363 | # 憒歇蝏憪409垢秤嚗 |
| 302 | 364 | try: |
| ... | ... | @@ -305,6 +367,8 @@ async def cancel_video(jid: str): |
| 305 | 367 | raise HTTPException(502, "窈瘙垢嚗") |
| 306 | 368 | if resp.status_code >= 400: |
| 307 | 369 | raise HTTPException(resp.status_code, resp.text) |
| 370 | + if was_running: | |
| 371 | + _mark_resetting() | |
| 308 | 372 | job = resp.json() |
| 309 | 373 | job["id"] = jid |
| 310 | 374 | with db() as con: |
| ... | ... | @@ -437,6 +501,20 @@ async def backends(): |
| 437 | 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 | 518 | @router.get("/v1/models") |
| 441 | 519 | def models(): |
| 442 | 520 | # 縑嚗笆摨銝 api.py PARTITIONS 撣賊霂餃內嚗 |
| ... | ... | @@ -569,6 +647,7 @@ async def reconciler_loop(): |
| 569 | 647 | await _try_submit(r["id"]) |
| 570 | 648 | for r in in_flight: |
| 571 | 649 | await _refresh_from_gateway(r["id"], r["gateway_id"]) |
| 650 | + await _discover_from_gateway() | |
| 572 | 651 | except Exception as e: |
| 573 | 652 | print(f"[reconciler] tick failed: {e}", flush=True) |
| 574 | 653 | ... | ... |
h3-console/static/index.html
| ... | ... | @@ -83,6 +83,7 @@ |
| 83 | 83 | <div id="backends"></div> |
| 84 | 84 | <div id="sidebar-footer"> |
| 85 | 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 | 87 | </div> |
| 87 | 88 | </div> |
| 88 | 89 | |
| ... | ... | @@ -438,6 +439,7 @@ function readParamSelects(prefix) { |
| 438 | 439 | |
| 439 | 440 | async function submitJob(kind) { |
| 440 | 441 | const errBox = document.getElementById(kind + '-err'); errBox.textContent = ''; |
| 442 | + if (resetting) { errBox.textContent = 'GPU正在重置,请等重置完成后再提交'; return; } | |
| 441 | 443 | let body = {}; |
| 442 | 444 | if (kind === 'text') { |
| 443 | 445 | body = { prompt: document.getElementById('text-prompt').value, ...readParamSelects('text') }; |
| ... | ... | @@ -569,21 +571,68 @@ async function rerunJob(id) { |
| 569 | 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 | 600 | async function cancelJob(id, running) { |
| 573 | 601 | // running=true 时是"停止":Raylight的Ray子进程不理会ComfyUI的/interrupt,唯一能真正把 |
| 574 | 602 | // GPU收回来的办法是硬重启整个服务——所以要把这个代价明确告诉用户,不能假装是优雅停止。 |
| 575 | 603 | const msg = running |
| 576 | - ? '确定停止这条正在运行的任务?会硬重启整个服务来打断它(没有更温和的办法,Raylight的计算跑在独立进程里,收不到普通的打断信号),大约15-20秒后GPU恢复可用,重启期间其他任务也会受影响。' | |
| 604 | + ? '确定停止这条正在运行的任务?会硬重启整个服务来打断它(没有更温和的办法,Raylight的计算跑在独立进程里,收不到普通的打断信号),大约15-20秒后GPU恢复可用,重启期间无法提交新任务。' | |
| 577 | 605 | : '确定取消这条排队中的任务?'; |
| 578 | 606 | if (!confirm(msg)) return; |
| 579 | 607 | try { |
| 580 | 608 | const r = await fetch(`${H3_BASE}/v1/videos/${id}/cancel`, { method: 'POST' }); |
| 581 | 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 | 617 | await refreshDetail(); |
| 583 | 618 | await refreshJobs(); |
| 584 | 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 | 636 | function statusLabel(s) { |
| 588 | 637 | const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' }; |
| 589 | 638 | return `<span class="status-${s}">${map[s] || s}</span>`; | ... | ... |
raylight/scripts/server.py
| ... | ... | @@ -569,6 +569,16 @@ def create_video(req: GenReq): |
| 569 | 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 | 582 | @app.post("/v1/videos/{jid}/cancel") |
| 573 | 583 | def cancel_video(jid: str): |
| 574 | 584 | j = JOBS.get(jid) |
| ... | ... | @@ -591,17 +601,28 @@ def cancel_video(jid: str): |
| 591 | 601 | j.finished_at = time.time() |
| 592 | 602 | j.error = "用户手动停止(硬重启GPU——Raylight的Ray子进程收不到ComfyUI的/interrupt信号,见notes.md)" |
| 593 | 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 | 605 | return j.model_dump() |
| 602 | 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 | 626 | @app.get("/v1/videos/{jid}") |
| 606 | 627 | def get_video(jid: str): |
| 607 | 628 | j = JOBS.get(jid) | ... | ... |