""".ui → bricks(bui) 渲染链路的启动期守卫(Bug 906IGBEVANkne-og1cv0r / P0)。 ## 缺陷现象(R36 后回归实测,2026-10-21) GET /index.ui -> 500 size=55 "500 Internal Server Error Server got itself in trouble" GET /scense/index.ui -> 500 GET /runtime/index.ui -> 500 GET /scene/index.ui -> 500 GET /world/index.ui -> 500 GET /entity/index.ui -> 500 对照:GET /runtime/play.html -> 200 size=6368、GET /runtime/runtime.js -> 200 size=27674 GET /healthz -> 200、56 个 api/*.dspy -> 无 500 「全部 .ui 一律 500、静态资源全 200」说明缺陷不在具体页面文件,而在 **bui 渲染器本身**。 ## 根因(代码级链路,见 references 证据文档) ahserver 的 `BricksUIProcessor.datahandle()`(pkgs/ahserver/ahserver/baseProcessor.py:196) 在渲染任何 .ui 之前**无条件**取两个共享片段: content0 = await self.resource.path_call(request, entire_url('/bricks/header.tmpl')) content2 = await self.resource.path_call(request, entire_url('/bricks/footer.tmpl')) `ProcessorResource.path_call()`(processorResource.py:510)把 URL 映射到磁盘文件后取处理器: path = self.url2path(url); fpath = self.url2file(path) processor = self.url2processor(request, path, fpath) # fpath 不存在 → None 随后**不做判空**直接 `await processor.be_call(...)` → `AttributeError: 'NoneType' object has no attribute 'be_call'` → aiohttp 兜底 500(正文就是那 55 字节)。 所以只要 `/wwwroot/bricks/header.tmpl`(或 footer.tmpl)在目标机不可解析, **每一个** .ui 页面都会 500,而 .html/.js/.dspy 全都不受影响——与实测矩阵完全一致。 ## 为什么目标机缺 /bricks/header.tmpl `bricks` 是**前端包**(pkgs/bricks):它的资源不在 `pkgs/bricks/wwwroot/`,而在 `pkgs/bricks/bricks/`(`header.tmpl` / `footer.tmpl` / `bricks.js` / `bricks.css` …)。 build.sh 第 5 步的软链循环只认 `pkgs//wwwroot`: if [ -d "${PDB}/${m}/wwwroot" ]; then ln -sfn ... ; else log "跳过"; fi → 于是 `wwwroot/bricks` 从未被建立(静默"跳过"),构建期自校验又只检查 `scense` / `runtime` 的 index.ui,不检查 `/bricks/*.tmpl` → 缺陷一路留到冒烟才发现。 ## 本模块的职责(两层防御,均为 fail-soft,绝不因自身异常拖垮启动) 1. `ensure_bricks_assets()` —— 启动期自愈:若 `/wwwroot/bricks` 不可解析或缺少 header.tmpl/footer.tmpl,就在候选目录里找到真正的 bricks 资源目录并(重)建软链; 修不了则打印**可行动**的 ERROR(含逐个候选绝对路径),而不是让 6 个页面继续 500。 2. `ui_chain_problems()` —— 启动期自检:遍历 `/wwwroot/*` 下所有 .ui, 报告悬空软链 / 缺失文件,并把结论写进日志(部署后可直接 grep 取证)。 设计约束: - 不改 ahserver / bricks 等基础模块(project-directory-spec §3 引用模块只读)——缺陷的 「判空」在框架侧,但应用侧完全可以不让它被触发:把资源就位 + 构建期/启动期双门禁。 - 所有路径来自 `__file__` 推导出的部署根(`/app/ui_render_guard.py` → ``), 不写死任何部署路径(project-directory-spec §8.6)。 - 任何异常都在函数内吞掉并记日志,返回诊断 dict,**不向上抛**(启动不能被守卫搞挂)。 """ import logging import os import time log = logging.getLogger("scense.ui_guard") #: bui 渲染器无条件依赖的共享片段(缺任意一个 → 所有 .ui 501/500,见模块 docstring) BRICKS_ASSET_FILES = ("header.tmpl", "footer.tmpl") #: wwwroot 下 bricks 资源的挂载点名(与 entire_url('/bricks/header.tmpl') 对齐) BRICKS_MOUNT = "bricks" def default_deploy_dir(): """由本文件位置推导部署根:/app/ui_render_guard.py → 。 不写死路径;文件被单测从别处导入时也只用其所在目录的父目录,行为可预测。 """ here = os.path.dirname(os.path.abspath(__file__)) return os.path.dirname(here) def _missing_assets(directory): """返回 directory 下缺失的资源文件名列表;目录不存在时返回全部。""" if not directory or not os.path.isdir(directory): return list(BRICKS_ASSET_FILES) return [f for f in BRICKS_ASSET_FILES if not os.path.isfile(os.path.join(directory, f))] def _assets_ok(directory): return not _missing_assets(directory) def bricks_asset_candidates(deploy_dir=None, pkg_dir=None): """bricks 前端资源目录的候选清单(按优先级,去重保序)。 覆盖三类真实现场: 1. 部署机:`/pkgs/bricks/bricks`(前端包把资源放在与包同名的子目录) —— 也可由 `$BRICKS_WWWROOT` 显式覆盖; 2. 部署机旧布局:`/pkgs/bricks/wwwroot`(少数版本可能补齐过 wwwroot); 3. 机构工作空间/本地装配:`/modules/bricks/wwwroot`、 `/pkgs/bricks/bricks`(develop 侧离线自检时用)。 """ deploy_dir = deploy_dir or default_deploy_dir() pkg_dir = pkg_dir or os.path.join(deploy_dir, "pkgs") space_root = os.path.dirname(deploy_dir) cands = [] env_override = os.environ.get("BRICKS_WWWROOT") if env_override: cands.append(os.path.abspath(os.path.expanduser(env_override))) cands.extend([ # 部署机真实布局(2026-10-25 修复 /index.ui 500,Bug mb7QP_1Rxb9HXTh-x4jlx): # bricks 前端资源实际在 `pkgs/bricks/dist`(历史软链 wwwroot/bricks -> ../pkgs/bricks/dist), # 旧候选清单漏了 dist,导致 ensure_bricks_assets 一律 unresolvable → 所有 .ui 500。 os.path.join(pkg_dir, BRICKS_MOUNT, "dist"), # pkgs/bricks/dist os.path.join(pkg_dir, BRICKS_MOUNT, BRICKS_MOUNT), # pkgs/bricks/bricks os.path.join(pkg_dir, BRICKS_MOUNT, "wwwroot"), # pkgs/bricks/wwwroot os.path.join(space_root, "modules", BRICKS_MOUNT, "wwwroot"), os.path.join(space_root, "pkgs", BRICKS_MOUNT, BRICKS_MOUNT), os.path.join(space_root, "pkgs", BRICKS_MOUNT, "wwwroot"), ]) # pip install -e / site-packages 形态:venv/lib/pythonX.Y/site-packages/bricks{/dist,wwwroot,} try: venv_root = os.path.join(deploy_dir, "venv", "lib") if os.path.isdir(venv_root): for pyver in sorted(os.listdir(venv_root)): sp = os.path.join(venv_root, pyver, "site-packages", BRICKS_MOUNT) cands.extend([os.path.join(sp, "dist"), os.path.join(sp, "wwwroot"), sp]) except OSError: pass seen = set() out = [] for c in cands: c = os.path.normpath(c) if c not in seen: seen.add(c) out.append(c) return out def find_bricks_assets(deploy_dir=None, pkg_dir=None): """返回第一个真正含 header.tmpl+footer.tmpl 的候选目录;都没有则 None。 2026-10-25 增强(Bug mb7QP_1Rxb9HXTh-x4jlx / .ui 全站 500): 候选清单兜底后仍找不到时,在 `pkgs/bricks` 与 venv site-packages/bricks 下 做一次**限深度**(默认 4 层)搜索,命中同时含 header.tmpl + footer.tmpl 的目录 即返回。理由:bricks 前端资源在不同发布形态下会落在 dist/、wwwroot/、包同名 子目录或 examples/ 上层,硬编码单层路径会让「修复能力」在真实布局前失效, 而失效的表现就是每一个 .ui 都 500(BricksUIProcessor 取 /bricks/header.tmpl 时 processor=None)。 """ deploy_dir = deploy_dir or default_deploy_dir() for cand in bricks_asset_candidates(deploy_dir=deploy_dir, pkg_dir=pkg_dir): if _assets_ok(cand): return cand # 限量深度兜底搜索 pkg_dir = pkg_dir or os.path.join(deploy_dir, "pkgs") roots = [os.path.join(pkg_dir, BRICKS_MOUNT)] try: venv_root = os.path.join(deploy_dir, "venv", "lib") if os.path.isdir(venv_root): for pyver in sorted(os.listdir(venv_root)): roots.append(os.path.join(venv_root, pyver, "site-packages", BRICKS_MOUNT)) except OSError: pass max_depth = 4 for root in roots: if not os.path.isdir(root): continue base_depth = root.rstrip(os.sep).count(os.sep) for dirpath, dirnames, _files in os.walk(root): if dirpath.count(os.sep) - base_depth >= max_depth: dirnames[:] = [] continue if _assets_ok(dirpath): log.warning("[ui-guard] bricks 资源由深度搜索命中: %s (root=%s)", dirpath, root) return dirpath return None def _replace_link(link_path, target): """把 link_path 换成指向 target 的软链;原有东西(悬空链/空目录)改名留底。""" parent = os.path.dirname(link_path) if parent and not os.path.isdir(parent): os.makedirs(parent, exist_ok=True) backup = None if os.path.islink(link_path) or os.path.exists(link_path): # 已存在但不可用(悬空链 / 缺文件):先移走,保留现场便于取证 backup = "%s.broken.%s" % (link_path, time.strftime("%Y%m%d%H%M%S")) try: os.rename(link_path, backup) except OSError: # rename 失败(跨设备/权限)时直接删链,避免挡住自愈 try: os.unlink(link_path) backup = None except OSError: pass os.symlink(target, link_path) return backup def ensure_bricks_assets(deploy_dir=None, pkg_dir=None, dry_run=False): """确保 `/wwwroot/bricks/{header,footer}.tmpl` 可解析;不可则自愈。 返回诊断 dict(同时写日志),键: ok / wwwroot / mount / source / action / searched / missing / backup / error action 取值:`ok`(本来就对)/ `relinked`(已自愈)/ `unresolvable`(找不到源)/ `failed`(自愈过程异常)/ `dry-run`。 """ deploy_dir = deploy_dir or default_deploy_dir() wwwroot = os.path.join(deploy_dir, "wwwroot") mount = os.path.join(wwwroot, BRICKS_MOUNT) diag = { "ok": False, "wwwroot": wwwroot, "mount": mount, "source": None, "action": "unknown", "searched": [], "missing": [], "backup": None, "error": None, } try: diag["searched"] = bricks_asset_candidates(deploy_dir=deploy_dir, pkg_dir=pkg_dir) missing = _missing_assets(mount) diag["missing"] = missing if not missing: diag["ok"] = True diag["action"] = "ok" diag["source"] = os.path.realpath(mount) log.info("[ui-guard] bricks 渲染片段就位: %s (%s)", mount, ", ".join(BRICKS_ASSET_FILES)) return diag source = find_bricks_assets(deploy_dir=deploy_dir, pkg_dir=pkg_dir) diag["source"] = source if not source: diag["action"] = "unresolvable" log.error( "[ui-guard] /bricks/{header,footer}.tmpl 不可解析且找不到 bricks 资源目录 —— " "所有 .ui 将 500(BricksUIProcessor 取 /bricks/header.tmpl 时 processor=None)。" " 已搜索: %s。修复:确认 pkgs/bricks 已投递(平台 deliver_app_code)," "或设 BRICKS_WWWROOT 指向含 header.tmpl/footer.tmpl 的目录。", "; ".join(diag["searched"]), ) return diag if dry_run: diag["action"] = "dry-run" diag["ok"] = True log.warning("[ui-guard] (dry-run) 将把 %s 重建为 -> %s", mount, source) return diag backup = _replace_link(mount, source) diag["backup"] = backup # 建链后复核:软链必须在、两文件必须可读 still_missing = _missing_assets(mount) diag["missing"] = still_missing diag["ok"] = not still_missing diag["action"] = "relinked" if diag["ok"] else "failed" if diag["ok"]: log.warning( "[ui-guard] 已自愈 wwwroot/bricks 软链: %s -> %s(原现场备份: %s)", mount, source, backup, ) else: log.error("[ui-guard] 自愈后仍缺 %s(软链 -> %s)", still_missing, source) return diag except Exception as exc: # noqa: BLE001 diag["ok"] = False diag["action"] = "failed" diag["error"] = "%s: %s" % (type(exc).__name__, exc) log.exception("[ui-guard] 确保 bricks 渲染片段时异常(不影响启动): %s", exc) return diag def ui_chain_problems(deploy_dir=None): """遍历 `/wwwroot/*/**/*.ui`,返回问题清单(悬空软链 / 文件缺失)。 只读检查,不做任何修改;供启动日志与部署后取证使用。 """ deploy_dir = deploy_dir or default_deploy_dir() wwwroot = os.path.join(deploy_dir, "wwwroot") problems = [] checked = 0 try: if not os.path.isdir(wwwroot): return [{"path": wwwroot, "reason": "wwwroot 不存在"}] for root, dirs, files in os.walk(wwwroot): # 不跟随软链(os.walk 默认不跟随目录软链),链路完整性单独判 for name in files: if not name.endswith(".ui"): continue full = os.path.join(root, name) checked += 1 if not os.path.exists(full): problems.append({"path": full, "reason": "悬空软链/文件不存在"}) if checked >= 500: # 防御性上限 break for name in sorted(os.listdir(wwwroot)): full = os.path.join(wwwroot, name) if os.path.islink(full) and not os.path.exists(full): problems.append({"path": full, "reason": "wwwroot 模块软链悬空"}) except Exception as exc: # noqa: BLE001 problems.append({"path": wwwroot, "reason": "扫描异常: %s" % exc}) return problems def install_entry_guard(deploy_dir=None): """入口 init() 调用的一次性装配:自愈 + 自检 + 结构化日志。 绝不抛异常(守卫自身故障不得导致启动失败),返回汇总 dict 供 /healthz/日志取证。 汇总字段:bricks(ensure 诊断)/ problems(.ui 链路问题数)/ checked_ok。 """ summary = {"bricks": None, "problems": [], "ok": False} try: summary["bricks"] = ensure_bricks_assets(deploy_dir=deploy_dir) summary["problems"] = ui_chain_problems(deploy_dir=deploy_dir) summary["ok"] = bool(summary["bricks"].get("ok")) and not summary["problems"] if summary["problems"]: log.error("[ui-guard] .ui 链路存在 %d 个问题: %s", len(summary["problems"]), summary["problems"][:10]) else: log.info("[ui-guard] .ui 链路自检通过(bricks 片段 action=%s)", summary["bricks"].get("action")) except Exception as exc: # noqa: BLE001 log.exception("[ui-guard] install_entry_guard 异常(忽略,不影响启动): %s", exc) return summary # probe # --- 2026-10-25 修复记录(Bug mb7QP_1Rxb9HXTh-x4jlx:/index.ui 与 /scense/index.ui 500)--- # 根因:wwwroot/*.ui 由 BricksUIProcessor 渲染,渲染器无条件取 /bricks/header.tmpl 与 # /bricks/footer.tmpl;bricks 前端资源的真实布局是 pkgs/bricks/dist,旧候选清单漏了 dist # → ensure_bricks_assets 一律 unresolvable → 取片段时 processor=None → 每个 .ui 都 500。 # 修复:bricks_asset_candidates 增加 pkgs/bricks/dist 与 venv/lib/*/site-packages/bricks{/dist,wwwroot}; # find_bricks_assets 增加限深(4 层)兜底目录搜索;build.sh 第 5 步补 wwwroot/bricks 软链。 # 复验:scripts/verify_ui_render_chain.py --fix --http(目标机侧,运行时读 env 的 deploy.path / base_url)。