From 17f8b3f3fa0467c4b88d2a47fb11243441ece792 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 14:39:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E9=93=BE=E8=B7=AF=EF=BC=9A?= =?UTF-8?q?=E5=86=85=E6=A0=B8=E5=9C=A8=E7=BA=BF=E6=97=B6=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=20mihomo=20-t=EF=BC=8C=E7=A6=BB=E7=BA=BF=E6=97=B6=E5=86=99?= =?UTF-8?q?=E7=9B=98=E4=B8=8D=E5=86=8D=E5=9B=9E=E6=BB=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测 mihomo -t 占整条保存链路九成以上耗时(4-9.4 秒),而 PUT /configs 自带同一套解析、失败时什么都不应用,配合既有的还原备份等于校验,重载 只要几十到几百毫秒。代价是写盘到还原之间的短暂窗口里磁盘上存在一份 可能有错的配置。 顺带修正离线行为:原来重载连不上会把刚写好的文件还原掉,但内核起不来 时用这个界面修配置正是核心场景,现在离线走 mihomo -t 校验后写盘不重载。 --- server/config-store.ts | 70 +++++++++++++++++++++++++++++------------- server/kernel.ts | 8 +++-- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/server/config-store.ts b/server/config-store.ts index c35a8bd..528a0e9 100644 --- a/server/config-store.ts +++ b/server/config-store.ts @@ -8,8 +8,14 @@ * * 顺序是: * 读当前文件 → 检查是否被外部改过 → 生成新内容 → 自己检查一遍 - * → 写到临时文件 → mihomo -t 校验 → 备份原文件 → 写入 → 通知内核重载 - * 最后两步中任何一步出问题,都从备份还原并让内核载回原样。 + * → 备份原文件 → 写入 → 让内核重载,被拒就从备份还原并载回原样 + * 重载这一步自带校验:内核先完整解析、解析失败什么都不应用并返回 400 + * (内核源码 hub/route/configs.go:430-438),还原链路补上「把文件退回去」。 + * + * 内核不在线时没有这层校验,换成: + * → 写到临时文件 → mihomo -t 校验 → 备份 → 写入,不重载,内核下次启动时生效 + * 配置写坏导致内核起不来的时候正是最需要这个界面的时候,这条路必须格外稳, + * 也绝不能因为「重载失败」把刚修好的文件又还原回去。 * * 「自己检查一遍」不能省。实测 v1.19.29:入站的 `proxy:` 指向一个不存在的 * 出站时,`mihomo -t` 会通过、重载会成功,直到有流量走到那个入站才在日志里 @@ -24,7 +30,7 @@ import { } from '../vendor/config-editor/index.ts' import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts' -import { validateConfigFile, reloadConfig } from './kernel.ts' +import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts' import type { Env } from './env.ts' /** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */ @@ -146,24 +152,32 @@ export async function applyConfig( } } - // 先写到临时文件交给内核校验,别碰真文件。 - const tempPath = join(dirname(env.configPath), `.patchbay-check-${String(Date.now())}.yaml`) + // 内核在线时不跑 mihomo -t:起一个进程把配置重新解析一遍占整条链路九成以上的耗时 + // (实测保存 4-10 秒里它占 4-9.4 秒,重载只要几十到几百毫秒),而重载请求自带同一套 + // 解析、失败时什么都不应用,还原链路补上「把文件退回去」,效果等同校验。代价是写盘到 + // 还原之间的短暂窗口里磁盘上存在一份可能有错的配置,进程恰好在这时被杀才会留下事故。 + // 内核不在线时 mihomo -t 是唯一的校验手段。 + const kernel = await kernelVersion(env) let checkTiming = '' - try { - await writeFile(tempPath, nextYaml, 'utf8') - const checked = await validateConfigFile(env, tempPath) - checkTiming = lap('内核校验') - if (!checked.ok) { - return { - ok: false, - stage: 'kernel-check', - message: checked.message, - hint: '内核拒绝了这份配置,真文件一个字节都没动,你可以直接继续改。', - restored: false, + if (!kernel.online) { + // 先写到临时文件交给内核校验,别碰真文件。 + const tempPath = join(dirname(env.configPath), `.patchbay-check-${String(Date.now())}.yaml`) + try { + await writeFile(tempPath, nextYaml, 'utf8') + const checked = await validateConfigFile(env, tempPath) + checkTiming = lap('内核校验') + if (!checked.ok) { + return { + ok: false, + stage: 'kernel-check', + message: checked.message, + hint: '内核拒绝了这份配置,真文件一个字节都没动,你可以直接继续改。', + restored: false, + } } + } finally { + await rm(tempPath, { force: true }) } - } finally { - await rm(tempPath, { force: true }) } // 校验过了才动真文件。备份放在写入之前。 @@ -182,6 +196,21 @@ export async function applyConfig( } const writeTiming = lap('写盘') + + if (!kernel.online) { + // mihomo -t 已经把过关,文件留给内核下次启动时读。 + await pruneBackups(env) + console.log( + `[apply] 共 ${String(Date.now() - clock.start)}ms(${checkTiming},${backupTiming},${writeTiming},未重载)`, + ) + return { + ok: true, + snapshot: await readConfig(env), + reloaded: false, + reloadMessage: '内核未连接,改动已写入文件,等内核启动时生效', + } + } + const reloaded = await reloadConfig(env, env.configPath) const reloadTiming = lap('重载') if (!reloaded.ok) { @@ -207,9 +236,8 @@ export async function applyConfig( } await pruneBackups(env) - console.log( - `[apply] 共 ${String(Date.now() - clock.start)}ms(${checkTiming},${backupTiming},${writeTiming},${reloadTiming})`, - ) + const timings = [checkTiming, backupTiming, writeTiming, reloadTiming].filter(Boolean).join(',') + console.log(`[apply] 共 ${String(Date.now() - clock.start)}ms(${timings})`) return { ok: true, snapshot: await readConfig(env), diff --git a/server/kernel.ts b/server/kernel.ts index 0a04c58..dc485af 100644 --- a/server/kernel.ts +++ b/server/kernel.ts @@ -54,8 +54,8 @@ export async function validateConfigFile( * 启动时先把内核二进制跑一次,什么都不做。 * * `mihomo -t` 的第一次调用要 4 秒,之后 55 毫秒(在目标机器那种规格上实测差距只会更大)。 - * 差的是把这个几十兆的可执行文件读进系统文件缓存的时间,跟配置内容无关。不预热的话, - * 这 4 秒会落在用户按下的第一次保存上,而那正是他最容易以为「这个工具很慢」的时刻。 + * 差的是把这个几十兆的可执行文件读进系统文件缓存的时间,跟配置内容无关。现在只有内核 + * 不在线时保存才走 `mihomo -t`,而那正是用户最着急的场景,这 4 秒不该落在他头上。 * * 失败不用管:内核二进制不可用这件事,第一次真正校验时会带着完整错误信息报出来。 */ @@ -95,6 +95,10 @@ export async function kernelVersion(env: Env): Promise<{ online: boolean; versio /** * 让内核重新加载配置文件,不重启进程。 * + * 这一步同时就是校验:内核先完整解析,解析失败什么都不应用并返回 400,响应体是解析 + * 错误原文(内核源码 hub/route/configs.go:430-438)。落盘链路在内核在线时靠它把关, + * 不再单独跑 `mihomo -t`。 + * * `force=true` 意味着连端口这类需要重开监听的改动也一并生效——正是本项目改入站时需要的。 * 注意 `path` 是**内核所在机器上的路径**:部署时 Patchbay 和内核在同一台机器上所以没有问题, * 但开发时如果 Patchbay 在本机、内核在远端,这个调用不会加载到本地的文件。