From 94b591e0b29bd33150bf1107948b38397b3b24e2 Mon Sep 17 00:00:00 2001 From: Gloridust Date: Mon, 6 Jul 2026 23:31:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(critical):=20xsettingsd=20Xft/DPI=20?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=E9=94=99=E8=AF=AF=20=E2=80=94=E2=80=94=20v1.?= =?UTF-8?q?2.9~v1.3.1=20=E5=BE=AE=E4=BF=A1=E5=85=AC=E4=BC=97=E5=8F=B7/?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E7=A7=92=E5=85=B3=E3=80=81=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E9=BB=91=E5=B1=8F=E7=9A=84=E6=80=BB=E6=A0=B9=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XSETTINGS 规范中 Xft/DPI 的单位是「DPI×1024」(96 DPI 应写 98304),#101 引入的 xsettingsd 配置误写成 96 → 所有 Chromium 内核应用把 DPI 读成 96/1024≈0.09 → 缩放因子≈0 → 显示变换 矩阵 [0 0 0 0;…] 不可逆(transform.cc NOTREACHED)→ GPU/viz 进程连崩 3 次 → 窗口/应用退出: - 微信内嵌 CEF:点公众号/附件/小程序 → 窗口秒关/白屏(issue #111,日志与本地复现完全吻合) - 系统 Chromium:整个浏览器崩溃循环 → 黑屏(v1.3.1 的 --force-device-scale-factor=1 只救了 能加 flag 的 Chromium,是治症状;微信 CEF 无法加 flag,必须修 XSETTINGS 源头) A/B 实证:Xft/DPI 96 → 裸 chromium 立崩(同款堆栈);改 98304 → 存活。两处写入点均已修 (docker/autostart 每次开机重写 + 面板 applyXsettingsFont 实时下发)。 配套修复本轮反馈的其余问题: - 磁盘暴涨("1TB 没了"):实例容器注入 LogConfig 20m×2(默认 json-file 无上限,崩溃循环 每 2s 刷错顶满磁盘);compose 面板同配;微信 crashinfo 崩溃转储 1 天过期清理; 一键升级完成后清理悬空()旧镜像并记录释放空间。 - 一键升级"卡死":改异步——立即返回、后台顺序执行、前端轮询进度(n/总数·当前实例); 镜像统一只拉一次(原每实例各拉一次,受限网络下每次都等停滞超时)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 8 +++++ docker/autostart | 15 +++++++++- panel/server/src/docker.ts | 34 ++++++++++++++++++---- panel/server/src/index.ts | 55 ++++++++++++++++++++++++++--------- panel/web/src/api.ts | 14 +++++---- panel/web/src/pages/Admin.tsx | 37 +++++++++++++++++++---- 6 files changed, 132 insertions(+), 31 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e4f1924..540a2df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,4 +56,12 @@ services: ports: - "${WOC_HTTP_PORT:-36080}:8080" # 面板 = 唯一对外入口 + # 日志硬上限:docker 默认 json-file 不限大小,长期运行/异常刷错会无限吃磁盘。 + # (实例容器的同类上限由面板创建时注入,无需在此配置。) + logging: + driver: json-file + options: + max-size: "20m" + max-file: "2" + restart: unless-stopped diff --git a/docker/autostart b/docker/autostart index a40883f..cbf9a76 100644 --- a/docker/autostart +++ b/docker/autostart @@ -145,12 +145,16 @@ if command -v xsettingsd >/dev/null 2>&1; then family=$(cat "$FONT_FAMILY_FILE") [ -n "$family" ] && XSETTINGS_FONT="$family" fi + # ⚠️ XSETTINGS 规范里 Xft/DPI 的单位是「DPI × 1024」:96 DPI 必须写 98304。 + # 若误写 96,所有 Chromium 内核应用(系统 Chromium、微信内嵌 CEF)会把 DPI 读成 96/1024≈0.09 + # → 缩放因子≈0 → 显示变换矩阵不可逆(transform.cc NOTREACHED) → GPU 进程连崩 → 窗口秒关/黑屏。 + # 这曾是 v1.2.9~v1.3.1 微信打不开公众号/附件、浏览器黑屏的总根因(issue #111)。 cat > "${XSETTINGS_CONF}" << EOF Xft/Antialias 1 Xft/Hinting 1 Xft/HintStyle "hintslight" Xft/RGBA "rgb" -Xft/DPI 96 +Xft/DPI 98304 Gtk/FontName "${XSETTINGS_FONT} 10" EOF xsettingsd --config="${XSETTINGS_CONF}" 2>/dev/null & @@ -167,6 +171,15 @@ while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do sleep 2 done +# 3.5) 清理微信崩溃转储(crashinfo)。 +# 微信内嵌浏览器崩溃时 crashpad 会往 /config/.xwechat/crashinfo 里堆 .dmp 转储与 lock/meta 文件; +# 崩溃风暴(如 DPI bug 期间每次点公众号都崩)会让它无限膨胀吃满磁盘,且启动时反复扫描刷日志。 +# 转储仅用于事后诊断,保留 1 天足够;过期即删,防磁盘被吃。 +if [ -d /config/.xwechat/crashinfo ]; then + find /config/.xwechat/crashinfo -type f -mtime +1 -delete 2>/dev/null || true + find /config/.xwechat/crashinfo -mindepth 1 -type d -empty -delete 2>/dev/null || true +fi + # 3) 常驻拉起应用 while true; do if [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; then diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index 5d7bba2..5b510b2 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -223,6 +223,9 @@ export async function runInstance(inst: Instance): Promise { SecurityOpt: ['seccomp=unconfined'], ShmSize: SHM_SIZE, RestartPolicy: { Name: 'unless-stopped' }, + // 日志硬上限:docker 默认 json-file 无大小限制,应用崩溃循环(每 2s 刷错误)会把宿主磁盘 + // 无限吃掉(群晖用户反馈"一下子 1TB 没了"的元凶之一)。每实例封顶 20MB×2。 + LogConfig: { Type: 'json-file', Config: { 'max-size': '20m', 'max-file': '2' } }, }; if (INSTANCE_MEM > 0) { hostConfig.Memory = INSTANCE_MEM; @@ -293,15 +296,31 @@ export async function ensureRunning(inst: Instance): Promise { // 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。 // 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。 -export async function upgradeInstance(inst: Instance): Promise { - try { - await pullImage(); - } catch (e: any) { - console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e); +// skipPull:批量升级时由调用方先统一拉取一次,避免 N 个实例拉 N 次(受限网络下每次 +// 都要等到拉取停滞超时,表现为"一键升级卡死")。 +export async function upgradeInstance(inst: Instance, opts?: { skipPull?: boolean }): Promise { + if (!opts?.skipPull) { + try { + await pullImage(); + } catch (e: any) { + console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e); + } } await runInstance(inst); } +// 清理悬空(dangling)镜像:升级后旧实例镜像失去 tag 变成 ,长期堆积吃磁盘 +// (每层 1-2GB,多次升级后可观)。只删无 tag 且无容器引用的镜像,安全。best-effort。 +export async function pruneDanglingImages(): Promise { + try { + const res: any = await docker.pruneImages({ filters: { dangling: ['true'] } as any }); + const freed = Number(res?.SpaceReclaimed || 0); + if (freed > 0) appendPanelLog('INFO', `已清理悬空镜像,释放 ${(freed / 1024 / 1024 / 1024).toFixed(2)} GB`); + } catch (e: any) { + console.warn('[docker] 清理悬空镜像失败(忽略):', e?.message || e); + } +} + // 重置实例的设备 machine-id:删掉持久化的 .woc-machine-id 后重启,由 00-woc-identity 钩子重新生成 // 一个全新的唯一值(相当于"换一台新设备")。用于某账号被腾讯风控标记后手动滚新设备身份。 // 仅对含身份钩子的新镜像有效;旧镜像(升级前)无钩子,先 throw 提示升级,避免做无用功。 @@ -1214,12 +1233,15 @@ export async function applyFont(inst: Instance, fontFile: string): Promise async function applyXsettingsFont(inst: Instance, family: string): Promise { const conf = '/config/.xsettingsd'; + // ⚠️ XSETTINGS 规范里 Xft/DPI 单位是「DPI × 1024」:96 DPI 必须写 98304。误写 96 会让所有 + // Chromium 内核应用(系统 Chromium / 微信内嵌 CEF)把缩放因子算成≈0 → 变换矩阵不可逆 → + // GPU 进程连崩 → 窗口秒关/黑屏(v1.2.9~v1.3.1 的总根因,issue #111)。 const lines = [ 'Xft/Antialias 1', 'Xft/Hinting 1', 'Xft/HintStyle "hintslight"', 'Xft/RGBA "rgb"', - 'Xft/DPI 96', + 'Xft/DPI 98304', `Gtk/FontName "${family} 10"`, ]; await execCapture(inst, ['sh', '-c', `printf '%s\\n' ${lines.map(l => `'${l}'`).join(' ')} > ${conf}`]); diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 72a9a53..9ad672d 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -45,6 +45,8 @@ import { upgradeInstance, latestInstanceImageId, instanceOutdated, + pullImage, + pruneDanglingImages, removeInstance as removeInstanceContainer, instanceRuntime, triggerWechat, @@ -663,30 +665,55 @@ app.get('/api/admin/instances/upgrade-status', async (req, reply) => { list.map(async (inst) => ({ id: inst.id, name: inst.name, outdated: await instanceOutdated(inst, latestId) })), ); const outdated = results.filter((r) => r.outdated); - return { known: !!latestId, outdatedCount: outdated.length, outdatedIds: outdated.map((r) => r.id), instances: results }; + return { + known: !!latestId, + outdatedCount: outdated.length, + outdatedIds: outdated.map((r) => r.id), + instances: results, + upgradeAll: upgradeAllState, // 一键升级进行中的进度(running=false 表示空闲/已完成) + }; }); -// 一键升级全部"镜像落后"的实例(顺序执行,逐个 best-effort;个别失败不影响其余)。 +// 一键升级全部"镜像落后"的实例。 +// 异步化:拉镜像 + 逐个重建可能耗时数分钟到更久(受限网络下拉取要等到停滞超时),同步等待会让 +// 前端请求悬死、代理超时——用户反馈"一键升级一直卡死"。改为:立即返回,后台顺序执行, +// 前端轮询 upgrade-status 里的 upgradeAll 进度。镜像只拉一次(不再每实例各拉一次)。 +let upgradeAllState = { running: false, total: 0, done: 0, failed: 0, phase: '' }; app.post('/api/admin/instances/upgrade-all', async (req, reply) => { if (!requireAdmin(req, reply)) return; + if (upgradeAllState.running) return reply.code(409).send({ error: '一键升级正在进行中,请等待完成' }); const latestId = await latestInstanceImageId(); if (!latestId) return reply.code(409).send({ error: '本地尚无实例镜像,无法判断是否需要升级(请先联网拉取)' }); const list = listInstances(); - let upgraded = 0; - let failed = 0; - for (const inst of list) { - if (!(await instanceOutdated(inst, latestId))) continue; + const outdated: typeof list = []; + for (const inst of list) if (await instanceOutdated(inst, latestId)) outdated.push(inst); + if (!outdated.length) return { ok: true, started: false, upgraded: 0, failed: 0 }; + + upgradeAllState = { running: true, total: outdated.length, done: 0, failed: 0, phase: '拉取最新镜像…' }; + void (async () => { + // 统一拉取一次(失败不阻断:用本地已有镜像重建) try { - appendPanelLog('INFO', `一键升级实例「${inst.name}」(id=${inst.id})…`); - await upgradeInstance(inst); - upgraded++; + await pullImage(); } catch (e: any) { - failed++; - appendPanelLog('ERROR', `一键升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + appendPanelLog('WARN', `一键升级:拉取镜像失败(${e?.message || e}),改用本地镜像重建`); } - } - appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgraded}、失败 ${failed}`); - return { ok: true, upgraded, failed }; + for (const inst of outdated) { + upgradeAllState.phase = `升级「${inst.name}」…`; + try { + appendPanelLog('INFO', `一键升级实例「${inst.name}」(id=${inst.id})…`); + await upgradeInstance(inst, { skipPull: true }); + } catch (e: any) { + upgradeAllState.failed++; + appendPanelLog('ERROR', `一键升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + } + upgradeAllState.done++; + } + appendPanelLog('INFO', `一键升级全部实例完成:成功 ${upgradeAllState.done - upgradeAllState.failed}、失败 ${upgradeAllState.failed}`); + // 升级后旧镜像变悬空(),顺手清理防磁盘堆积 + await pruneDanglingImages(); + upgradeAllState = { ...upgradeAllState, running: false, phase: '' }; + })(); + return { ok: true, started: true, total: outdated.length }; }); // 实例侧:设置该实例可被哪些账户访问 diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index f95d28e..0780678 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -202,13 +202,17 @@ export const api = { instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }), instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }), instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }), - // 实例镜像升级状态(哪些实例落后于最新镜像)+ 一键升级全部落后实例。 + // 实例镜像升级状态(哪些实例落后于最新镜像 + 一键升级进行中的进度)+ 发起一键升级(异步,立即返回,轮询进度)。 upgradeStatus: () => - req<{ known: boolean; outdatedCount: number; outdatedIds: string[]; instances: { id: string; name: string; outdated: boolean }[] }>( - '/api/admin/instances/upgrade-status', - ), + req<{ + known: boolean; + outdatedCount: number; + outdatedIds: string[]; + instances: { id: string; name: string; outdated: boolean }[]; + upgradeAll: { running: boolean; total: number; done: number; failed: number; phase: string }; + }>('/api/admin/instances/upgrade-status'), upgradeAllInstances: () => - req<{ ok: boolean; upgraded: number; failed: number }>('/api/admin/instances/upgrade-all', { method: 'POST' }), + req<{ ok: boolean; started: boolean; total?: number }>('/api/admin/instances/upgrade-all', { method: 'POST' }), instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`, // 全局日志 / 诊断包(范围 24h/7d/30d/1y) diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`, diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index 8d1535c..aa71b2b 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -270,6 +270,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () const [acting, setActing] = useState>({}); // 实例 id → 进行中的动作文案(启动中/升级中…) const [upg, setUpg] = useState<{ outdatedCount: number; outdatedIds: string[] } | null>(null); // 镜像落后的实例 const [upgradingAll, setUpgradingAll] = useState(false); + const [upgProgress, setUpgProgress] = useState(''); // 一键升级进度文案("2/5 · 升级「xxx」…") // 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。 const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]); // 残留 woc-wx-* 容器(runInstance 启动失败遗留的 Created 容器等):占着卷名让删卷报 409。 @@ -412,25 +413,51 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () } }; - // 一键升级全部"镜像落后"的实例(逐个拉新镜像重建,可能几分钟)。 + // 一键升级全部"镜像落后"的实例。后端异步执行(拉镜像+逐个重建可能数分钟), + // 这里发起后轮询 upgrade-status 里的进度,避免单个请求悬死(旧版同步等待被反馈"一直卡死")。 const upgradeAll = async () => { const n = upg?.outdatedCount || 0; const ok = await confirm({ title: `升级全部 ${n} 个可升级实例?`, - body: '将逐个拉取最新实例镜像并重建(数据保留),每个约十几秒到几分钟;期间这些实例会短暂重连。', + body: '后台逐个拉取最新实例镜像并重建(数据保留);期间这些实例会短暂重连,可离开本页。', confirmText: '全部升级', }); if (!ok) return; setUpgradingAll(true); - toast('正在逐个升级实例,请勿离开…', 'info'); try { const r = await api.upgradeAllInstances(); - toast(`升级完成:成功 ${r.upgraded}${r.failed ? `、失败 ${r.failed}` : ''}`, r.failed ? 'error' : 'ok'); + if (!r.started) { + toast('所有实例已是最新镜像', 'ok'); + setUpgradingAll(false); + await load(); + return; + } + toast(`已开始升级 ${r.total} 个实例(后台进行)…`, 'info'); + // 轮询进度直到完成(3s 一次;异常网络下最多轮 30 分钟兜底退出) + for (let i = 0; i < 600; i++) { + await new Promise((res) => setTimeout(res, 3000)); + try { + const s = await api.upgradeStatus(); + const p = s.upgradeAll; + if (p.running) { + setUpgProgress(`${p.done}/${p.total}${p.phase ? ` · ${p.phase}` : ''}`); + continue; + } + toast( + `升级完成:成功 ${p.total - p.failed}${p.failed ? `、失败 ${p.failed}(看面板日志)` : ''}`, + p.failed ? 'error' : 'ok', + ); + break; + } catch { + /* 面板短暂不可达(不影响后台任务),继续轮询 */ + } + } await load(); } catch (e: any) { toast(e.message || '升级失败', 'error'); } finally { setUpgradingAll(false); + setUpgProgress(''); } }; @@ -485,7 +512,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () (更新面板不会自动升级实例,二者是不同镜像) )}