diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..591ea18 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + node-selftests: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Syntax checks + shell: pwsh + run: | + node --check accel.mjs + node --check gh.mjs + node --check clash.mjs + node --check discover.mjs + node --check hostsio.mjs + node --check ip.mjs + node --check manifest.mjs + node --check pool.mjs + node --check probe.mjs + - name: Offline self-tests + shell: pwsh + run: | + node probe.mjs --selftest + node ip.mjs --selftest + node hostsio.mjs --selftest + node accel.mjs --selftest + node gh.mjs --selftest + node relay/test/middleware.test.mjs diff --git a/README.md b/README.md index 7588c61..5cc94ac 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ 面向 Windows(Git Bash / PowerShell 皆可)。两层互补,各自解决实测确认不同的故障面: +## 稳定性策略(2026-10) + +- **不改 Clash 配置**。本仓库只管理自己的 hosts 自动化和下载/克隆工具。 +- **GitHub 主站与关键资源域不再固定静态 IP**:`github.com`、`gist.github.com`、`api.github.com`、`codeload.github.com`、`raw.githubusercontent.com`、`objects.githubusercontent.com`、`avatars.githubusercontent.com`、`github.githubassets.com` 一律使用当前 DNS。一次探测成功不能代表下一刻仍可用;写入全局 hosts 会把瞬时波动放大成整机访问故障。运行 `node accel.mjs` 会重建本工具标记块并移除旧的 GitHub 静态钉。 +- **下载:健康镜像并发竞速 → 全部失败后官方 URL 无代理直连重试**。直连回退会清除本次子进程的代理环境变量,并使用 curl `--noproxy '*'`,不改变 Clash 或系统代理设置;只在完整文件成功后才替换输出文件。 +- **克隆:镜像入口探测/重试 → 官方 GitHub URL 直连重试**。直连回退清除代理环境变量、显式清空 Git 的代理配置,而且不添加 `insteadOf` 镜像重写。若目标目录在运行前已存在,工具拒绝覆盖,不会因镜像失败删除已有文件。 +- **边界说明**:镜像回退只适用于本工具的下载与 `git clone`。浏览器页面的透明故障切换必须由浏览器/系统路由层实现;在不改变 Clash、且浏览器仍配置为 GitHub 直连的前提下,任何仓库脚本都无法保证运营商链路、DNS 或 GitHub 上游永不故障。此次改动的目标是移除本工具主动造成的坏钉,并为本工具执行的下载/克隆补齐最终直连路线。 + + > **关于权限(2026-10-13 实测更新)**:写 `C:\Windows\System32\drivers\etc\hosts` 的**内容是可行的**—— > 前提是 hosts 文件上有一条本用户的显式 ACE(本机现状:`jingjiang:(M)`,另有目录继承的 SYSTEM/Administrators 项)。 > `drivers\etc\` **目录本身不可写**(用户在该目录无法创建/删除文件),所以"临时文件 + 原子 rename"的记录方式在这台机器上 @@ -14,7 +23,7 @@ | 层 | 文件 | 解决什么 | |---|---|---| | A 自适应 hosts | `accel.mjs`(+ `ip` / `probe` / `hostsio` 三个模块) | 头像、raw、归档包、npm/jsDelivr、PyPI、SourceForge、cdnjs/unpkg 等**能靠 IP 救**的通道:候选 IP 逐条本机 TLS 实测,只有确实更快才写入 | -| B 竞速取件 | `gh.mjs` | `github.com` 首跳、release 下载、`git clone` 这类**换 IP 无效**的通道:每次请求同时压多个入口,先出数据的胜出,停滞带 Range 换源续传 | +| B 竞速取件 | `gh.mjs` | 下载/`git clone` 并发竞速镜像;镜像全失败时强制尝试官方地址直连。不是浏览器透明代理 | 设计取向是**宁可不写,也不写坏**:写错的 hosts 条目比不写更糟(浏览器全站受影响),所以每条固定都要过"多轮实测 + 写入后真实解析复核"。复核若发现真实解析落到了**没钉的那一族**,先按族补钉(用另一族实测可用的候选)再复核第二轮,补不上才整域撤销。该判定(`planAfterLeak`)可用 `node accel.mjs --selftest` 离线回归。 @@ -22,11 +31,11 @@ v2(2026-10-13)把这套守护做细了:落地复核 = **系统解析整链 ## 覆盖哪些域(2026-10-13 实测口径) -**进 A 层的域 = 「换 IP 救得回来」+「有可信候选源」两个条件都满足**,不是列得越多越好: +**进 A 层的域 = 「换 IP 救得回来」+「有可信候选源」两个条件都满足**,不是列得越多越好。GitHub 自有核心域由代码级安全规则排除静态 hosts 固定,以免短暂成功的地址在下一轮失效: | 分类 | 域 | 候选来源 | |---|---|---| -| GitHub | `github.com`、`api.github.com`、`codeload.github.com`、`raw`/`objects`/`avatars`/`githubassets` | GitHub `/meta`(带缓存兜底)+ DoH + 第三方 hosts hint | +| GitHub 核心域 | `github.com`、`api.github.com`、`codeload.github.com`、`raw`/`objects`/`avatars`/`githubassets` | **不静态钉 hosts**;动态 DNS 负责解析,`gh.mjs` 负责下载/克隆镜像竞速与官方直连回退 | | Cloudflare 前置 | `cdn.jsdelivr.net`、`registry.npmjs.org`、`sourceforge.net`、`downloads.sourceforge.net`、`cloudflare.com`、`cdnjs.cloudflare.com`、`unpkg.com` | Cloudflare `ips-v4`/`ips-v6`(官方)+ DoH | | Fastly 前置 | `pypi.org`、`files.pythonhosted.org` | Fastly `public-ip-list`(官方)+ DoH | diff --git a/accel.mjs b/accel.mjs index 3722e40..d65f806 100644 --- a/accel.mjs +++ b/accel.mjs @@ -33,6 +33,65 @@ const REVERT = ARGV.includes('--revert') const VERBOSE = ARGV.includes('-v') const SELFTEST = ARGV.includes('--selftest') const DOMRE = /^[a-z0-9._-]+$/i +// Critical GitHub web/resource endpoints must use current DNS. This code-level invariant prevents +// a briefly healthy IP pin turning into a machine-wide outage between scheduled maintenance runs. +const NEVER_PIN_DOMAINS = new Set([ + 'github.com', 'gist.github.com', 'api.github.com', 'codeload.github.com', + 'raw.githubusercontent.com', 'objects.githubusercontent.com', + 'avatars.githubusercontent.com', 'github.githubassets.com' +]) + +// Remove only GitHub core pins from our managed hosts block. This is safe even when a proxy +// fake-IP environment prevents fresh probes; retain unrelated CDN/package pins and hand-written lines. +function stripProtectedPinsFromBlock(block) { + const lines = String(block || '').split(/\r?\n/) + const start = lines.findIndex(function (line) { return line.trim() === MARK_S }) + const end = lines.findIndex(function (line, i) { return i > start && line.trim() === MARK_E }) + if (start < 0 || end < 0) return { block: block, changed: false } + let changed = false + const out = lines.slice(0, start + 1) + for (let i = start + 1; i < end; i++) { + const line = lines[i] + const trimmed = line.trim() + if (!trimmed || trimmed.charAt(0) === '#') { out.push(line); continue } + const fields = trimmed.split(/\s+/) + if (fields.length < 2) { out.push(line); continue } + const keep = fields.slice(1).filter(function (domain) { + return !NEVER_PIN_DOMAINS.has(domain.toLowerCase()) + }) + if (keep.length !== fields.length - 1) changed = true + if (keep.length) out.push(fields[0] + ' ' + keep.join(' ')) + else if (keep.length === fields.length - 1) out.push(line) + } + out.push.apply(out, lines.slice(end)) + return { block: out.join('\r\n'), changed: changed } +} +async function removeProtectedPinsFromHosts() { + if (DRY) return false + const cur = readHosts(CFG.hosts_path) + if (cur === null) return false + const start = cur.indexOf(MARK_S) + const end = cur.indexOf(MARK_E, start + MARK_S.length) + if (start < 0 || end < 0) return false + const managed = cur.slice(start, end + MARK_E.length) + const filtered = stripProtectedPinsFromBlock(managed) + if (!filtered.changed) return false + const result = await commitHosts({ + hostsPath: CFG.hosts_path, + bdir: path.join(HERE, 'backups'), + block: filtered.block, + markS: MARK_S, + markE: MARK_E, + keep: 5 + }) + if (!result.ok) { + log('保护性清理 GitHub hosts 固定未成立:' + result.reason + (result.error ? ' ' + result.error : '')) + return false + } + await run('ipconfig.exe', ['/flushdns'], 8000) + log('保护性清理完成:仅移除本工具块内的 GitHub 核心域固定,保留其他域的固定') + return true +} function now() { return new Date().toISOString().slice(11, 19) } function log() { @@ -374,7 +433,8 @@ async function main() { const env = await fakeEnv() if (env.active) { log('环境:系统解析 ' + env.domain + ' -> ' + env.ip + ' = fake-ip(代理内核接管 DNS)') - log('==== 本轮跳过:hosts 层被架空,保留现有固定,不写也不撤(report.json 留上一轮) ====') + await removeProtectedPinsFromHosts() + log('==== 本轮跳过 IP 测速与选路;已尽力清理本工具块内的 GitHub 核心域旧固定,其他域固定保留 ====') return } // 候选过期就顺手刷新一次(discover 自己也会判有效期;这里保证它不会永远不跑)。 @@ -401,7 +461,14 @@ async function main() { : { up: false }, pool: { enabled: POOL.on }, domains: {} } const chosen = [] for (const dom of Object.keys(CFG.domains)) { - if (CFG.deny.indexOf(dom) >= 0 || !DOMRE.test(dom)) continue + if (!DOMRE.test(dom)) continue + if (NEVER_PIN_DOMAINS.has(dom)) { + report.domains[dom] = { baseline: null, baseline6: null, pinned: null, pinned6: null, + reason: 'safety-no-pin:GitHub 核心域保留动态 DNS,避免间歇性 IP 固定影响主站/资源' } + log('保护性跳过 ', dom.padEnd(28), '不写入 hosts 固定(使用动态 DNS)') + continue + } + if (CFG.deny.indexOf(dom) >= 0) continue const conf = CFG.domains[dom] || {} const r = await evalDomain(dom, conf, remote) if (r.pin4 || r.pin6) chosen.push({ dom: dom, ip: r.pin4, ip6: r.pin6, alt4: r.alt4, alt6: r.alt6, canary: conf.canary || '/' }) @@ -571,6 +638,19 @@ if (SELFTEST) { if (!ok) bad++ console.log(' ' + (ok ? 'PASS' : 'FAIL') + ' 落地码 ' + dom + ' code=' + code + ' -> ' + got + (ok ? '' : ',期望 ' + want)) } + // Stability invariant: critical GitHub endpoints never enter the hosts pin set. + const protectedDomains = ['github.com', 'gist.github.com', 'api.github.com', 'codeload.github.com', 'raw.githubusercontent.com', 'objects.githubusercontent.com', 'avatars.githubusercontent.com', 'github.githubassets.com'] + const protectedOk = protectedDomains.every(function (d) { return NEVER_PIN_DOMAINS.has(d) }) + if (!protectedOk) bad++ + console.log(' ' + (protectedOk ? 'PASS' : 'FAIL') + ' GitHub 核心域禁止静态 hosts 固定' + (protectedOk ? '' : '(缺失保护域名)')) + const sampleBlock = [MARK_S, '# generated', '1.2.3.4 github.com other.example', '2.2.2.2 raw.githubusercontent.com', '3.3.3.3 cdn.jsdelivr.net', MARK_E].join('\r\n') + const cleanedBlock = stripProtectedPinsFromBlock(sampleBlock) + const cleanupOk = cleanedBlock.changed && cleanedBlock.block.indexOf('github.com') < 0 && + cleanedBlock.block.indexOf('raw.githubusercontent.com') < 0 && + cleanedBlock.block.indexOf('1.2.3.4 other.example') >= 0 && + cleanedBlock.block.indexOf('3.3.3.3 cdn.jsdelivr.net') >= 0 + if (!cleanupOk) bad++ + console.log(' ' + (cleanupOk ? 'PASS' : 'FAIL') + ' fake-IP 早退清理只移除核心域,保留其他域固定' + (cleanupOk ? '' : '(结果不符)')) // 模块接线自检:ip.mjs 的严格校验确实被本进程用上(垃圾形式一律为 0) const wireCases = [ ['2606:50c0:8000::153', 6], ['104.17.208.5', 4], ['2606:50c0:8000:::1', 0], ['', 0] @@ -602,7 +682,7 @@ if (SELFTEST) { console.log(' ' + (ok ? 'PASS' : 'FAIL') + ' ' + name + ' -> take4=' + !!got.take4 + ' take6=' + !!got.take6 + (ok ? '' : ',期望 take4=' + want4 + ' take6=' + want6)) } - const total = cases.length + codeCases.length + wireCases.length + pinCases.length + const total = cases.length + codeCases.length + wireCases.length + pinCases.length + 2 console.log(bad ? ' 自测失败 ' + bad + ' 项' : ' 自测 ' + total + '/' + total + ' 通过') process.exit(bad ? 1 : 0) } diff --git a/config.json b/config.json index 121068e..bd4f4ef 100644 --- a/config.json +++ b/config.json @@ -91,6 +91,14 @@ "low_speed_limit": 1024, "low_speed_time": 20 }, + "direct_fallback": { + "enabled": true, + "retries": 2, + "clone_retries": 2, + "connect_timeout_s": 6, + "speed_limit_bps": 1024, + "speed_time_s": 30 + }, "domains": { "api.github.com": { "canary": "/zen" }, "raw.githubusercontent.com": { "canary": "/521xueweihan/GitHub520/main/hosts" }, diff --git a/gh.mjs b/gh.mjs index f492cac..6cdd547 100644 --- a/gh.mjs +++ b/gh.mjs @@ -16,11 +16,13 @@ const TTL = (CFG.proxy_ttl_s || 60) * 1000 const ARGV = process.argv.slice(2) const SELFTEST = ARGV.includes('--selftest') -function run(exe, args, timeoutMs) { +function run(exe, args, timeoutMs, env) { return new Promise(function (resolve) { // maxBuffer 8MB(2026-10-13 从 64MB 收紧):内容级体检要在内存里读响应体找标记(不能设太小否则会截断体检), // 8MB 对"哨兵响应 + 带宽样本的尾巴"有足够余量,同时消除 64MB 的失控面。 - execFile(exe, args, { maxBuffer: 8 * 1024 * 1024, windowsHide: true, timeout: timeoutMs || 60000, encoding: 'utf8' }, + const opts = { maxBuffer: 8 * 1024 * 1024, windowsHide: true, timeout: timeoutMs || 60000, encoding: 'utf8' } + if (env) opts.env = env + execFile(exe, args, opts, function (err, out, errout) { resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout: (out || '').trim(), stderr: (errout || '').trim() }) }) @@ -176,13 +178,12 @@ async function loadManifest(force) { if (!force && cache && cache.at && Date.now() - cache.at < ttl) { return { urls: manifestEntries(cache), at: cache.at, stale: false, source: sourceLabel(cache.sources), generated_at: cache.generated_at || '', sources: cache.sources || [] } } - const fetched = [] - for (const url of manifestUrls()) { + const fetched = await Promise.all(manifestUrls().map(async function (url) { const r = await run('curl.exe', ['-sS', '-L', '--ssl-no-revoke', '--connect-timeout', '4', '--max-time', '12', url], 16000) let mf = null if (r.code === 0 && r.stdout) { try { mf = JSON.parse(r.stdout) } catch (e) { mf = null } } - fetched.push({ url: url, mf: mf }) - } + return { url: url, mf: mf } + })) const merged = mergeManifests(fetched) if (merged.urls.length) { const rec = { at: Date.now(), generated_at: merged.sources.reduce(function (a, s) { return s.generated_at && s.generated_at > a ? s.generated_at : a }, ''), sources: merged.sources, entries: merged.urls.map(function (u) { return { url: u, ok: true } }) } @@ -399,20 +400,155 @@ function startCurl(url, file, resume) { } function statSize(f) { try { return fs.statSync(f).size } catch (e) { return 0 } } +// Official-origin fallback must be direct, not silently reusing a shell proxy setting. +function proxyFreeEnv() { + const env = Object.assign({}, process.env) + for (const k of ['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']) delete env[k] + return env +} +function directCurlArgs(url, file) { + const D = CFG.direct_fallback || {} + const R = CFG.race || {} + return ['-f', '-L', '-sS', '--noproxy', '*', '--ssl-no-revoke', + '--connect-timeout', String(D.connect_timeout_s || 6), + '--max-time', String(R.max_time_s || 900), + '--speed-limit', String(D.speed_limit_bps || 1024), + '--speed-time', String(D.speed_time_s || 30), + '-o', file, '-w', '%{http_code} %{size_download} %{speed_download}', url] +} +async function directDownloadFallback(url, name) { + const D = CFG.direct_fallback || {} + if (D.enabled === false) { + console.warn(' 官方地址直连回退已禁用;未改写现有输出文件') + return false + } + const temp = name + '.part-direct' + const attempts = Math.max(1, Math.min(5, Number(D.retries) || 2)) + const maxTime = Number((CFG.race || {}).max_time_s) || 900 + for (let attempt = 1; attempt <= attempts; attempt++) { + try { fs.unlinkSync(temp) } catch (e) {} + console.warn(' 镜像不可用,尝试 GitHub 官方地址直连 ' + attempt + '/' + attempts + ' ...') + const r = await run('curl.exe', directCurlArgs(url, temp), (maxTime + 15) * 1000, proxyFreeEnv()) + const bytes = statSize(temp) + if (r.code === 0 && bytes > 0) { + try { if (fs.existsSync(name)) fs.unlinkSync(name) } catch (e) {} + try { + fs.renameSync(temp, name) + const m = String(r.stdout || '').match(/^(\d+)\s+(\d+)\s+([\d.]+)/) + console.log(' 官方地址直连成功 ' + name + ' ' + bytes + ' 字节 ' + + mbps(m ? Number(m[3]) : 0) + ' MB/s(未使用代理环境变量)') + return true + } catch (e) { + console.error(' 直连文件提交失败:' + (e && e.message || e)) + try { fs.unlinkSync(temp) } catch (x) {} + return false + } + } + console.warn(' 官方直连失败:curl=' + r.code + ' bytes=' + bytes + + (r.stderr ? ' ' + String(r.stderr).slice(-180) : '')) + try { fs.unlinkSync(temp) } catch (e) {} + if (attempt < attempts) await sleep(1000) + } + return false +} + +// Parse optional clone destination while skipping option values such as --depth 1. +function cloneOutputDir(repo, rest) { + const valueOptions = new Set([ + '-b', '--branch', '-o', '--origin', '-u', '--upload-pack', '--reference', + '--reference-if-able', '--separate-git-dir', '--depth', '--filter', '--jobs', + '-j', '--shallow-since', '--shallow-exclude', '--config', '-c', + '--server-option', '--template', '--bundle-uri', '--revision' + ]) + const positionals = [] + let afterEnd = false + for (let i = 0; i < rest.length; i++) { + const a = String(rest[i]) + if (!afterEnd && a === '--') { afterEnd = true; continue } + if (!afterEnd && a.startsWith('-')) { + if (a.includes('=')) continue + if (valueOptions.has(a)) i++ + continue + } + positionals.push(a) + } + if (positionals.length) return positionals[positionals.length - 1] + try { + return path.basename(new URL(repo).pathname.replace(/\/+$/, '')).replace(/\.git$/i, '') + } catch (e) { + return path.basename(String(repo).replace(/\.git$/i, '')) + } +} +function cleanupFailedClone(targetDir) { + // Called only after the caller verified the destination did not exist before this run. + try { if (fs.existsSync(targetDir)) fs.rmSync(targetDir, { recursive: true, force: true }) } catch (e) {} +} +function makeCloneArgs(rest, repo, prefix, direct) { + const C = CFG.clone || {} + const a = [] + if (direct) { + // Explicitly clear general and URL-specific Git proxy config; environment vars are removed separately. + a.push('-c', 'http.proxy=', '-c', 'http.https://github.com/.proxy=') + } + a.push('-c', 'http.version=HTTP/1.1', + '-c', 'http.lowSpeedLimit=' + (C.low_speed_limit || 1024), + '-c', 'http.lowSpeedTime=' + (C.low_speed_time || 20)) + if (prefix) a.push('-c', 'url.' + prefix + 'https://github.com/.insteadOf=https://github.com/') + return a.concat(['clone'], rest, [repo]) +} +async function directCloneFallback(repo, rest, targetDir) { + const D = CFG.direct_fallback || {} + if (D.enabled === false) { + console.warn(' 官方地址直连回退已禁用') + return false + } + const C = CFG.clone || {} + const attempts = Math.max(1, Math.min(3, Number(D.clone_retries || C.same_retry) || 2)) + if (fs.existsSync(targetDir)) { + console.error(' 目标目录已存在,拒绝删除或覆盖:' + targetDir) + return false + } + for (let attempt = 1; attempt <= attempts; attempt++) { + console.log(' 所有镜像失败,尝试官方 GitHub 地址直连 ' + attempt + '/' + attempts + ' ...') + const r = await run('git.exe', makeCloneArgs(rest, repo, null, true), 1800000, proxyFreeEnv()) + if (r.code === 0) { + console.log(' 官方 GitHub 地址直连克隆成功') + return true + } + console.warn(' 官方直连克隆失败:' + tail(r.stderr)) + cleanupFailedClone(targetDir) + if (attempt < attempts) await sleep(1000) + } + return false +} async function race(url, name) { const why = gate(url) if (why) { console.error('拒绝:' + why + '。只经反代取 GitHub 内容,且不携带凭据。'); process.exit(2) } - const st = await checkAll(false) + let st + try { + st = await checkAll(false) + } catch (e) { + console.warn('镜像体检失败,直接进入官方地址回退:' + String(e && e.message || e)) + if (!(await directDownloadFallback(url, name))) { + console.error('镜像体检与官方直连均失败') + process.exitCode = 4 + } + return + } const N = Math.max(CFG.race.max || 3, 2) let cands = pickRacers(st, N, true) if (!cands.length) { cands = pickRacers(st, N, false) if (cands.length) console.warn(' 没有入口过带宽门槛,退化为仅内容标记可用') } - if (!cands.length) { console.error('体检里没有可用入口,跑 node gh.mjs doctor --force'); process.exit(3) } + if (!cands.length) { + console.warn(' 没有可用镜像入口,转官方地址直连') + if (!(await directDownloadFallback(url, name))) { console.error('镜像与官方直连均失败'); process.exitCode = 4 } + return + } const R = CFG.race const parts = cands.slice(0, Math.max(R.max, 2)).map(function (e, i) { - return { entry: e, file: name + '.part' + i, proc: null, last: 0, stale: 0, tries: 0, firstSeen: 0 } + return { entry: e, file: name + '.part' + i, proc: null, last: 0, stale: 0, tries: 0, firstSeen: 0, exhausted: false } }) const launch = function (p, resume) { p.proc = startCurl(p.entry.prefix + url, p.file, !!resume) @@ -428,19 +564,40 @@ async function race(url, name) { for (const p of parts) { const sz = statSize(p.file) if (p.proc && p.proc.done) { - const ok = p.proc.code === 0 + const ok = p.proc.code === 0 && sz > 0 if (ok) { winner = p; report = p.proc.out; break } - if (sz > 0 && p.tries < (R.retries_per_entry || 2)) { p.tries++; launch(p, true); continue } + if (p.tries < (R.retries_per_entry || 2)) { + p.tries++ + launch(p, sz > 0) + continue + } p.proc = null + p.exhausted = true continue } if (!p.proc) continue // 从没拿到字节的入口早点踢掉 - if (sz === 0 && Date.now() - p.firstSeen > R.first_byte_ms) { p.proc.child.kill(); p.proc = null; continue } - // 增速低于阈值的判停滞,换源续传 + if (sz === 0 && Date.now() - p.firstSeen > R.first_byte_ms) { + p.proc.child.kill() + p.proc = null + if (p.tries < (R.retries_per_entry || 2)) { + p.tries++ + launch(p, false) + } else { + p.exhausted = true + } + continue + } + // 增速低于阈值的判停滞,换源续传;到预算后将入口标记为耗尽。 if (sz > 0 && sz - p.last < R.speed_limit_bps && Date.now() - p.stale > R.speed_time_ms) { p.proc.child.kill(); p.last = sz; p.stale = Date.now() - if (p.tries < (R.retries_per_entry || 2)) { p.tries++; launch(p, true) } else p.proc = null + if (p.tries < (R.retries_per_entry || 2)) { + p.tries++ + launch(p, true) + } else { + p.proc = null + p.exhausted = true + } continue } if (sz > p.last) { p.last = sz; p.stale = Date.now() } @@ -455,7 +612,7 @@ async function race(url, name) { } const alive = parts.filter(function (p) { return p.proc && !p.proc.done }).length if (alive === 0) { - const next = parts.filter(function (p) { return !p.proc }) + const next = parts.filter(function (p) { return !p.proc && !p.exhausted }) if (next.length) launch(next[0], statSize(next[0].file) > 0) else break } @@ -466,8 +623,11 @@ async function race(url, name) { if (!winner) { parts.forEach(function (p) { try { fs.unlinkSync(p.file) } catch (e) {} }) - console.error('竞速失败:没有入口拿到完整文件') - process.exit(4) + if (!(await directDownloadFallback(url, name))) { + console.error('镜像与官方直连均失败;请检查 DNS、网络链路或稍后重试') + process.exitCode = 4 + } + return } const m = String(report || '').match(/^(\d+)\s+(\d+)\s+([\d.]+)/) try { if (fs.existsSync(name)) fs.unlinkSync(name) } catch (e) {} @@ -503,33 +663,40 @@ async function clone(args) { const why = gate(repo) if (why) { console.error('拒绝:' + why + '。只经反代克隆 GitHub 仓库,且不携带凭据。'); process.exit(2) } const rest = args.filter(function (a) { return a !== repo }) - let candidates = await probeRefs(repo) + const targetDir = cloneOutputDir(repo, rest) + if (fs.existsSync(targetDir)) { + console.error('目标目录已存在,拒绝覆盖:' + targetDir + '。请指定一个不存在的新目录。') + process.exitCode = 2 + return + } + let candidates = [] + try { candidates = await probeRefs(repo) } catch (e) { + console.warn('镜像 info/refs 探测异常:' + String(e && e.message || e)) + } if (!candidates.length) { - const st = await checkAll(true) - candidates = pickRacers(st, Math.max(CFG.race.max || 3, 2), false) - console.log(' info/refs 探测没选出可用入口,退化为按体检评分顺序重试') + try { + const st = await checkAll(true) + candidates = pickRacers(st, Math.max(CFG.race.max || 3, 2), false) + } catch (e) { + console.warn('镜像体检异常:' + String(e && e.message || e)) + } + console.log(' info/refs 探测未找到可用入口,已尝试按体检评分回退') } - if (!candidates.length) { console.error('没有可用入口'); process.exit(3) } const C = CFG.clone || {} for (const e of candidates) { for (let attempt = 1; attempt <= (C.same_retry || 2); attempt++) { process.stdout.write(' 经 ' + label(e.prefix) + ' 第' + attempt + ' 次 ... ') - const gitArgs = ['-c', 'http.version=HTTP/1.1', - '-c', 'http.lowSpeedLimit=' + (C.low_speed_limit || 1024), - '-c', 'http.lowSpeedTime=' + (C.low_speed_time || 20), - '-c', 'url.' + e.prefix + 'https://github.com/.insteadOf=https://github.com/'] - .concat(['clone'], rest, [repo]) - const r = await run('git.exe', gitArgs, 1800000) + const r = await run('git.exe', makeCloneArgs(rest, repo, e.prefix, false), 1800000) if (r.code === 0) { console.log('完成'); return } console.log('失败 ' + tail(r.stderr)) - const dirs = rest.filter(function (x) { return x.charAt(0) !== '-' }) - const dirname = dirs.length > 1 ? dirs[1] : repo.replace(/\.git$/, '').split('/').pop() - try { if (dirname && fs.existsSync(dirname)) fs.rmSync(dirname, { recursive: true, force: true }) } catch (x) {} - // schannel 随机握手失败(实测约 1/3 概率)值得原地重试一次 + cleanupFailedClone(targetDir) if (!/handshake|RPC failed|empty reply|Failed to connect/i.test(r.stderr)) break } } - console.error('全部入口失败'); process.exit(4) + if (!(await directCloneFallback(repo, rest, targetDir))) { + console.error('镜像与官方直连均失败;未触碰运行前已存在的目标目录') + process.exitCode = 4 + } } async function doctor(show, explore) { @@ -593,6 +760,13 @@ if (SELFTEST) { T('没有尾巴时返回空', splitBody('just body'), { body: '', meta: '' }) T('正文里出现同样标记时取最后一个', splitBody('a__GHW__b\n__GHW__404'), { body: 'a__GHW__b', meta: '404' }) T('空输入不炸', splitBody(''), { body: '', meta: '' }) + // Direct fallback invariants: bypass environment proxies and never install mirror rewrites. + const directArgs = directCurlArgs('https://github.com/example/repo/archive/refs/heads/main.zip', 'x.part-direct') + T('官方下载强制 --noproxy', directArgs.includes('--noproxy') && directArgs[directArgs.indexOf('--noproxy') + 1] === '*', true) + const directGitArgs = makeCloneArgs(['--depth', '1'], 'https://github.com/octocat/Hello-World.git', null, true) + T('官方克隆回退不含镜像重写', directGitArgs.some(function (x) { return String(x).indexOf('.insteadOf=') >= 0 }), false) + T('官方克隆回退显式清空代理', directGitArgs.includes('http.proxy=') && directGitArgs.includes('http.https://github.com/.proxy='), true) + T('克隆目录解析不会把 --depth 的值当目标', cloneOutputDir('https://github.com/octocat/Hello-World.git', ['--depth', '1']), 'Hello-World') // 冷却阶梯 T('两次失败不冷却', cooldownFor(2, 1000), 0) T('三次失败进入 30 分钟冷却', cooldownFor(3, 1000) - 1000, 30 * 60e3)