diff --git a/apps/agent/src/capabilities/selfupdate.ts b/apps/agent/src/capabilities/selfupdate.ts index 4162504..8ce3fc9 100644 --- a/apps/agent/src/capabilities/selfupdate.ts +++ b/apps/agent/src/capabilities/selfupdate.ts @@ -102,6 +102,12 @@ export function describeNetError(err: unknown, timeoutMs: number): string { if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ECONNREFUSED') { return `${msg}(域名解析失败或目标拒绝连接;若配了代理,请核对代理地址与端口)`; } + // 2026-10-27 真机补:境内直连 GitHub 的**最常见**失败形态就是 TCP 层 ETIMEDOUT + // (不是我们自己的 timeoutMs,也不是 ECONNRESET)。原先它落到下面兜底分支, + // 用户只看到一串 IP:443 超时,不知道该干什么 —— 而这时唯一有效的动作就是配代理。 + if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') { + return `${msg}(连接超时/网络不可达;若目标在境外或需要代理,请设置 NA_PROXY=<代理地址> 或配置系统代理后重试)`; + } return msg; } diff --git a/tests/unit/selfupdate-backup-cleanup.test.mjs b/tests/unit/selfupdate-backup-cleanup.test.mjs index a421b58..be6fe28 100644 --- a/tests/unit/selfupdate-backup-cleanup.test.mjs +++ b/tests/unit/selfupdate-backup-cleanup.test.mjs @@ -85,3 +85,23 @@ test('错误分类:DNS/拒连单独一类,普通错误原样透出', () => { const plain = new Error('boom'); assert.equal(describeNetError(plain, 1000), 'boom', '无法归类的错误原样透出,不编造原因'); }); + +test('describeNetError:TCP 层 ETIMEDOUT 必须给出「配代理」的可执行提示(2026-10-27 真机)', () => { + // 真机现场:境内 Windows 直连 github.com:443 → connect ETIMEDOUT 20.205.243.166:443。 + // 这条最常见,原先落到兜底分支只回一串超时,用户不知道唯一有效动作是配代理。 + const etimedout = Object.assign(new Error('connect ETIMEDOUT 20.205.243.166:443'), { code: 'ETIMEDOUT' }); + const msg = describeNetError(etimedout, 20_000); + assert.match(msg, /连接超时\/网络不可达/); + assert.match(msg, /NA_PROXY/, '必须点明 NA_PROXY 这个具体开关'); + assert.match(msg, /系统代理/); + + // 同族错误走同一提示 + for (const code of ['EHOSTUNREACH', 'ENETUNREACH']) { + assert.match(describeNetError(Object.assign(new Error('x'), { code }), 1000), /NA_PROXY/, code); + } + + // 不得与 ECONNRESET 的「代理或线路问题」混为一谈(两类原因不同) + const reset = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + assert.match(describeNetError(reset, 1000), /代理或线路/); + assert.doesNotMatch(describeNetError(reset, 1000), /NA_PROXY/); +});