ghprobe 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +170 -0
- package/README.en.md +28 -17
- package/README.md +53 -18
- package/bin/ghprobe.js +209 -9
- package/config.default.yaml +58 -7
- package/package.json +1 -1
- package/src/algorithm.js +173 -0
- package/src/daemon.js +153 -0
- package/src/doctor.js +174 -0
- package/src/health.js +28 -6
- package/src/hosts.js +83 -0
- package/src/pool.js +8 -5
- package/src/probe.js +16 -3
- package/src/prober.js +175 -9
- package/src/ranker.js +26 -0
- package/src/status.js +98 -0
- package/src/updates.js +77 -0
package/bin/ghprobe.js
CHANGED
|
@@ -11,9 +11,14 @@ import { loadConfig, ensureUserConfig, userConfigPath, userConfigSnapshotPath, c
|
|
|
11
11
|
import { updatePool, poolNeedsUpdate, poolCachePath } from "../src/pool.js";
|
|
12
12
|
import { hasLocalNetwork } from "../src/net.js";
|
|
13
13
|
import { probeNow, startServer } from "../src/server.js";
|
|
14
|
-
import { runHealth, blockFromProbeResult, shouldKeepOldBlock } from "../src/health.js";
|
|
15
|
-
import {
|
|
14
|
+
import { runHealth, blockFromProbeResult, shouldKeepOldBlock, parseBlock } from "../src/health.js";
|
|
15
|
+
import { runDoctor, formatReport, queryTask } from "../src/doctor.js";
|
|
16
|
+
import { runStatus, readLogTail } from "../src/status.js";
|
|
17
|
+
import { runDaemon, readHeartbeat, heartbeatFresh, daemonRunning } from "../src/daemon.js";
|
|
18
|
+
import { mergeHosts, applyHosts, restoreHosts, WIN_HOSTS, acquireHostsLock, releaseHostsLock, runElevated, buildUninstallTaskScript, buildInstallScript, buildHiddenRunnerVbs, userDataDir, isPaused, setPaused, buildPauseTaskScript, buildStartTaskScript, rotateLogIfNeeded } from "../src/hosts.js";
|
|
19
|
+
import { maybeNotifyUpdate } from "../src/updates.js";
|
|
16
20
|
import { readFileSync } from "node:fs";
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
17
22
|
|
|
18
23
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
19
24
|
|
|
@@ -35,16 +40,33 @@ function writeHealthLog(action, reason, extra = {}) {
|
|
|
35
40
|
const entry = `${new Date().toISOString()} health action=${action} reason=${reason}`
|
|
36
41
|
+ `${extra.changed && extra.changed.length ? ` changed=${extra.changed.join(",")}` : ""}`
|
|
37
42
|
+ `${extra.stuck && extra.stuck.length ? ` stuck=${extra.stuck.join(",")}` : ""}\n`;
|
|
43
|
+
appendRawLog(entry);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 通用日志追加(daemon / 监督者用普通行,health 用上面的带格式行)
|
|
47
|
+
function appendRawLog(line) {
|
|
38
48
|
try {
|
|
39
49
|
const f = healthLogPath();
|
|
40
50
|
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
41
|
-
//
|
|
42
|
-
|
|
51
|
+
// 追加前先滚动:日志只增不减的话,长跑机器几个月能攒出几十 MB(0.1.6 前实测 33MB)
|
|
52
|
+
rotateLogIfNeeded(f);
|
|
43
53
|
const prefix = fs.existsSync(f) ? "" : "\ufeff";
|
|
44
|
-
fs.appendFileSync(f, prefix +
|
|
54
|
+
fs.appendFileSync(f, prefix + line);
|
|
45
55
|
} catch { /* 日志写失败不影响主流程 */ }
|
|
46
56
|
}
|
|
47
57
|
|
|
58
|
+
// 监督者:daemon 心跳陈旧时拉起新实例(分离进程,继承当前权限,随调用方退出继续存活)。
|
|
59
|
+
// 成功与否都不阻断本轮 —— 本轮 health 照常执行,作为 daemon 就位前的兜底。
|
|
60
|
+
function spawnDaemonDetached() {
|
|
61
|
+
try {
|
|
62
|
+
const child = spawn(process.execPath, [path.join(ROOT, "bin", "ghprobe.js"), "daemon"], {
|
|
63
|
+
detached: true, stdio: "ignore", windowsHide: true,
|
|
64
|
+
});
|
|
65
|
+
child.unref();
|
|
66
|
+
return true;
|
|
67
|
+
} catch { return false; }
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
// 打印 hosts 块里的 IP 条目(install 完成后回显写入结果)
|
|
49
71
|
function printHostsEntries() {
|
|
50
72
|
try {
|
|
@@ -64,6 +86,11 @@ const program = new Command();
|
|
|
64
86
|
const PKG = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
65
87
|
program.name("ghprobe").description("GitHub 直连优选:本地 DoH + TLS 证书终审探测 + hosts 生成(无代理)").version(PKG.version);
|
|
66
88
|
|
|
89
|
+
// 交互命令开头的版本提示(内部每天最多查一次 registry;只提示不自动升级,失败静默)
|
|
90
|
+
function notifyUpdate() {
|
|
91
|
+
return maybeNotifyUpdate(PKG.version, console.log);
|
|
92
|
+
}
|
|
93
|
+
|
|
67
94
|
// 打印一轮结果的可读摘要
|
|
68
95
|
function printSummary(r) {
|
|
69
96
|
for (const [domain, rs] of r.ranked) {
|
|
@@ -76,6 +103,37 @@ function printSummary(r) {
|
|
|
76
103
|
}
|
|
77
104
|
}
|
|
78
105
|
|
|
106
|
+
program.command("doctor")
|
|
107
|
+
.description("一键自检:网络 / DoH / 探测 / hosts / 计划任务,人话结论 + 下一步建议")
|
|
108
|
+
.option("-c, --config <path>", "用户配置覆盖")
|
|
109
|
+
.action(async (opts) => {
|
|
110
|
+
await notifyUpdate();
|
|
111
|
+
const cfg = loadConfig(opts.config);
|
|
112
|
+
console.error("ghprobe 自检中(跑一轮完整探测,约 1 分钟)…");
|
|
113
|
+
const r = await runDoctor(cfg, (m) => console.error(m));
|
|
114
|
+
console.log(formatReport(r.checks));
|
|
115
|
+
if (!r.ok) { process.exitCode = 1; }
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
program.command("daemon")
|
|
119
|
+
.description("常驻自愈循环(由监督者自动拉起,一般不需要手动跑):15~45s 自适应健康检查")
|
|
120
|
+
.option("-c, --config <path>", "用户配置覆盖")
|
|
121
|
+
.action(async (opts) => {
|
|
122
|
+
const cfg = loadConfig(opts.config);
|
|
123
|
+
await runDaemon(cfg, (m) => appendRawLog(`${new Date().toISOString()} ${m}\n`));
|
|
124
|
+
// 正常不返回;返回即让位/已有实例,静默退出
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
program.command("status")
|
|
128
|
+
.description("秒级速查:hosts 现状 + 自愈日志尾部 + 计划任务(不打任何网络请求)")
|
|
129
|
+
.option("-c, --config <path>", "用户配置覆盖")
|
|
130
|
+
.action(async (opts) => {
|
|
131
|
+
await notifyUpdate();
|
|
132
|
+
const cfg = loadConfig(opts.config);
|
|
133
|
+
const r = await runStatus(cfg, { taskQuery: () => queryTask() });
|
|
134
|
+
console.log(r.lines.join("\n"));
|
|
135
|
+
});
|
|
136
|
+
|
|
79
137
|
program.command("probe")
|
|
80
138
|
.description("跑一轮探测并输出结果(不写 hosts)")
|
|
81
139
|
.option("-c, --config <path>", "用户配置覆盖")
|
|
@@ -92,10 +150,15 @@ program.command("probe")
|
|
|
92
150
|
if (opts.json) {
|
|
93
151
|
const out = { at: r.at, domains: [...r.ranked].map(([d, rs]) => ({ domain: d, alive: rs.map((x) => ({ ip: x.ip, tcpMs: x.tcpMs, tlsMs: x.tlsMs, ttfb: x.ttfb, score: x.score })) })), extra: [...r.extra].map(([d, rs]) => ({ domain: d, alive: rs.map((x) => x.ip) })) };
|
|
94
152
|
console.log(JSON.stringify(out, null, 2));
|
|
95
|
-
|
|
96
|
-
printSummary(r);
|
|
153
|
+
return; // JSON 是给脚本消费的,不掺提示行
|
|
97
154
|
}
|
|
155
|
+
await notifyUpdate();
|
|
156
|
+
printSummary(r);
|
|
98
157
|
if (opts.apply) {
|
|
158
|
+
// 显式命令不受暂停门约束(那是给自动化设的),但要提醒用户自动化还停着
|
|
159
|
+
if (isPaused()) {
|
|
160
|
+
console.error("注意:当前处于暂停状态(ghprobe off),本轮按你的显式要求仍写入 hosts;恢复自动化跑 ghprobe on");
|
|
161
|
+
}
|
|
99
162
|
const lock = acquireHostsLock();
|
|
100
163
|
if (!lock) {
|
|
101
164
|
console.error("另一进程正在写 hosts,本轮跳过(下轮健康检查会自动补写)");
|
|
@@ -138,8 +201,28 @@ program.command("health")
|
|
|
138
201
|
.description("健康检查:只测已写入 IP,异常才全量重排换 IP(加 --apply 才写 hosts)")
|
|
139
202
|
.option("-c, --config <path>", "用户配置覆盖")
|
|
140
203
|
.option("-a, --apply", "检测到异常时写入系统 hosts(会弹 UAC)")
|
|
204
|
+
.option("-f, --force", "跳过 daemon 监督逻辑,本轮亲自跑(诊断用)")
|
|
141
205
|
.action(async (opts) => {
|
|
206
|
+
// ghprobe off 后自愈彻底停手:不探测、不写 hosts、不拉 daemon(标记由 off/on 维护)
|
|
207
|
+
if (isPaused()) {
|
|
208
|
+
console.error("ghprobe 处于暂停状态(ghprobe off),本轮跳过 —— 恢复跑 ghprobe on");
|
|
209
|
+
writeHealthLog("skip", "已暂停(ghprobe off),本轮不探测不写 hosts");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
142
212
|
let cfg = loadConfig(opts.config);
|
|
213
|
+
// 0.5.0 监督者逻辑:daemon 心跳新鲜 -> 秒退(它在干活,重复探测纯属浪费);
|
|
214
|
+
// 心跳陈旧/没有 -> 拉起新 daemon(分离进程),本轮仍照常执行,作为 daemon 就位前的兜底。
|
|
215
|
+
// daemon 崩溃 -> 下一分钟这里自动复活它。手动诊断加 --force 绕过。
|
|
216
|
+
if (!opts.force) {
|
|
217
|
+
const hb = readHeartbeat();
|
|
218
|
+
if (daemonRunning(hb)) {
|
|
219
|
+
writeHealthLog("skip", "daemon 心跳新鲜,本轮交给 daemon");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (spawnDaemonDetached()) {
|
|
223
|
+
appendRawLog(`${new Date().toISOString()} supervisor action=spawn-daemon reason=${hb ? "心跳陈旧" : "无心跳"}\n`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
143
226
|
// 本地断网 -> 直接跳过:所有候选必然全灭,跑一轮只会刷屏并把真故障淹掉。
|
|
144
227
|
if (!(await hasLocalNetwork(cfg))) {
|
|
145
228
|
console.error("本地无网络连接,跳过本轮(不探测、不更新池、不写 hosts)");
|
|
@@ -148,9 +231,13 @@ program.command("health")
|
|
|
148
231
|
}
|
|
149
232
|
// 到点先更新候选池(默认每天 1 次;成功失败同一档冷却,不会反复重试)。
|
|
150
233
|
// 放最前:池子新了,后面的重排才有更宽的候选可用;更新成功后重新加载配置以吃进新池。
|
|
234
|
+
// 0.4.0 自救:把 hosts 里**当前已写入的条目**一并当种子 —— 它们刚通过三关终审,
|
|
235
|
+
// 是「当下确实能直连 GitHub」的 IP;seed 池被 SNI 整段封锁时这批 IP 往往是
|
|
236
|
+
// 唯一还拉得到 meta 的入口,池子不至于永久冻结。
|
|
151
237
|
if (poolNeedsUpdate(cfg)) {
|
|
152
238
|
try {
|
|
153
|
-
|
|
239
|
+
const cur = parseBlock(fs.readFileSync(WIN_HOSTS, "utf8"));
|
|
240
|
+
await updatePool(cfg, (m) => console.error(m), [...cur.entries.values()]);
|
|
154
241
|
cfg = loadConfig(opts.config);
|
|
155
242
|
} catch (e) {
|
|
156
243
|
console.error(`候选池更新失败(忽略,用现有池继续): ${e.message}`);
|
|
@@ -165,6 +252,7 @@ program.command("install")
|
|
|
165
252
|
.option("-c, --config <path>", "用户配置覆盖(间隔取 schedule.healthMin 分钟)")
|
|
166
253
|
.option("--skip-probe", "只注册计划任务,不立即探测写 hosts(等下一次定时触发)")
|
|
167
254
|
.action(async (opts) => {
|
|
255
|
+
await notifyUpdate();
|
|
168
256
|
// 生成用户可编辑的配置文件(已存在则不覆盖,保留用户改动)
|
|
169
257
|
const ucfg = ensureUserConfig();
|
|
170
258
|
const cfg = loadConfig(opts.config);
|
|
@@ -231,9 +319,55 @@ program.command("install")
|
|
|
231
319
|
console.log("停止:ghprobe uninstall");
|
|
232
320
|
});
|
|
233
321
|
|
|
322
|
+
program.command("off")
|
|
323
|
+
.description("一键暂停:剥掉 hosts 块 + 暂停计划任务 + 落暂停标记(自愈停止写入)。GitHub 回到系统默认解析;恢复用 ghprobe on")
|
|
324
|
+
.action(async () => {
|
|
325
|
+
// 先落标记:off 与计划任务正在跑的一轮是并发的,标记是第一动作才能保证
|
|
326
|
+
// 正在跑的那轮也看到暂停,不会刚剥完块又被那一轮写回去
|
|
327
|
+
setPaused(true);
|
|
328
|
+
let taskOk = true;
|
|
329
|
+
try {
|
|
330
|
+
const r = await runElevated(buildPauseTaskScript(true));
|
|
331
|
+
if (r.code !== 0) { taskOk = false; console.error(`暂停计划任务失败: ${r.err || r.out}`); }
|
|
332
|
+
} catch (e) { taskOk = false; console.error(`暂停计划任务失败: ${e.message}`); }
|
|
333
|
+
try {
|
|
334
|
+
await restoreHosts();
|
|
335
|
+
console.log("已暂停:hosts 块已剥离、DNS 缓存已刷新,GitHub 恢复系统默认解析");
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.error(`剥离 hosts 块失败: ${e.message}(暂停标记已落,自动化不会再写入;hosts 里可能还残留 ghprobe 条目,可手动删或重跑 ghprobe off)`);
|
|
338
|
+
process.exitCode = 1;
|
|
339
|
+
}
|
|
340
|
+
if (taskOk) { console.log("计划任务已暂停,常驻自愈停止写入"); }
|
|
341
|
+
console.log("恢复:ghprobe on(当场触发一轮健康检查,约 1 分钟内重新写好 hosts)");
|
|
342
|
+
if (!taskOk) { process.exitCode = 1; }
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
program.command("on")
|
|
346
|
+
.description("恢复(ghprobe off 的逆操作):清暂停标记 + 恢复计划任务 + 当场触发一轮健康检查")
|
|
347
|
+
.action(async () => {
|
|
348
|
+
setPaused(false);
|
|
349
|
+
let ok = true;
|
|
350
|
+
try {
|
|
351
|
+
const r = await runElevated(buildPauseTaskScript(false));
|
|
352
|
+
if (r.code !== 0) { ok = false; console.error(`恢复计划任务失败: ${r.err || r.out}`); }
|
|
353
|
+
} catch (e) { ok = false; console.error(`恢复计划任务失败: ${e.message}`); }
|
|
354
|
+
if (ok) {
|
|
355
|
+
try {
|
|
356
|
+
const r = await runElevated(buildStartTaskScript());
|
|
357
|
+
if (r.code !== 0) { console.error(`当场触发失败: ${r.err || r.out}(不致命,下一个间隔任务会自动跑)`); }
|
|
358
|
+
else { console.log("已当场触发一轮健康检查,约 1 分钟内 hosts 重新写好"); }
|
|
359
|
+
} catch (e) { console.error(`当场触发失败: ${e.message}(不致命,下一个间隔任务会自动跑)`); }
|
|
360
|
+
} else {
|
|
361
|
+
process.exitCode = 1;
|
|
362
|
+
}
|
|
363
|
+
console.log("已恢复:ghprobe 自动自愈回到工作状态");
|
|
364
|
+
});
|
|
365
|
+
|
|
234
366
|
program.command("uninstall")
|
|
235
367
|
.description("删除 ghprobe 计划任务并还原 hosts")
|
|
236
368
|
.action(async () => {
|
|
369
|
+
// 暂停标记一并清掉:重装 = 全新开始,不要把上一次的 off 状态带进新装
|
|
370
|
+
setPaused(false);
|
|
237
371
|
// 两步互相独立:任一步失败都不影响另一步,最后统一置 exitCode
|
|
238
372
|
try {
|
|
239
373
|
await restoreHosts();
|
|
@@ -262,7 +396,9 @@ program.command("update-pool")
|
|
|
262
396
|
.action(async (opts) => {
|
|
263
397
|
const cfg = loadConfig(opts.config);
|
|
264
398
|
try {
|
|
265
|
-
|
|
399
|
+
// 手动更新同样带上健康 hosts 条目当备用种子(见 health 命令里的说明)
|
|
400
|
+
const cur = parseBlock(fs.readFileSync(WIN_HOSTS, "utf8"));
|
|
401
|
+
const r = await updatePool(cfg, (m) => console.error(m), [...cur.entries.values()]);
|
|
266
402
|
console.log(`候选池已更新:${r.count} 个 IP`);
|
|
267
403
|
console.log(` 经 ${r.via} 拉取 -> ${r.path}`);
|
|
268
404
|
console.log("下次 probe / health 会自动使用 auto 池");
|
|
@@ -302,4 +438,68 @@ program.command("config-diff")
|
|
|
302
438
|
console.log(`配置文件:${userConfigPath()}`);
|
|
303
439
|
});
|
|
304
440
|
|
|
441
|
+
program.command("report")
|
|
442
|
+
.description("生成报障诊断包:版本/系统/暂停状态/status/配置差异/hosts 块/日志尾部,复制到剪贴板并落盘(贴 issue 用)")
|
|
443
|
+
.option("-o, --out <path>", "诊断包写入路径(默认 %LOCALAPPDATA%\\ghprobe\\report-<时间>.txt)")
|
|
444
|
+
.action(async (opts) => {
|
|
445
|
+
await notifyUpdate();
|
|
446
|
+
const cfg = loadConfig();
|
|
447
|
+
const lines = [];
|
|
448
|
+
lines.push(`ghprobe ${PKG.version} report`);
|
|
449
|
+
lines.push(`node ${process.version} / ${process.platform} / os=${os.release()} / ${new Date().toISOString()}`);
|
|
450
|
+
lines.push(`暂停状态:${isPaused() ? "已暂停(ghprobe off)" : "正常"}`);
|
|
451
|
+
lines.push("");
|
|
452
|
+
lines.push("== status ==");
|
|
453
|
+
try {
|
|
454
|
+
const r = await runStatus(cfg, { taskQuery: () => queryTask() });
|
|
455
|
+
lines.push(...r.lines);
|
|
456
|
+
} catch (e) { lines.push(`status 失败: ${e.message}`); }
|
|
457
|
+
lines.push("");
|
|
458
|
+
lines.push("== config-diff ==");
|
|
459
|
+
try {
|
|
460
|
+
const { hasUser, diff } = configDiff();
|
|
461
|
+
lines.push(hasUser ? (diff.length ? `用户配置与默认有 ${diff.length} 处差异:${diff.map((d) => d.key).join(", ")}` : "用户配置与当前默认一致") : "无用户配置(还没跑过 install)");
|
|
462
|
+
} catch (e) { lines.push(`config-diff 失败: ${e.message}`); }
|
|
463
|
+
lines.push("");
|
|
464
|
+
lines.push("== hosts 块 ==");
|
|
465
|
+
try {
|
|
466
|
+
const cur = readFileSync(WIN_HOSTS, "utf8");
|
|
467
|
+
const m = cur.match(/# === ghprobe begin ===[\s\S]*?(?:# === ghprobe end ===|$)/);
|
|
468
|
+
lines.push(m ? m[0].trimEnd() : "(无 ghprobe 块)");
|
|
469
|
+
} catch (e) { lines.push(`hosts 读取失败: ${e.message}`); }
|
|
470
|
+
lines.push("");
|
|
471
|
+
lines.push("== 自愈日志尾部(最近 50 行) ==");
|
|
472
|
+
const tail = readLogTail(50);
|
|
473
|
+
if (tail.length) { lines.push(...tail); } else { lines.push("(暂无日志)"); }
|
|
474
|
+
lines.push("");
|
|
475
|
+
lines.push("== 下一步 ==");
|
|
476
|
+
lines.push("再跑一次 `ghprobe doctor`,把它的输出也贴进 issue(约 1 分钟,report 里放不下探测过程)。");
|
|
477
|
+
const text = lines.join("\r\n") + "\r\n";
|
|
478
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
479
|
+
const out = opts.out || path.join(userDataDir(), `report-${stamp}.txt`);
|
|
480
|
+
try {
|
|
481
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
482
|
+
fs.writeFileSync(out, text, "utf8");
|
|
483
|
+
} catch (e) {
|
|
484
|
+
console.error(`诊断包落盘失败: ${e.message}`);
|
|
485
|
+
process.exitCode = 1;
|
|
486
|
+
}
|
|
487
|
+
// 尝试复制到剪贴板:失败不影响落盘(诊断包已在文件里)
|
|
488
|
+
let copied = false;
|
|
489
|
+
try {
|
|
490
|
+
await new Promise((resolve) => {
|
|
491
|
+
const child = spawn("clip", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
492
|
+
child.on("error", () => resolve());
|
|
493
|
+
child.on("close", () => { copied = true; resolve(); });
|
|
494
|
+
child.stdin.on("error", () => { /* clip 不存在时 write 会 emit,别让它炸进程 */ });
|
|
495
|
+
child.stdin.write(text);
|
|
496
|
+
child.stdin.end();
|
|
497
|
+
setTimeout(resolve, 3000); // clip 偶发不回 close,兜底别挂着
|
|
498
|
+
});
|
|
499
|
+
} catch { /* 复制失败不影响落盘 */ }
|
|
500
|
+
console.log(copied
|
|
501
|
+
? `诊断包已复制到剪贴板(可直接粘贴进 issue),同时写在 ${out}`
|
|
502
|
+
: `诊断包已写入 ${out}(剪贴板复制不可用,请手动打开复制)`);
|
|
503
|
+
});
|
|
504
|
+
|
|
305
505
|
program.parseAsync();
|
package/config.default.yaml
CHANGED
|
@@ -10,13 +10,22 @@
|
|
|
10
10
|
# 所以升级后不需要手动同步配置。想看有哪些差异:`ghprobe config-diff`。
|
|
11
11
|
# (本文件里的注释只是说明,不参与合并 —— 合并看的是键值。)
|
|
12
12
|
# 目标域名族:每域名独立探测、独立写 hosts
|
|
13
|
+
# api.github.com(0.2.0):gh CLI / REST API 走它,DNS 同样被污染 —— 写进 hosts 后
|
|
14
|
+
# `gh api` / `curl https://api.github.com` 直连可用;候选走 seed/auto 池(GitHub 自租段)
|
|
15
|
+
# github.githubassets.com(0.3.0):github.com 网页的全部 JS/CSS/字体都从这里加载 ——
|
|
16
|
+
# 不写它「页面能开但样式全裂」。注意其 Fastly 段是 .215,与 githubusercontent 的 .133 不同
|
|
17
|
+
# ssh.github.com(0.3.0):SSH-over-443(git clone git@ssh.github.com:... 用),
|
|
18
|
+
# 探测方式是读 SSH banner,不走 TLS/HTTP 三关
|
|
13
19
|
domains:
|
|
14
20
|
- github.com
|
|
21
|
+
- api.github.com
|
|
22
|
+
- ssh.github.com
|
|
15
23
|
- codeload.github.com
|
|
16
24
|
- objects.githubusercontent.com
|
|
17
25
|
- raw.githubusercontent.com
|
|
18
26
|
- gist.githubusercontent.com
|
|
19
27
|
- avatars.githubusercontent.com
|
|
28
|
+
- github.githubassets.com
|
|
20
29
|
|
|
21
30
|
# DoH 端点(全部 IP 直连,绕开解析 DoH 域名的死锁;默认只放国内实测可达端点)
|
|
22
31
|
# sni 填服务域名:TLS 握手按它验证书(RFC 6066 不允许拿 IP 当 SNI)
|
|
@@ -24,6 +33,7 @@ doh:
|
|
|
24
33
|
- { name: alidns1, host: 223.5.5.5, sni: dns.alidns.com, path: /dns-query }
|
|
25
34
|
- { name: alidns2, host: 223.6.6.6, sni: dns.alidns.com, path: /dns-query }
|
|
26
35
|
- { name: dnspod, host: 1.12.12.12, sni: doh.pub, path: /dns-query }
|
|
36
|
+
- { name: dnspod2, host: 120.53.53.53, sni: doh.pub, path: /dns-query }
|
|
27
37
|
|
|
28
38
|
# 内置候选 IP 池:补足 DoH 拿不到的候选。
|
|
29
39
|
# 🔴 池子必须够宽,否则会出现「明明有活 IP,工具却报 stuck」——见下面 azure 段的说明。
|
|
@@ -44,6 +54,10 @@ ipPools:
|
|
|
44
54
|
- 140.82.121.3
|
|
45
55
|
# Fastly 段:服务 githubusercontent 系(raw / objects / gist / avatars),多年不变
|
|
46
56
|
fastly: [185.199.108.133, 185.199.109.133, 185.199.110.133, 185.199.111.133]
|
|
57
|
+
# Fastly ghassets 段(0.3.0 新增):github.githubassets.com 专用。
|
|
58
|
+
# 🔴 与上面 .133 不是同一段:实测 .133 对 github.githubassets.com 的 SNI 连不上,
|
|
59
|
+
# DoH 对该域名返回的也是 .215 —— 两段不能混用,所以单独建池。
|
|
60
|
+
ghassets: [185.199.108.215, 185.199.109.215, 185.199.110.215, 185.199.111.215]
|
|
47
61
|
# GitHub 自租段:服务 github.com / codeload.github.com 主站。
|
|
48
62
|
# 🔴 2026-10 实测:这些 IP **在分钟级抖动** —— 同一批 IP 几分钟前全灭、现在 9/20 可用,
|
|
49
63
|
# 且「活的是哪几个」每次都不一样。所以池子必须宽(靠 tcpMs 短超时兜住成本),
|
|
@@ -85,11 +99,14 @@ ipPools:
|
|
|
85
99
|
# 顺序有意义:靠前的池先占 maxPoolIps 名额。
|
|
86
100
|
domainPools:
|
|
87
101
|
github.com: [auto, seed, azure]
|
|
102
|
+
api.github.com: [auto, seed]
|
|
103
|
+
ssh.github.com: [auto, seed]
|
|
88
104
|
codeload.github.com: [auto, seed, azure]
|
|
89
105
|
objects.githubusercontent.com: [fastly]
|
|
90
106
|
raw.githubusercontent.com: [fastly]
|
|
91
107
|
gist.githubusercontent.com: [fastly]
|
|
92
108
|
avatars.githubusercontent.com: [fastly]
|
|
109
|
+
github.githubassets.com: [ghassets]
|
|
93
110
|
|
|
94
111
|
# 候选池自动更新:从 GitHub 官方 api.github.com/meta 拉最新网段,缓存到用户目录,供 `auto` 池使用。
|
|
95
112
|
# 为什么需要:GitHub 的 Azure 段在变,内置 azure 池会过期(实测 24 个里 4 个已失效)。
|
|
@@ -126,16 +143,19 @@ networkCheck:
|
|
|
126
143
|
# 完整 raw.githubusercontent.com:
|
|
127
144
|
# - { domain: my.mirror, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
|
|
128
145
|
# 完整写法**自带探测判据** —— 第三关用它验证「这个 IP 真的在提供这份内容」。
|
|
129
|
-
# 为什么判据必须跟着配置走:不同替身服务的 URL 形态不同,CDN 映射是
|
|
130
|
-
# `/gh/<user>/<repo>@<ref>/<path>`,代理是 `/<完整原始 URL>`,写死在代码里就没法让用户自己加替身。
|
|
131
146
|
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
147
|
+
# 0.4.0 默认链(2026-10-31 实测数据驱动,详见 CHANGELOG):
|
|
148
|
+
# - cdn.jsdelivr.net 移除:node + 双 curl 共 45 次全部在 ClientHello 后被 RST(SNI 级硬封),
|
|
149
|
+
# 留着只会白耗每轮 6s 超时;
|
|
150
|
+
# - fastly.jsdelivr.net 首选:Fastly anycast(151.101.x.229),全栈全 IP 100%、最快(TTFB ~125ms);
|
|
151
|
+
# - testingcf.jsdelivr.net 次选:Cloudflare 段,与 Fastly 真正异构;
|
|
152
|
+
# - gcore.jsdelivr.net 末位:当前 DoH 解析与 testingcf 落在同一对 CF IP(名不副实),纯语义冗余。
|
|
153
|
+
# 🔴 三个替身都不在代码 DOMAIN_SIG 里 —— 必须用完整写法自带判据,否则会退回「探根路径」的弱判据。
|
|
136
154
|
fallbacks:
|
|
137
155
|
raw.githubusercontent.com:
|
|
138
|
-
-
|
|
156
|
+
- { domain: fastly.jsdelivr.net, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
|
|
157
|
+
- { domain: testingcf.jsdelivr.net, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
|
|
158
|
+
- { domain: gcore.jsdelivr.net, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
|
|
139
159
|
|
|
140
160
|
# 超时:tcpMs 只管 TCP 握手阶段(死 IP 快速淘汰),握手成功后自动切到 tlsMs。
|
|
141
161
|
# 黑洞 IP 会一直挂到超时,所以这两段必须分开 —— 否则每加一个候选都要多等 tlsMs。
|
|
@@ -145,6 +165,15 @@ probe:
|
|
|
145
165
|
maxPoolIps: 32 # 内置池每域名最多补几个。**与 DoH 各自限额,不共享总上限**(见 probe.js)
|
|
146
166
|
maxPerDomain: 4 # 每域名最多保留几个可用 IP(前 maxEntriesPerDomain 个会写进 hosts)
|
|
147
167
|
concurrency: 10 # 全局同时握手的 socket 上限(跨域名共享)
|
|
168
|
+
# schannel 二次重探(0.3.0):Node 的 TLS ClientHello 被中间设备识别并 reset 时
|
|
169
|
+
# (README §3 实测:同一 IP 同一时刻 openssl 20/20、Node 5/20、浏览器正常),
|
|
170
|
+
# 改用 Windows 自带的 curl.exe(Schannel 构建,与 Edge 同一 TLS 栈)重探一次。
|
|
171
|
+
# 0.3.0 前这种情况只能写 # blocked 放弃,而浏览器明明能用 —— 这是假阴性的根治。
|
|
172
|
+
schannelRetry: true
|
|
173
|
+
# 复测抗抖动(0.3.0):对每域名前 3 名再采一枪,均值 + 失败罚分后重排名。
|
|
174
|
+
# 单采样时同一 IP 相邻两轮分差可达数倍、排名近乎随机,而无谓的换 IP 要刷 DNS 缓存。
|
|
175
|
+
# 2 = 总采样两次(默认,代价 ~1-2s);1 或删掉 = 退回单采样旧行为。
|
|
176
|
+
stableSamples: 2
|
|
148
177
|
# hosts 写入:每域名写几条候选。
|
|
149
178
|
# 🔴 2026-10-11 实测推翻旧假设「Windows 同一域名只取第一条」—— hosts 里同名多条会被
|
|
150
179
|
# 解析器**全部返回**(保持文件顺序),且 curl / 浏览器会**逐个尝试**:黑洞 IP 排在前
|
|
@@ -154,6 +183,28 @@ probe:
|
|
|
154
183
|
hosts:
|
|
155
184
|
maxEntriesPerDomain: 2
|
|
156
185
|
serve: { host: 127.0.0.1, port: 8930 }
|
|
186
|
+
# 用户行为感知的「切换时机」算法(0.6.0,daemon 专用):
|
|
187
|
+
# 换 IP 有打扰成本(flushdns + 连接池重建,正在看的页面会卡一下)。成本按权重打分:
|
|
188
|
+
# 前台是浏览器 100 > 浏览器在后台跑 30 > 前台是其他软件 10 > 空闲 0
|
|
189
|
+
# 非紧急优化类写入(提上备选/重排优化/补缺失/兜底)在「前台是浏览器」时**推迟**,攒到空闲落地,
|
|
190
|
+
# 上限 deferMaxMs 防饿死;**当前已断的域名(主备全死)不吃权重,立即切** —— 正在用 ≠ 该断网。
|
|
191
|
+
# 检测免管理员:tasklist 扫浏览器进程 + PowerShell GetForegroundWindow 查前台。
|
|
192
|
+
algorithm:
|
|
193
|
+
enabled: true
|
|
194
|
+
deferMaxMs: 600000 # 推迟上限(毫秒):到点强制切,宁可打扰也不能饿死修复
|
|
195
|
+
deferThreshold: 100 # 成本 >= 此值才推迟(默认只有「前台是浏览器」会到 100)
|
|
196
|
+
detectCacheMs: 20000 # 活动检测结果缓存(daemon 一轮内不重复打系统)
|
|
197
|
+
browsers: # 视作「浏览器」的进程名(小写,不带 .exe)
|
|
198
|
+
- chrome
|
|
199
|
+
- msedge
|
|
200
|
+
- firefox
|
|
201
|
+
- brave
|
|
202
|
+
- opera
|
|
203
|
+
- vivaldi
|
|
204
|
+
- 360se
|
|
205
|
+
- 360chrome
|
|
206
|
+
- qqbrowser
|
|
207
|
+
- sogouexplorer
|
|
157
208
|
# 调度:固定频率健康检查,不做指数退避(哨兵一轮 <2s,退避省不下开销反而拉长最坏发现延迟)
|
|
158
209
|
schedule:
|
|
159
210
|
# 哨兵间隔(分钟):只测已写入 IP(3 条并发,健康时 ~1s),异常才触发全量重排。
|
package/package.json
CHANGED
package/src/algorithm.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// algorithm:用户行为感知的「切换时机」决策(0.6.0)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要:换 IP 不是零成本的 —— flushdns + 连接池重建,正在浏览的页面会卡一下,
|
|
6
|
+
// 进行中的下载/克隆可能断。0.5.0 及以前 daemon 只看网络健康,不看用户在干什么:
|
|
7
|
+
// 半夜优化切没事,用户正看 GitHub 文档时切就是「打扰」。
|
|
8
|
+
//
|
|
9
|
+
// 决策模型(成本 vs 紧急度):
|
|
10
|
+
// 成本 = 前台是浏览器(100) > 浏览器在后台跑(30) > 前台是其他软件(10) > 空闲(0),权重可配
|
|
11
|
+
// 紧急度 = 当前**已经不可用**的域名(主备全死)——这类修复**不吃权重,立即切**:
|
|
12
|
+
// 「正在用」和「不能用」二选一,当然选别断网。
|
|
13
|
+
// 非紧急(提上备选/重排优化/补写缺失/兜底写入)在前台浏览器时推迟,攒成待写块,
|
|
14
|
+
// 空闲时落地;推迟上限 deferMaxMs(默认 10 分钟)防饿死。
|
|
15
|
+
//
|
|
16
|
+
// 检测全部**免管理员**:tasklist 扫浏览器进程 + PowerShell GetForegroundWindow 查前台。
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
// 常见浏览器进程名(小写、不带 .exe)。匹配 tasklist 镜像名与前台的 ProcessName。
|
|
20
|
+
export const DEFAULT_BROWSERS = [
|
|
21
|
+
"chrome", "msedge", "firefox", "brave", "opera", "vivaldi", "360se", "360chrome",
|
|
22
|
+
"qqbrowser", "sogouexplorer", "maxthon", "twinkstar", "hxbcrowser", "baidubrowser",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// 解析 tasklist /FO CSV /NH 输出 -> 小写进程名 Set("chrome.exe","123",...)
|
|
26
|
+
export function parseTasklist(out) {
|
|
27
|
+
const names = new Set();
|
|
28
|
+
for (const line of String(out || "").split(/\r?\n/)) {
|
|
29
|
+
const m = line.match(/^"([^"]+)"/);
|
|
30
|
+
if (m) { names.add(m[1].toLowerCase().replace(/\.exe$/, "")); }
|
|
31
|
+
}
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 纯函数:进程名集合里有没有浏览器
|
|
36
|
+
export function browserRunningFrom(processNames, browsers) {
|
|
37
|
+
return (browsers || []).some((b) => processNames.has(String(b).toLowerCase()));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 活动检测(可注入 exec,测试不打真系统):
|
|
41
|
+
// running = 浏览器进程存在(含后台标签页/更新进程 —— 它们同样会被 flushdns 影响)
|
|
42
|
+
// foreground = 前台窗口是浏览器(用户正在看网页)
|
|
43
|
+
// PowerShell 查询失败按 foreground=false 处理(保守偏向「可以切」:打扰是体验问题,
|
|
44
|
+
// 断网是可用性问题,检测手段失败时不应把可用性押在一个尽力而为的信号上)。
|
|
45
|
+
export async function detectActivity(cfg, deps = {}) {
|
|
46
|
+
const exec = deps.exec || execFile;
|
|
47
|
+
const browsers = cfg.algorithm?.browsers || DEFAULT_BROWSERS;
|
|
48
|
+
const platform = deps.platform || process.platform;
|
|
49
|
+
if (platform !== "win32") { return { running: false, foreground: false, source: "non-win32" }; }
|
|
50
|
+
|
|
51
|
+
const running = await new Promise((resolve) => {
|
|
52
|
+
exec("tasklist", ["/FO", "CSV", "/NH"], { windowsHide: true, timeout: 5000 }, (e, stdout) => {
|
|
53
|
+
if (e) { resolve(false); return; }
|
|
54
|
+
resolve(browserRunningFrom(parseTasklist(stdout), browsers));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const fgName = await new Promise((resolve) => {
|
|
59
|
+
const ps = `Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint p);' -Name FG -Namespace W; $h=[W.FG]::GetForegroundWindow(); $p=0; [W.FG]::GetWindowThreadProcessId($h, [ref]$p) | Out-Null; if ($p) { $pr = Get-Process -Id $p -ErrorAction SilentlyContinue; if ($pr) { Write-Output $pr.ProcessName.ToLower() } }`;
|
|
60
|
+
exec("powershell", ["-NoProfile", "-Command", ps], { windowsHide: true, timeout: 6000 }, (e, stdout) => {
|
|
61
|
+
resolve(e ? "" : String(stdout || "").trim().toLowerCase());
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
running,
|
|
67
|
+
foreground: running && browsers.includes(fgName),
|
|
68
|
+
fgName,
|
|
69
|
+
source: fgName ? "foreground-ok" : "foreground-unknown",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 成本权重(纯函数)
|
|
74
|
+
export function switchCost(activity, weights = {}) {
|
|
75
|
+
const w = { browserForeground: 100, browserRunning: 30, otherForeground: 10, idle: 0, ...weights };
|
|
76
|
+
if (activity.foreground) { return w.browserForeground; }
|
|
77
|
+
if (activity.running) { return w.browserRunning; }
|
|
78
|
+
if (activity.otherForeground) { return w.otherForeground; }
|
|
79
|
+
return w.idle;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 核心决策(纯函数)。
|
|
83
|
+
// deadCount = 当前主备全死的域名数 —— P0,不吃权重
|
|
84
|
+
// initial = 首次写入(hosts 里还没有块)—— 无从打扰,直接切
|
|
85
|
+
// cost = switchCost 结果
|
|
86
|
+
// deferSinceMs/nowMs = 本次推迟的起点,超 deferMaxMs 强制切(防饿死)
|
|
87
|
+
export function decideSwitch({ cost = 0, deadCount = 0, initial = false, deferSinceMs = null, nowMs = Date.now(), cfg = {} }) {
|
|
88
|
+
const a = cfg.algorithm || {};
|
|
89
|
+
if (a.enabled === false) { return { switch: true, reason: "algorithm 已禁用" }; }
|
|
90
|
+
if (deadCount > 0) { return { switch: true, reason: `紧急修复(当前不可用域名 ${deadCount} 个,用户正在受影响)` }; }
|
|
91
|
+
if (initial) { return { switch: true, reason: "首次写入(无从打扰)" }; }
|
|
92
|
+
if (deferSinceMs != null && nowMs - deferSinceMs >= (a.deferMaxMs ?? 600_000)) {
|
|
93
|
+
return { switch: true, reason: "推迟已达上限,强制切换" };
|
|
94
|
+
}
|
|
95
|
+
const threshold = a.deferThreshold ?? 100;
|
|
96
|
+
if (cost >= threshold) {
|
|
97
|
+
return { switch: false, reason: `打扰成本 ${cost} >= 阈值 ${threshold}(用户正在用浏览器),攒到空闲再切` };
|
|
98
|
+
}
|
|
99
|
+
return { switch: true, reason: `打扰成本 ${cost} < 阈值 ${threshold}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 门控:交给 runHealth 的 beforeApply 钩子 + daemon 的空闲落地(flush)。
|
|
103
|
+
// 内部状态跨轮存活:deferred 的块存 pendingBlock,用户空闲后由 maybeFlush 落地。
|
|
104
|
+
export function createSwitchGate(deps = {}) {
|
|
105
|
+
const state = { pendingBlock: null, deferSinceMs: null };
|
|
106
|
+
const detect = deps.detect || detectActivity;
|
|
107
|
+
|
|
108
|
+
async function activityNow(cfg, { fresh = false } = {}) {
|
|
109
|
+
// 检测结果短缓存:daemon 15~45s 一轮,PS + tasklist 各 ~0.3s,同轮内不重复打系统。
|
|
110
|
+
// fresh=true 跳过缓存 —— 空闲落地是最终决策点,必须用此刻的真实状态
|
|
111
|
+
// (用户可能刚离开浏览器;用几秒前的缓存会白白推迟一次)。
|
|
112
|
+
const ttl = cfg.algorithm?.detectCacheMs ?? 20_000;
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
if (!fresh && state.cache && now - state.cache.at < ttl) { return state.cache.activity; }
|
|
115
|
+
const activity = await detect(cfg, deps);
|
|
116
|
+
state.cache = { at: now, activity };
|
|
117
|
+
return activity;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
// runHealth 的 beforeApply 钩子:meta = { deadDomains, initial, changed, reason, block, cfg }
|
|
122
|
+
async beforeApply(meta) {
|
|
123
|
+
// P0 短路:用户此刻已经断着 / 首次写入 —— 不检测、不推迟,立即切。
|
|
124
|
+
// 必须在活动检测之前 return,否则活动检测失败等噪声会把救命修复也拦下来。
|
|
125
|
+
if ((meta.deadDomains || []).length > 0 || meta.initial) {
|
|
126
|
+
state.lastReason = meta.initial ? "首次写入" : `紧急修复(不可用域名 ${meta.deadDomains.length} 个)`;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
const activity = await activityNow(meta.cfg || {});
|
|
130
|
+
const cost = switchCost(activity, meta.cfg?.algorithm?.weights);
|
|
131
|
+
const d2 = decideSwitch({
|
|
132
|
+
cost,
|
|
133
|
+
deadCount: 0,
|
|
134
|
+
initial: false,
|
|
135
|
+
deferSinceMs: state.deferSinceMs,
|
|
136
|
+
nowMs: Date.now(),
|
|
137
|
+
cfg: meta.cfg || {},
|
|
138
|
+
});
|
|
139
|
+
if (d2.switch) {
|
|
140
|
+
state.pendingBlock = null;
|
|
141
|
+
state.deferSinceMs = null;
|
|
142
|
+
state.lastReason = d2.reason;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
state.pendingBlock = meta.block; // 攒着,空闲时落地
|
|
146
|
+
state.deferSinceMs = state.deferSinceMs ?? Date.now();
|
|
147
|
+
state.lastReason = d2.reason;
|
|
148
|
+
return false;
|
|
149
|
+
},
|
|
150
|
+
// daemon 每轮调用:有待写块且此刻成本低 -> 落地
|
|
151
|
+
async maybeFlush(cfg, log, deps2 = {}) {
|
|
152
|
+
if (!state.pendingBlock) { return null; }
|
|
153
|
+
const activity = await activityNow(cfg, { fresh: true });
|
|
154
|
+
const cost = switchCost(activity, cfg?.algorithm?.weights);
|
|
155
|
+
const d = decideSwitch({
|
|
156
|
+
cost, deadCount: 0, initial: false,
|
|
157
|
+
deferSinceMs: state.deferSinceMs, nowMs: Date.now(), cfg,
|
|
158
|
+
});
|
|
159
|
+
if (!d.switch) { return { flushed: false, reason: d.reason }; }
|
|
160
|
+
const applyHosts = deps.applyHosts || deps2.applyHosts;
|
|
161
|
+
const hostsPath = deps2?.hostsPath;
|
|
162
|
+
const merged = deps2.mergeLatest(state.pendingBlock); // 调用方负责读最新 hosts 并合并(护栏在其中)
|
|
163
|
+
await applyHosts(merged, hostsPath);
|
|
164
|
+
const block = state.pendingBlock;
|
|
165
|
+
state.pendingBlock = null;
|
|
166
|
+
state.deferSinceMs = null;
|
|
167
|
+
log(`空闲落地:打扰成本 ${cost},已写入推迟的 hosts 块(${d.reason})`);
|
|
168
|
+
return { flushed: true, block };
|
|
169
|
+
},
|
|
170
|
+
get pending() { return Boolean(state.pendingBlock); },
|
|
171
|
+
get lastReason() { return state.lastReason || ""; },
|
|
172
|
+
};
|
|
173
|
+
}
|