mslxdff 0.1.219 → 0.1.221
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/package.json +3 -1
- package/src/groups.js +4 -1
- package/src/peers.js +54 -11
- package/src/routes/groups-relay.js +6 -2
- package/src/routes/peer-health.js +59 -0
- package/src/routes/peer-serial.js +84 -17
- package/src/routes/peers.js +20 -54
- package/src/runtime/broadband.js +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mslxdff",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.221",
|
|
4
4
|
"description": "测试项目,请勿使用。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/mslxdff.js",
|
|
11
11
|
"test": "node --test --test-concurrency=1 test/*.test.js",
|
|
12
|
+
"test:scoped": "node scripts/test-scoped.js",
|
|
13
|
+
"test:all": "node --test --test-concurrency=1 test/*.test.js",
|
|
12
14
|
"docs:check": "node scripts/docs-check.js"
|
|
13
15
|
},
|
|
14
16
|
"engines": {
|
package/src/groups.js
CHANGED
|
@@ -205,7 +205,10 @@ export function syncPeersFromMembers({ peers, members, myUrl, group, skipIds = [
|
|
|
205
205
|
// heartbeatLatencyMs:组长在**心跳**里量的成员延迟(成员上报 rttMs)。选路 `ordered()` 在没有
|
|
206
206
|
// 真实请求 EMA 时回落到它——这就是「组员定时上报的过程中就知道谁延迟更低」。
|
|
207
207
|
const hbLat = Number.isFinite(Number(m?.latencyMs)) && Number(m.latencyMs) >= 0 ? Number(m.latencyMs) : null;
|
|
208
|
-
|
|
208
|
+
// hasV6:该成员自己在心跳里报的「我有可用 IPv6 出口池」。IPv4 链全用完时才进 v6 阶段,
|
|
209
|
+
// 靠这个标记筛「谁的 v6 额度还能用」(见 routes/peer-serial.js 的 v6 阶段)。
|
|
210
|
+
const hasV6 = m?.hasV6 === true;
|
|
211
|
+
if (peers.add({ name: id, url, token: m?.token || "", group, hasV6, ...(hbLat != null ? { heartbeatLatencyMs: hbLat } : {}) })) added++;
|
|
209
212
|
}
|
|
210
213
|
return { removed, added, total: Object.keys(members || {}).length };
|
|
211
214
|
}
|
package/src/peers.js
CHANGED
|
@@ -144,6 +144,12 @@ export function createPeersService({
|
|
|
144
144
|
// ① stats 里的真实请求 EMA(recordResult 记的 latencyMs)——最可信;
|
|
145
145
|
// ② peer.heartbeatLatencyMs —— 组员**定时心跳**上报的 rtt(没跑过真实请求时也能排序);
|
|
146
146
|
// 未测过延迟的排在有数据之后;同延迟按「最早失败优先」;都没失败过的排最后。
|
|
147
|
+
// 组长识别:本机是组员时,peers 里有 name==="leader" 的记录(cli/group-test.js 同口径)。
|
|
148
|
+
// 组长自己节点视角没有这条记录 → isLeaderPeer 恒 false,退化为纯延迟排序(正是想要的行为)。
|
|
149
|
+
function isLeaderPeer(p) {
|
|
150
|
+
return String(p?.name || "").toLowerCase() === "leader";
|
|
151
|
+
}
|
|
152
|
+
|
|
147
153
|
function peerLatency(p) {
|
|
148
154
|
const s = stats[p.url]?.latencyMs;
|
|
149
155
|
if (s != null) return s;
|
|
@@ -151,20 +157,56 @@ export function createPeersService({
|
|
|
151
157
|
return Number.isFinite(Number(hb)) ? Number(hb) : null;
|
|
152
158
|
}
|
|
153
159
|
|
|
160
|
+
// 组员延迟升序(组长不参与),供两处复用:ordered 的第二档 + standbyChain。
|
|
161
|
+
function byLatencyAsc(a, b) {
|
|
162
|
+
const la = peerLatency(a);
|
|
163
|
+
const lb = peerLatency(b);
|
|
164
|
+
const ha = la != null, hb = lb != null;
|
|
165
|
+
if (ha !== hb) return ha ? -1 : 1; // 有延迟数据的优先
|
|
166
|
+
if (ha && hb && la !== lb) return la - lb; // 延迟小的先上
|
|
167
|
+
const ea = lastErrorAt[a.url];
|
|
168
|
+
const eb = lastErrorAt[b.url];
|
|
169
|
+
if (ea == null && eb == null) return 0;
|
|
170
|
+
if (ea == null) return 1;
|
|
171
|
+
if (eb == null) return -1;
|
|
172
|
+
return ea - eb;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 用户 2026-10-20 约定:**组长恒定第一**(先榨干组长额度),组长用完再按延迟升序上组员。
|
|
176
|
+
// 之前是纯延迟排序,「9/9 走组长」只是组长恰好延迟最低的巧合(AGENTS.md 已记差距)。
|
|
154
177
|
function ordered(t = now()) {
|
|
155
178
|
return available().sort((a, b) => {
|
|
156
|
-
const la =
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
const la = isLeaderPeer(a), lb = isLeaderPeer(b);
|
|
180
|
+
if (la !== lb) return la ? -1 : 1; // 组长恒排第一
|
|
181
|
+
return byLatencyAsc(a, b);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 常驻接力链(用户 2026-10-20:永远准备一个备选,429 立即切)。
|
|
187
|
+
* IPv4 阶段:组长 → 组员(延迟升序);IPv4 全用完才进 IPv6 阶段:先本机/组长 v6,
|
|
188
|
+
* 再按延迟升序找 hasV6 的组员。
|
|
189
|
+
* @returns {{ipv4: object[], ipv6: object[]}} 两段链,各自已排序
|
|
190
|
+
*/
|
|
191
|
+
function standbyChain(t = now()) {
|
|
192
|
+
const ipv4 = available().sort((a, b) => {
|
|
193
|
+
const la = isLeaderPeer(a), lb = isLeaderPeer(b);
|
|
194
|
+
if (la !== lb) return la ? -1 : 1;
|
|
195
|
+
return byLatencyAsc(a, b);
|
|
167
196
|
});
|
|
197
|
+
// IPv6 阶段:只保留心跳自报 hasV6 的节点,同样组长优先、其余延迟升序
|
|
198
|
+
const ipv6 = ipv4.filter((p) => p.hasV6 === true);
|
|
199
|
+
return { ipv4, ipv6 };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 下一个备选:从链里取第一个还没试过的。这就是「永远准备好的那个」。 */
|
|
203
|
+
function nextStandby(tried, t = now()) {
|
|
204
|
+
const { ipv4, ipv6 } = standbyChain(t);
|
|
205
|
+
const skip = tried instanceof Set ? tried : new Set(tried || []);
|
|
206
|
+
return {
|
|
207
|
+
ipv4: ipv4.find((p) => !skip.has(p.url)) || null,
|
|
208
|
+
ipv6: ipv6.find((p) => !skip.has(p.url)) || null,
|
|
209
|
+
};
|
|
168
210
|
}
|
|
169
211
|
|
|
170
212
|
// Available peers ordered specifically for recovery-time retry: earliest
|
|
@@ -259,6 +301,7 @@ export function createPeersService({
|
|
|
259
301
|
const peer = list.find((p) => p.url === url);
|
|
260
302
|
return peer ? isBroadbandStale(peer, now(), broadbandStaleMs()) : false;
|
|
261
303
|
}, isHot, stat, ordered, orderedByLastError, coolingByLastError, available, next,
|
|
304
|
+
standbyChain, nextStandby, isLeaderPeer,
|
|
262
305
|
recordError, recordResult, errors: () => ({ ...lastErrorAt }), stats: () => ({ ...stats }),
|
|
263
306
|
};
|
|
264
307
|
}
|
|
@@ -44,12 +44,16 @@ export async function heartbeatHandler({ req, res, groups, bus, logs }) {
|
|
|
44
44
|
const reportedRtt = Number(body?.rttMs);
|
|
45
45
|
const memberRtt = Number.isFinite(reportedRtt) && reportedRtt >= 0 ? Math.round(reportedRtt) : null;
|
|
46
46
|
if (memberRtt != null) m.latencyMs = memberRtt;
|
|
47
|
+
// hasV6:成员自报「我这台有没有可用的 IPv6 出口池」。IPv4 链全用完后的 v6 阶段靠它筛候选
|
|
48
|
+
// (先组长 v6 → 再按延迟升序找有 v6 的组员)。
|
|
49
|
+
const hasV6 = body?.hasV6 === true;
|
|
50
|
+
m.hasV6 = hasV6;
|
|
47
51
|
try {
|
|
48
|
-
groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen, ...(memberRtt != null ? { latencyMs: memberRtt } : {}) });
|
|
52
|
+
groups.upsertMember(groupName, { memberName: targetId, url: m.url, token: m.token, kind: "broadband", publicIp: ip, lastSeen: m.lastSeen, hasV6, ...(memberRtt != null ? { latencyMs: memberRtt } : {}) });
|
|
49
53
|
} catch {}
|
|
50
54
|
}
|
|
51
55
|
const serverMs = Date.now() - tRecv;
|
|
52
|
-
const evtData = { ts: Date.now(), type: "relay-heartbeat", member: targetId, ip, lastSeen: m?.lastSeen, rttMs: Number.isFinite(Number(body?.rttMs)) ? Number(body.rttMs) : null, serverMs, group: groupName };
|
|
56
|
+
const evtData = { ts: Date.now(), type: "relay-heartbeat", member: targetId, ip, lastSeen: m?.lastSeen, rttMs: Number.isFinite(Number(body?.rttMs)) ? Number(body.rttMs) : null, hasV6: body?.hasV6 === true, serverMs, group: groupName };
|
|
53
57
|
if (bus) bus.emit(evtData);
|
|
54
58
|
logs?.appendEvent?.(evtData);
|
|
55
59
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// 组员健康探测与缓存:拉对方的 /v1/models/status,拿「该组员当前健康可用的模型」。
|
|
2
|
+
// 从 routes/peers.js 拆出(2026-10-20):那个文件已到 20KB 硬闸,健康探测这块自洽
|
|
3
|
+
// (只依赖 compatFetch),拆走后 peers.js 回到闸内,也更好单测。
|
|
4
|
+
import { compatFetch, timeoutSignal } from "../compat.js";
|
|
5
|
+
|
|
6
|
+
const PEER_STATUS_TIMEOUT_MS = 2_000;
|
|
7
|
+
|
|
8
|
+
function peerHealthTtlMs() {
|
|
9
|
+
const n = Number(process.env.MSLXDFF_PEER_HEALTH_TTL_MS);
|
|
10
|
+
return Number.isInteger(n) && n >= 0 ? n : 30_000;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const healthCache = new Map(); // url -> { at, data }
|
|
14
|
+
const healthInflight = new Map(); // url -> Promise
|
|
15
|
+
|
|
16
|
+
export function clearPeerHealthCache() {
|
|
17
|
+
healthCache.clear();
|
|
18
|
+
healthInflight.clear();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = compatFetch } = {}) {
|
|
22
|
+
const key = peer?.url || "";
|
|
23
|
+
const ttl = peerHealthTtlMs();
|
|
24
|
+
if (ttl > 0) {
|
|
25
|
+
const hit = healthCache.get(key);
|
|
26
|
+
if (hit && Date.now() - hit.at < ttl) return hit.data;
|
|
27
|
+
const inflight = healthInflight.get(key);
|
|
28
|
+
if (inflight) return inflight;
|
|
29
|
+
}
|
|
30
|
+
const p = (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetchImpl(`${peer.url}/v1/models/status`, {
|
|
33
|
+
headers: {
|
|
34
|
+
"Authorization": `Bearer ${peer.token || ""}`,
|
|
35
|
+
"Accept": "application/json",
|
|
36
|
+
},
|
|
37
|
+
signal: timeoutSignal(timeoutMs),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) return [];
|
|
40
|
+
const j = await res.json().catch(() => ({}));
|
|
41
|
+
return (j.data || [])
|
|
42
|
+
.filter((m) => m && typeof m.id === "string" && m.status === "normal")
|
|
43
|
+
.map((m) => m.id);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
if (ttl > 0) {
|
|
49
|
+
healthInflight.set(key, p);
|
|
50
|
+
try {
|
|
51
|
+
const data = await p;
|
|
52
|
+
healthCache.set(key, { at: Date.now(), data });
|
|
53
|
+
return data;
|
|
54
|
+
} finally {
|
|
55
|
+
healthInflight.delete(key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return p;
|
|
59
|
+
}
|
|
@@ -1,31 +1,58 @@
|
|
|
1
|
-
// 组员串行选路(用户 2026-10-19
|
|
1
|
+
// 组员串行选路(用户 2026-10-19 决定:不竞速;2026-10-20 升级为**常驻热备**)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// 两种模式下都必须满足的语义:
|
|
4
|
+
// 「本机额度用完 → 用延迟最小的组员」,但**组长恒定第一**(先榨干组长额度),
|
|
5
|
+
// 组长也满了才按延迟升序逐个组员顶上。
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
+
// 2026-10-20 用户要求「永远准备一个备选,429 立即切」:
|
|
8
|
+
// ① 进循环前就**预取**下一个候选(不是每次现排序);
|
|
9
|
+
// ② 切换成功后**立即再预取**下一个;
|
|
10
|
+
// ③ 对预取的备选做**连接预热**(轻量 /health 建连),切过去时连接已在池里,省掉建连 RTT。
|
|
11
|
+
//
|
|
12
|
+
// IPv4 全用完 → 进 IPv6 阶段:先组长 v6,再按延迟升序找 hasV6 的组员。
|
|
13
|
+
//
|
|
14
|
+
// 本模块只做「按序试 + 预取」这件事,其余(画目标、转发、记账、事件名)从 peers.js 注入,避免循环依赖。
|
|
7
15
|
export const PEER_SERIAL = true;
|
|
8
16
|
|
|
9
17
|
/**
|
|
10
|
-
*
|
|
18
|
+
* 连接预热:对备选节点发一个极轻的请求,只为让 undici 连接池里提前建好 TCP/TLS 连接。
|
|
19
|
+
* 失败静默——预热只是提速,不是前置条件;真发请求时该失败还是会失败。
|
|
20
|
+
* @param {object} peer 目标 peer(需 url)
|
|
21
|
+
* @param {object} deps { warmFetch?, timeoutMs? }
|
|
22
|
+
*/
|
|
23
|
+
export function warmPeer(peer, deps = {}) {
|
|
24
|
+
const { warmFetch, timeoutMs = 1500 } = deps;
|
|
25
|
+
if (!peer?.url || typeof warmFetch !== "function") return Promise.resolve();
|
|
26
|
+
try {
|
|
27
|
+
const p = warmFetch(`${peer.url}/health`, { method: "GET", timeoutMs });
|
|
28
|
+
if (p && typeof p.catch === "function") p.catch(() => {});
|
|
29
|
+
} catch { /* 预热失败不影响主流程 */ }
|
|
30
|
+
return Promise.resolve();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 串行试候选(预取 + 预热版):一次只打一个,成功即返回;失败顺位试下一个。
|
|
11
35
|
*
|
|
12
36
|
* @param {object} p
|
|
13
|
-
* @param {Array} p.candidates
|
|
37
|
+
* @param {Array} p.candidates 已排好序的组员(组长第一、其余延迟升序)
|
|
38
|
+
* @param {Array} [p.v6Candidates] IPv4 全用完后的 v6 阶段候选(同样已排序)
|
|
14
39
|
* @param {object} p.ctx 请求上下文(evt/peers/body/hops/plugins/peerErrors/triedUrls…)
|
|
15
40
|
* @param {Set} p.tried 已试过的 url 集合
|
|
16
|
-
* @param {object} p.deps { resolvePeerTarget, forwardToPeer, peerName, recordLater,
|
|
41
|
+
* @param {object} p.deps { resolvePeerTarget, forwardToPeer, warmPeer, peerName, recordLater,
|
|
17
42
|
* summarizeRequest, runHook, errMsg, perAttemptCap, deadline, budgetMs }
|
|
18
43
|
* @returns {Promise<null | {peer, target, res, latencyMs}>}
|
|
19
44
|
*/
|
|
20
|
-
export async function serialTryPeers({ candidates, ctx, tried, deps }) {
|
|
45
|
+
export async function serialTryPeers({ candidates, v6Candidates = [], ctx, tried, deps }) {
|
|
21
46
|
const {
|
|
22
47
|
resolvePeerTarget, forwardToPeer, peerName, recordLater, summarizeRequest, runHook, errMsg,
|
|
23
48
|
perAttemptCap, deadline, budgetMs,
|
|
24
49
|
} = deps;
|
|
50
|
+
const warm = deps.warmPeer || warmPeer;
|
|
25
51
|
const failed = [];
|
|
52
|
+
|
|
26
53
|
// 延迟上限过滤:历史延迟超过「单次尝试上限」的组员直接跳过(它大概率又超时)。
|
|
27
54
|
// 只在**还有别的候选**时才跳过,避免把唯一可用的节点也跳没了。
|
|
28
|
-
const
|
|
55
|
+
const filterSlow = (list) => list.filter((p) => {
|
|
29
56
|
let lat = null;
|
|
30
57
|
try {
|
|
31
58
|
// 与 ordered() 同源:先看真实请求 EMA,再回落到心跳上报的 rtt
|
|
@@ -33,23 +60,63 @@ export async function serialTryPeers({ candidates, ctx, tried, deps }) {
|
|
|
33
60
|
if (lat != null) lat = Number(lat);
|
|
34
61
|
} catch { lat = null; }
|
|
35
62
|
if (lat == null || !Number.isFinite(lat) || lat <= perAttemptCap) return true;
|
|
36
|
-
if (
|
|
63
|
+
if (list.length <= 1) return true; // 只剩它 → 仍然试
|
|
37
64
|
ctx.evt("peer-skip-slow", { peer: p.url, name: peerName(p), latencyMs: lat, capMs: perAttemptCap });
|
|
38
65
|
return false;
|
|
39
66
|
});
|
|
40
67
|
|
|
41
|
-
|
|
68
|
+
// 两段链:先 IPv4(组长→组员延迟升序),IPv4 全败才进 IPv6(同样组长优先 + hasV6 过滤)。
|
|
69
|
+
// 只保留本次还没试过的。
|
|
70
|
+
const fresh = (list) => list.filter((p) => !tried.has(p.url));
|
|
71
|
+
const ipv4Chain = filterSlow(fresh(candidates));
|
|
72
|
+
const ipv6Chain = filterSlow(fresh(v6Candidates));
|
|
73
|
+
|
|
74
|
+
// —— 预取:进循环前就把「下一个」定好,并顺手预热连接 ——
|
|
75
|
+
let pending = null;
|
|
76
|
+
const prime = (peer) => {
|
|
77
|
+
if (!peer) return;
|
|
78
|
+
warm(peer, deps.warmPeerDeps);
|
|
79
|
+
};
|
|
80
|
+
const takeNext = (stage) => {
|
|
81
|
+
const chain = stage === "ipv6" ? ipv6Chain : ipv4Chain;
|
|
82
|
+
const p = chain.shift() || null;
|
|
83
|
+
if (p) prime(chain[0] || null); // 立即预热「再下一个」,保证永远有备选就绪
|
|
84
|
+
return p;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
let stage = "ipv4";
|
|
88
|
+
let emptyIpv4Announced = false;
|
|
89
|
+
|
|
90
|
+
for (;;) {
|
|
42
91
|
if (deadline > 0 && Date.now() >= deadline) {
|
|
43
|
-
|
|
92
|
+
const remaining = ipv4Chain.length + ipv6Chain.length;
|
|
93
|
+
ctx.evt("peer-budget-out", { peer: null, reason: `peer 阶段总预算 ${budgetMs}ms 用尽`, budgetMs, remaining });
|
|
44
94
|
break;
|
|
45
95
|
}
|
|
46
|
-
|
|
96
|
+
pending = takeNext(stage);
|
|
97
|
+
// IPv4 链走空 → 切 IPv6 阶段(用户约定:所有正常 IPv4 额度用完才动 v6)
|
|
98
|
+
if (!pending && stage === "ipv4") {
|
|
99
|
+
stage = "ipv6";
|
|
100
|
+
if (!ipv6Chain.length) {
|
|
101
|
+
if (!emptyIpv4Announced) {
|
|
102
|
+
emptyIpv4Announced = true;
|
|
103
|
+
ctx.evt("peer-v6-stage", { entered: false, reason: ipv4Chain.length ? "ipv4 未走空" : "无 hasV6 候选" });
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
ctx.evt("peer-v6-stage", { entered: true, candidates: ipv6Chain.length });
|
|
108
|
+
pending = takeNext("ipv6");
|
|
109
|
+
if (!pending) break;
|
|
110
|
+
}
|
|
111
|
+
if (!pending) break;
|
|
112
|
+
|
|
113
|
+
const prepared = await resolvePeerTarget(ctx, pending);
|
|
47
114
|
if (!prepared) continue;
|
|
48
115
|
const { peer, target } = prepared;
|
|
49
116
|
tried.add(peer.url);
|
|
50
117
|
const ctrl = new AbortController();
|
|
51
118
|
const name = peerName(peer);
|
|
52
|
-
ctx.evt("peer-request", { peer: peer.url, name, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body), serial:
|
|
119
|
+
ctx.evt("peer-request", { peer: peer.url, name, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body), serial: 1, stage });
|
|
53
120
|
if (ctx.plugins?.length) {
|
|
54
121
|
runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
|
|
55
122
|
}
|
|
@@ -68,14 +135,14 @@ export async function serialTryPeers({ candidates, ctx, tried, deps }) {
|
|
|
68
135
|
if (isErr || status >= 400) {
|
|
69
136
|
const msg = isErr ? errMsg(res).slice(0, 200) : null;
|
|
70
137
|
if (Array.isArray(ctx.peerErrors)) ctx.peerErrors.push({ peer: peer.url, status, message: msg });
|
|
71
|
-
ctx.evt("peer-attempt", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: false, status, message: msg });
|
|
138
|
+
ctx.evt("peer-attempt", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: false, status, message: msg, stage });
|
|
72
139
|
ctx.evt("peer-error", { peer: peer.url, name, model: target, status, message: msg });
|
|
73
140
|
failed.push({ ok: false, peer, target, res, status });
|
|
74
141
|
recordLater(() => ctx.peers.recordError(peer.url, { status, message: msg }));
|
|
75
142
|
recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
|
|
76
|
-
continue; //
|
|
143
|
+
continue; // 串行:试下一个(预取的下一个已经在路上)
|
|
77
144
|
}
|
|
78
|
-
ctx.evt("peer-forward", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: true, status });
|
|
145
|
+
ctx.evt("peer-forward", { peer: peer.url, name, model: target, hops: ctx.hops + 1, latencyMs, ok: true, status, stage });
|
|
79
146
|
return { peer, target, res, latencyMs };
|
|
80
147
|
}
|
|
81
148
|
|
package/src/routes/peers.js
CHANGED
|
@@ -126,58 +126,10 @@ export function stripCallerBoundReasoning(body) {
|
|
|
126
126
|
return changed ? { ...body, messages: out } : body;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const healthCache = new Map(); // url -> { at, data }
|
|
135
|
-
const healthInflight = new Map(); // url -> Promise
|
|
136
|
-
|
|
137
|
-
export function clearPeerHealthCache() {
|
|
138
|
-
healthCache.clear();
|
|
139
|
-
healthInflight.clear();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_MS, fetchImpl = compatFetch } = {}) {
|
|
143
|
-
const key = peer?.url || "";
|
|
144
|
-
const ttl = peerHealthTtlMs();
|
|
145
|
-
if (ttl > 0) {
|
|
146
|
-
const hit = healthCache.get(key);
|
|
147
|
-
if (hit && Date.now() - hit.at < ttl) return hit.data;
|
|
148
|
-
const inflight = healthInflight.get(key);
|
|
149
|
-
if (inflight) return inflight;
|
|
150
|
-
}
|
|
151
|
-
const p = (async () => {
|
|
152
|
-
try {
|
|
153
|
-
const res = await fetchImpl(`${peer.url}/v1/models/status`, {
|
|
154
|
-
headers: {
|
|
155
|
-
"Authorization": `Bearer ${peer.token || ""}`,
|
|
156
|
-
"Accept": "application/json",
|
|
157
|
-
},
|
|
158
|
-
signal: timeoutSignal(timeoutMs),
|
|
159
|
-
});
|
|
160
|
-
if (!res.ok) return [];
|
|
161
|
-
const j = await res.json().catch(() => ({}));
|
|
162
|
-
return (j.data || [])
|
|
163
|
-
.filter((m) => m && typeof m.id === "string" && m.status === "normal")
|
|
164
|
-
.map((m) => m.id);
|
|
165
|
-
} catch {
|
|
166
|
-
return [];
|
|
167
|
-
}
|
|
168
|
-
})();
|
|
169
|
-
if (ttl > 0) {
|
|
170
|
-
healthInflight.set(key, p);
|
|
171
|
-
try {
|
|
172
|
-
const data = await p;
|
|
173
|
-
healthCache.set(key, { at: Date.now(), data });
|
|
174
|
-
return data;
|
|
175
|
-
} finally {
|
|
176
|
-
healthInflight.delete(key);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return p;
|
|
180
|
-
}
|
|
129
|
+
// 健康探测与缓存已拆到 ./peer-health.js(本文件受 20KB 硬闸约束)。
|
|
130
|
+
// 这里 import 后再 re-export:既要给本文件内部函数用,也要保持既有 import 方无需改动。
|
|
131
|
+
import { clearPeerHealthCache, peerHealthyModels } from "./peer-health.js";
|
|
132
|
+
export { clearPeerHealthCache, peerHealthyModels };
|
|
181
133
|
|
|
182
134
|
// controller 由调用方持有:赛跑 winner 确定后可主动 abort 其余 in-flight,
|
|
183
135
|
// 避免迟到流挂满 bodyTimeout(并给对方省下无用负载)。
|
|
@@ -273,9 +225,23 @@ export async function racePeerCandidates(candidates, ctx) {
|
|
|
273
225
|
|
|
274
226
|
// 串行:按 candidates 的既有次序(调用方已按「延迟最小优先」排好)逐个试。
|
|
275
227
|
if (PEER_SERIAL) {
|
|
228
|
+
// 常驻热备(用户 2026-10-20):IPv4 链走空后进 IPv6 阶段。
|
|
229
|
+
// v6 候选 = 心跳自报 hasV6 的节点(组长优先、其余延迟升序),由 peers.standbyChain 产出。
|
|
230
|
+
// 兼容:ctx.peers 可能是测试注入的假对象,缺 standbyChain 时 v6 阶段自然为空。
|
|
231
|
+
let v6Candidates = [];
|
|
232
|
+
try {
|
|
233
|
+
v6Candidates = ctx.peers?.standbyChain?.().ipv6 ?? [];
|
|
234
|
+
} catch { v6Candidates = []; }
|
|
276
235
|
return serialTryPeers({
|
|
277
|
-
candidates: fresh,
|
|
278
|
-
|
|
236
|
+
candidates: fresh,
|
|
237
|
+
v6Candidates,
|
|
238
|
+
ctx, tried,
|
|
239
|
+
deps: {
|
|
240
|
+
resolvePeerTarget, forwardToPeer, peerName, recordLater, summarizeRequest, runHook, errMsg,
|
|
241
|
+
perAttemptCap: peerAttemptTimeoutMs(), deadline, budgetMs,
|
|
242
|
+
// 连接预热:对「下一个备选」提前打 /health,复用 chat 的同一连接池(见 warmPeerHealth 注释)
|
|
243
|
+
warmPeer: (peer) => { if (peer?.url) warmPeerHealth(peer.url, { timeoutMs: 1500 }).catch(() => {}); },
|
|
244
|
+
},
|
|
279
245
|
});
|
|
280
246
|
}
|
|
281
247
|
|
package/src/runtime/broadband.js
CHANGED
|
@@ -101,6 +101,15 @@ export function startBroadband({ token, upstream, logs, log = console.log }) {
|
|
|
101
101
|
// 组长侧据此排序「谁的延迟更低」(选路=延迟最小优先,见 routes/peers.js 的 ordered)。
|
|
102
102
|
// 用上一轮而不是本轮的原因:本轮 RTT 要等响应回来才知道,来不及带在这次请求里。
|
|
103
103
|
const hbRtt = new Map(); // group name -> ms
|
|
104
|
+
// 本机 IPv6 出口池是否可用:心跳里带 hasV6,组长据此知道「谁的 v6 额度还能用」。
|
|
105
|
+
// 只有本机真的建起了带地址的 v6 池才算 true(空池/未检测到前缀 = false)。
|
|
106
|
+
const v6Available = () => {
|
|
107
|
+
try {
|
|
108
|
+
const d = upstream?.dispatcher;
|
|
109
|
+
const slots = d?.stats?.().slots;
|
|
110
|
+
return Array.isArray(slots) && slots.some((s) => s.addr && !s.cooling);
|
|
111
|
+
} catch { return false; }
|
|
112
|
+
};
|
|
104
113
|
const doHeartbeat = async () => {
|
|
105
114
|
for (const g of broadbandGroups()) {
|
|
106
115
|
try {
|
|
@@ -108,7 +117,7 @@ export function startBroadband({ token, upstream, logs, log = console.log }) {
|
|
|
108
117
|
const res = await compatFetch(`${g.leaderUrl}/v1/groups/relay/heartbeat`, {
|
|
109
118
|
method: "POST",
|
|
110
119
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
111
|
-
body: JSON.stringify({ name: g.name, group: g.name, rttMs: hbRtt.get(g.name) ?? null }),
|
|
120
|
+
body: JSON.stringify({ name: g.name, group: g.name, rttMs: hbRtt.get(g.name) ?? null, hasV6: v6Available() }),
|
|
112
121
|
signal: timeoutSignal(5000),
|
|
113
122
|
});
|
|
114
123
|
const rtt = Date.now() - t0;
|