fix(critical): xsettingsd Xft/DPI 单位错误 —— v1.2.9~v1.3.1 微信公众号/附件秒关、浏览器黑屏的总根因

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 天过期清理;
  一键升级完成后清理悬空(<none>)旧镜像并记录释放空间。
- 一键升级"卡死":改异步——立即返回、后台顺序执行、前端轮询进度(n/总数·当前实例);
  镜像统一只拉一次(原每实例各拉一次,受限网络下每次都等停滞超时)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-07-06 23:31:24 +08:00
parent bdb53065ca
commit 94b591e0b2
6 changed files with 132 additions and 31 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -223,6 +223,9 @@ export async function runInstance(inst: Instance): Promise<void> {
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<void> {
// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。
// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。
export async function upgradeInstance(inst: Instance): Promise<void> {
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<void> {
if (!opts?.skipPull) {
try {
await pullImage();
} catch (e: any) {
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
}
}
await runInstance(inst);
}
// 清理悬空dangling镜像升级后旧实例镜像失去 tag 变成 <none>,长期堆积吃磁盘
// (每层 1-2GB多次升级后可观。只删无 tag 且无容器引用的镜像安全。best-effort。
export async function pruneDanglingImages(): Promise<void> {
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<void>
async function applyXsettingsFont(inst: Instance, family: string): Promise<void> {
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}`]);

View File

@@ -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}`);
// 升级后旧镜像变悬空(<none>),顺手清理防磁盘堆积
await pruneDanglingImages();
upgradeAllState = { ...upgradeAllState, running: false, phase: '' };
})();
return { ok: true, started: true, total: outdated.length };
});
// 实例侧:设置该实例可被哪些账户访问

View File

@@ -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)}`,

View File

@@ -270,6 +270,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 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: ()
<span className="muted small"></span>
</span>
<button className="btn btn-primary s-btn" disabled={upgradingAll} onClick={upgradeAll}>
{upgradingAll ? '升级中…请稍候' : '一键升级全部实例'}
{upgradingAll ? `升级中 ${upgProgress || '…'}` : '一键升级全部实例'}
</button>
</div>
)}