dsh-lan-peers 0.7.0 → 0.7.1
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/HANDOVER.md +3 -1
- package/client/cli-group.test.mjs +4 -2
- package/client/cli.mjs +2 -1
- package/host/server.mjs +46 -15
- package/host/test/group-auth.test.mjs +3 -1
- package/host/test/group-cluster.mjs +5 -4
- package/host/test/group-commit.test.mjs +1 -1
- package/host/test/group-election.test.mjs +69 -1
- package/host/test/group.test.mjs +9 -3
- package/lib/admin-group.test.mjs +43 -3
- package/lib/admin.mjs +16 -6
- package/package.json +1 -1
package/HANDOVER.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
| 位置 | 形态 | 版本 |
|
|
13
13
|
|---|---|---|
|
|
14
|
-
| npm | 公开包 `dsh-lan-peers`,`latest` | **0.
|
|
14
|
+
| npm | 公开包 `dsh-lan-peers`,`latest` | **0.7.0**(2026-10-27 发布;51 文件 / 231.7 kB;含群聊 Phase1+2 与 S1/S2/S3)|
|
|
15
15
|
| NAS `192.168.5.51`(fn-302,Debian 12) | `link:` 安装进 `/home/ai/.dsh/profiles/web`,systemd `dsh-web` 托管 | **0.4.2**(2026-10-27 20:35 部署;含 unpair + admin 审计日志)|
|
|
16
16
|
| Windows 桌面版(Electron) | `link:D:/DS-Harness/lan-peers`(见 §7)| **0.4.0**(2026-10-27 重启生效;面板与 SSE 派单已实测)|
|
|
17
17
|
|
|
@@ -352,6 +352,8 @@ node interop/run-interop.mjs # A 旧→新明文+弃用告警 / B 新→旧
|
|
|
352
352
|
|
|
353
353
|
**发布记录**
|
|
354
354
|
|
|
355
|
+
- **0.7.0 → npm(2026-10-27)**:`npm publish` 成功(granular token,无 OTP);51 文件 / 231.7 kB;注册表传播比上次慢(约 2 分钟后 `latest` 才变 0.7.0,期间 `npm view` 仍报 0.4.0 —— **拿不准时先查 `dist-tags` 再重试,别急着重发**)。临时 profile 真装验证:`installed 0.7.0`、`bin.dsh-lan-peers=client/cli.mjs`、`lib/group-client.mjs` 在包内、`interop/` 未打包(符合 `files` 白名单)。
|
|
356
|
+
|
|
355
357
|
- 0.4.0 · 2026-10-27 · `npm publish` 成功(granular token,无 OTP)·34 文件 / 87.9 kB · shasum `204999ebe7fa3190fd2603c81ab4d96767e52538`;NAS 与 npm 均已 0.4.0,桌面待重启。
|
|
356
358
|
- **0.4.1 → NAS(2026-10-27 20:27)**:未发 npm,只部署 NAS。备份 `/tmp/lan-peers-backup-0.4.0-20261027T122711Z.tgz`(88660B,sha256 `5185a9cb…`);包内 45 条目、双端 sha256 一致、覆盖式解包;`dsh-web` active;health = `0.4.1` + capability 含 `unpair`;真实派单走 SSE 成功(elapsed 1227ms)。
|
|
357
359
|
- **0.4.2 → NAS(2026-10-27 20:35)**:只部署 NAS,未发 npm。备份 `/tmp/lan-peers-backup-0.4.1-20261027T123459Z.tgz`(116433B,49 条目);包 46 条目、双端 sha256 `675ac1d9…` 一致、覆盖解包;health = `0.4.2` + `unpair`;`[admin] pairing code issued ttl=600s` 实测出现且**日志无 6 位码**(正则命中 0);真实派单 reply「二次部署」;NAS `peers` 1→1 不变。
|
|
@@ -43,7 +43,9 @@ async function startHub(t, overrides = {}) {
|
|
|
43
43
|
const server = createPeerServer({
|
|
44
44
|
listenHost: '127.0.0.1',
|
|
45
45
|
stateDir,
|
|
46
|
-
|
|
46
|
+
// S3:majority 现在真的走 propose/commit(假成员没有投票端点会 503);
|
|
47
|
+
// 这个文件测的是 v0.5 的 CLI 行为,固定 quorum:none = 写本地即提交。
|
|
48
|
+
group: { enabled: true, autoJoin: true, quorum: 'none' },
|
|
47
49
|
...overrides,
|
|
48
50
|
});
|
|
49
51
|
const info = await server.listen(0, '127.0.0.1');
|
|
@@ -161,7 +163,7 @@ test('group tail 跟随事件流并逐条打印(期间别人发的消息也收
|
|
|
161
163
|
});
|
|
162
164
|
|
|
163
165
|
test('group join 在 hub 未开 autoJoin 时 exit 2 并说明原因', async (t) => {
|
|
164
|
-
const hub = await startHub(t, { group: { enabled: true, autoJoin: false } });
|
|
166
|
+
const hub = await startHub(t, { group: { enabled: true, autoJoin: false, quorum: 'none' } });
|
|
165
167
|
const dir = memberStateDir(t, { baseUrl: hub.baseUrl, hubInstanceId: hub.server.store.instanceId });
|
|
166
168
|
|
|
167
169
|
const out = await runAsync(['group', 'join', '--peer', 'hub', '--state-dir', dir]);
|
package/client/cli.mjs
CHANGED
|
@@ -698,7 +698,8 @@ async function cmdGroupClaim(dir, args) {
|
|
|
698
698
|
}
|
|
699
699
|
process.stderr.write(
|
|
700
700
|
'接管未成功: ' + (reply?.reason || 'vote-refused')
|
|
701
|
-
+ '
|
|
701
|
+
+ ' 票数=' + (reply?.votes ?? '?') + '/' + (reply?.needed ?? '?')
|
|
702
|
+
+ '(成员表 ' + (reply?.memberCount ?? '?') + ' 人;多数派分母是成员表全集,不是可达成员数)\n',
|
|
702
703
|
);
|
|
703
704
|
process.exitCode = 2;
|
|
704
705
|
}
|
package/host/server.mjs
CHANGED
|
@@ -96,7 +96,7 @@ import { parseArgs } from 'node:util';
|
|
|
96
96
|
// ---------------------------------------------------------------------------
|
|
97
97
|
|
|
98
98
|
/** 插件版本,用于 /v1/health.version */
|
|
99
|
-
export const PLUGIN_VERSION = '0.7.
|
|
99
|
+
export const PLUGIN_VERSION = '0.7.1';
|
|
100
100
|
|
|
101
101
|
/** §4.1 允许的时钟偏移 */
|
|
102
102
|
export const CLOCK_SKEW_MS = 60_000;
|
|
@@ -2938,13 +2938,19 @@ export function createPeerServer(overrides = {}) {
|
|
|
2938
2938
|
writeGroupKeyFile(config.stateDir, { ...existing, members });
|
|
2939
2939
|
}
|
|
2940
2940
|
|
|
2941
|
-
/**
|
|
2941
|
+
/**
|
|
2942
|
+
* 对端可达地址:学到的 > 群成员表(group.json / group-key.json,读盘即最新)> peers.json。
|
|
2943
|
+
* 群成员表必须排在 peers.json 前面:成员节点被 bootstrap 之前,peers.json 里可能是旧地址。
|
|
2944
|
+
*/
|
|
2942
2945
|
function baseUrlOf(peerId) {
|
|
2943
2946
|
const id = String(peerId ?? '');
|
|
2944
2947
|
if (id === '') return '';
|
|
2945
2948
|
if (id === store.instanceId) return localAdvertiseBaseUrl();
|
|
2946
2949
|
const learned = String(peerBaseUrls.get(id) ?? '').trim().replace(/\/+$/, '');
|
|
2947
2950
|
if (learned !== '') return learned;
|
|
2951
|
+
const fromGroup = String(localGroupContext()?.members?.[id]?.baseUrl ?? '')
|
|
2952
|
+
.trim().replace(/\/+$/, '');
|
|
2953
|
+
if (fromGroup !== '') return fromGroup;
|
|
2948
2954
|
const recorded = String(groupStore ? groupStore.state().members[id]?.baseUrl ?? '' : '')
|
|
2949
2955
|
.trim().replace(/\/+$/, '');
|
|
2950
2956
|
if (recorded !== '') return recorded;
|
|
@@ -2971,21 +2977,20 @@ export function createPeerServer(overrides = {}) {
|
|
|
2971
2977
|
}
|
|
2972
2978
|
|
|
2973
2979
|
/**
|
|
2974
|
-
*
|
|
2975
|
-
*
|
|
2976
|
-
*
|
|
2977
|
-
*
|
|
2978
|
-
* 契约的「成员总数含自己」完全等价。
|
|
2980
|
+
* 承诺集合 = **成员表全集**(含自己,去重)。
|
|
2981
|
+
* 分母绝不能按「知道地址」过滤:那会让刚启动/还没学到地址的节点把分母压到 1,
|
|
2982
|
+
* 于是自投即当选(2026-10-27 真机事故:桌面节点在两节点群里 epoch=2 自立为 leader)。
|
|
2983
|
+
* 地址只决定「提案/拉票发给谁」,不决定多数派的分母(契约 §3:N = 成员总数含自己)。
|
|
2979
2984
|
*/
|
|
2980
2985
|
function quorumMembers() {
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2986
|
+
const ids = new Set(groupMemberIds());
|
|
2987
|
+
ids.add(store.instanceId);
|
|
2988
|
+
return [...ids];
|
|
2984
2989
|
}
|
|
2985
2990
|
|
|
2986
|
-
/** 提案目标 =
|
|
2991
|
+
/** 提案目标 = 除自己以外、地址已知(含 peers.json 兜底)的成员;地址缺失只影响投递 */
|
|
2987
2992
|
function proposeTargets() {
|
|
2988
|
-
return
|
|
2993
|
+
return peerTargets();
|
|
2989
2994
|
}
|
|
2990
2995
|
|
|
2991
2996
|
/** 契约 §3:majority = floor(N/2)+1(N 含自己),none = 1 */
|
|
@@ -3255,8 +3260,11 @@ export function createPeerServer(overrides = {}) {
|
|
|
3255
3260
|
return { ok: false, reason: 'no-group-key' };
|
|
3256
3261
|
}
|
|
3257
3262
|
const targets = peerTargets();
|
|
3263
|
+
// 成员表里有、但地址未知/不可达的成员:它们不可能投票,票数自然不足
|
|
3264
|
+
const unreachableBase = Math.max(0, quorumMembers().length - 1 - targets.length);
|
|
3258
3265
|
let votes = 1;
|
|
3259
3266
|
let ahead = null;
|
|
3267
|
+
let unreachable = unreachableBase;
|
|
3260
3268
|
const tasks = targets.map(async (peerId) => {
|
|
3261
3269
|
try {
|
|
3262
3270
|
const reply = await requestGroupVote({
|
|
@@ -3276,6 +3284,8 @@ export function createPeerServer(overrides = {}) {
|
|
|
3276
3284
|
} catch (error) {
|
|
3277
3285
|
const body = error?.body;
|
|
3278
3286
|
if (Number(body?.epoch) > epoch) ahead = body;
|
|
3287
|
+
// 不可达成员只计数(lost 那行日志里有 unreachable=N),不逐条刷日志
|
|
3288
|
+
unreachable += 1;
|
|
3279
3289
|
return false;
|
|
3280
3290
|
}
|
|
3281
3291
|
});
|
|
@@ -3315,8 +3325,12 @@ export function createPeerServer(overrides = {}) {
|
|
|
3315
3325
|
// 重试窗口带抖动:两台落败者不会在同一毫秒又同时宣布候选
|
|
3316
3326
|
leaseUntil: config.now() + leaseMs + Math.floor(Math.random() * leaseMs),
|
|
3317
3327
|
});
|
|
3318
|
-
if (config.logger)
|
|
3319
|
-
|
|
3328
|
+
if (config.logger) {
|
|
3329
|
+
config.logger(
|
|
3330
|
+
`[group] election lost epoch=${epoch} votes=${votes}/${needed} unreachable=${unreachable} reason=${reason}`,
|
|
3331
|
+
);
|
|
3332
|
+
}
|
|
3333
|
+
return { ok: false, reason: 'quorum-unavailable', epoch, votes, needed, unreachable };
|
|
3320
3334
|
} finally {
|
|
3321
3335
|
electionInFlight = false;
|
|
3322
3336
|
}
|
|
@@ -3420,6 +3434,9 @@ export function createPeerServer(overrides = {}) {
|
|
|
3420
3434
|
commitSeq: st.commitSeq,
|
|
3421
3435
|
pendingCount: pendingCount(),
|
|
3422
3436
|
quorum: quorumMode,
|
|
3437
|
+
// 分母与门槛:成员表全集(含没有地址的成员),地址只影响投递
|
|
3438
|
+
memberCount: quorumMembers().length,
|
|
3439
|
+
quorumNeeded: quorumNeeded(),
|
|
3423
3440
|
degraded: isDegraded(),
|
|
3424
3441
|
advertiseBaseUrl: localAdvertiseBaseUrl(),
|
|
3425
3442
|
},
|
|
@@ -3515,6 +3532,10 @@ export function createPeerServer(overrides = {}) {
|
|
|
3515
3532
|
epoch: after.epoch,
|
|
3516
3533
|
role: after.role,
|
|
3517
3534
|
leaderPeerId: after.leaderPeerId,
|
|
3535
|
+
// 运维要能看出「差几票」:分母是成员表全集,不是可达成员数
|
|
3536
|
+
votes: out?.votes ?? null,
|
|
3537
|
+
needed: out?.needed ?? null,
|
|
3538
|
+
memberCount: quorumMembers().length,
|
|
3518
3539
|
},
|
|
3519
3540
|
peerId: auth.peerId,
|
|
3520
3541
|
};
|
|
@@ -3727,7 +3748,17 @@ export function createPeerServer(overrides = {}) {
|
|
|
3727
3748
|
const method = String(req.method ?? 'GET').toUpperCase();
|
|
3728
3749
|
// S3:成员节点只有当上 leader 才收 post(否则这些路径仍当作不存在,v0.5.0 语义不变)
|
|
3729
3750
|
const leaderPost = !groupStore && method === 'POST' && url.pathname === '/v1/group/post' && isLocalLeader();
|
|
3730
|
-
if (!groupStore && !leaderPost)
|
|
3751
|
+
if (!groupStore && !leaderPost) {
|
|
3752
|
+
// 成员节点上「post 打到非 leader」与「群没启用」要能分辨:状态码保持 404(v0.5 语义不变),
|
|
3753
|
+
// 但正文与日志点明原因,免得和「leader 拿不到多数派 503」混在一起。
|
|
3754
|
+
if (groupRole !== 'off' && method === 'POST' && url.pathname === '/v1/group/post') {
|
|
3755
|
+
if (config.logger) {
|
|
3756
|
+
config.logger(`[group] post refused reason=not-leader epoch=${roleState()?.epoch ?? 0}`);
|
|
3757
|
+
}
|
|
3758
|
+
return { status: 404, body: { error: 'not-leader' }, peerId: null };
|
|
3759
|
+
}
|
|
3760
|
+
return { status: 404, body: { error: 'not-found' } };
|
|
3761
|
+
}
|
|
3731
3762
|
if (!GROUP_V1_PATHS.includes(url.pathname)) return { status: 404, body: { error: 'not-found' } };
|
|
3732
3763
|
if (method === 'GET' && url.pathname === '/v1/group/state') return groupState(req, rawBody);
|
|
3733
3764
|
if (method === 'POST' && url.pathname === '/v1/group/join') return groupJoin(req, rawBody);
|
|
@@ -36,11 +36,13 @@ const OUTSIDER = peerFixture('peer-group-auth-2');
|
|
|
36
36
|
async function startHub(t, overrides = {}) {
|
|
37
37
|
const stateDir = overrides.stateDir ?? makeTempStateDir();
|
|
38
38
|
seedPeers(stateDir, [MEMBER, OUTSIDER]);
|
|
39
|
+
// S3:与 group.test.mjs 同理 —— 假 peerId 成员无法 ack,固定 quorum:none。
|
|
40
|
+
const group = { enabled: true, name: 'default', autoJoin: false, retentionDays: 7, quorum: 'none' };
|
|
39
41
|
const started = await startServer(t, {
|
|
40
42
|
stateDir,
|
|
41
43
|
allowRemotePrompts: true,
|
|
42
|
-
group: { enabled: true, name: 'default', autoJoin: false, retentionDays: 7 },
|
|
43
44
|
...overrides,
|
|
45
|
+
group: { ...group, ...(overrides.group ?? {}) },
|
|
44
46
|
});
|
|
45
47
|
return started;
|
|
46
48
|
}
|
|
@@ -16,8 +16,9 @@ import { createPeerServer } from '../server.mjs';
|
|
|
16
16
|
import { writeGroupKeyFile } from '../../lib/group-client.mjs';
|
|
17
17
|
import { makeTempStateDir, request, writeJson0600 } from './helpers.mjs';
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
export const
|
|
19
|
+
// 全量测试是并行跑的,机器很忙:留足余量,避免「提交窗口/投票超时」偶发红
|
|
20
|
+
export const heartbeatMs = 120;
|
|
21
|
+
export const commitTimeoutMs = 600;
|
|
21
22
|
export const tickMs = 25;
|
|
22
23
|
|
|
23
24
|
export const secretOf = (peerId) => `sec-${peerId}`;
|
|
@@ -56,7 +57,7 @@ export function postAsEncrypted(baseUrl, fromId, body, extra = {}) {
|
|
|
56
57
|
return postAs(baseUrl, fromId, body, { encrypt: true, ...extra });
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
export async function waitFor(check, { timeoutMs =
|
|
60
|
+
export async function waitFor(check, { timeoutMs = 6_000, intervalMs = 20 } = {}) {
|
|
60
61
|
const deadline = Date.now() + timeoutMs;
|
|
61
62
|
let last;
|
|
62
63
|
while (Date.now() < deadline) {
|
|
@@ -133,7 +134,7 @@ export async function startCluster(t, options = {}) {
|
|
|
133
134
|
commitTimeoutMs: commitOverride,
|
|
134
135
|
tickMs: tickOverride,
|
|
135
136
|
jitterMs,
|
|
136
|
-
peerTimeoutMs:
|
|
137
|
+
peerTimeoutMs: 250,
|
|
137
138
|
},
|
|
138
139
|
});
|
|
139
140
|
const info = await server.listen(0, '127.0.0.1');
|
|
@@ -86,7 +86,7 @@ test('S3 ack 不足:成员全部下线 → 503、消息不可见、seq 不被
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
test('S3 提交超时:对端接受连接但不响应 → 等到 commitTimeoutMs 才 503,消息仍不可见', async (t) => {
|
|
89
|
-
const cluster = await startCluster(t, { count: 3 });
|
|
89
|
+
const cluster = await startCluster(t, { count: 3, commitTimeoutMs: 200 });
|
|
90
90
|
const [a, b, c] = cluster.nodes;
|
|
91
91
|
// 黑洞:TCP 握手成功但永不回 HTTP 响应(模拟半死节点,和 ECONNREFUSED 区分开)
|
|
92
92
|
const sockets = new Set();
|
|
@@ -77,7 +77,7 @@ test('S3 租约过期:leader 被杀的 ≤3 个租约内自动接任,epoch+1
|
|
|
77
77
|
await a.server.close();
|
|
78
78
|
const survivors = [b, c];
|
|
79
79
|
const winner = await waitFor(() => survivors.find((node) => roleOf(node).role === 'leader'), {
|
|
80
|
-
timeoutMs:
|
|
80
|
+
timeoutMs: 8_000,
|
|
81
81
|
intervalMs: 10,
|
|
82
82
|
});
|
|
83
83
|
assert.ok(winner, '杀掉 leader 后必须有一台接任');
|
|
@@ -331,6 +331,74 @@ test('S3 同 epoch 分裂投票:自投的候选让位给裁决更合法的一
|
|
|
331
331
|
assert.equal(refused.reason, 'lower-priority');
|
|
332
332
|
assert.deepEqual(readRoleFile(c).votedFor, { epoch: 2, peerId: a.peerId });
|
|
333
333
|
});
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// §3 quorum 分母(安全不变量:分母是成员表全集,不是「知道地址的成员」)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
test('S3 quorum 分母 = 成员表全集:没有任何地址的成员也计入,且能真的挡住写入', async (t) => {
|
|
339
|
+
const cluster = await startCluster(t, { count: 3, heartbeatMs: 5_000, tickMs: 60_000 });
|
|
340
|
+
const [a, b] = cluster.nodes;
|
|
341
|
+
const lease = (node) => fetchGroupLease({ baseUrl: node.baseUrl, ...groupArgs(cluster, b.peerId) });
|
|
342
|
+
|
|
343
|
+
const baseline = await lease(a);
|
|
344
|
+
assert.equal(baseline.memberCount, 3, 'hub 成员表 = 自己 + 两台');
|
|
345
|
+
assert.equal(baseline.quorumNeeded, 2);
|
|
346
|
+
|
|
347
|
+
// 三个只存在于成员表、从来没有地址也从不在线的成员:分母必须跟着变大
|
|
348
|
+
a.server.group.addMember('peer-ghost-1', 'ghost-1');
|
|
349
|
+
a.server.group.addMember('peer-ghost-2', 'ghost-2');
|
|
350
|
+
a.server.group.addMember('peer-ghost-3', 'ghost-3');
|
|
351
|
+
const grown = await lease(a);
|
|
352
|
+
assert.equal(grown.memberCount, 6, '分母等于成员表条数(含 3 个无地址成员)');
|
|
353
|
+
assert.equal(grown.quorumNeeded, 4, 'floor(6/2)+1');
|
|
354
|
+
|
|
355
|
+
// 分母变大后,三台可达只凑得出 3 票 → 503(幽灵成员没有地址也必须算进多数派)
|
|
356
|
+
const blocked = await postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'ghost-blocked' });
|
|
357
|
+
assert.equal(blocked.status, 503, blocked.text);
|
|
358
|
+
assert.equal(blocked.json.error, 'quorum-unavailable');
|
|
359
|
+
assert.deepEqual(a.server.group.history({ sinceSeq: 0 }), []);
|
|
360
|
+
|
|
361
|
+
// 清掉幽灵成员后恢复可写(票数 3 ≥ 3)
|
|
362
|
+
for (const id of ['peer-ghost-1', 'peer-ghost-2', 'peer-ghost-3']) a.server.group.removeMember(id);
|
|
363
|
+
const ok = await postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'ghost-gone' });
|
|
364
|
+
assert.equal(ok.status, 201, ok.text);
|
|
365
|
+
assert.equal((await lease(a)).quorumNeeded, 2);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('S3 单节点自立为 leader 必败:N=2 且另一台不可达 → quorum-unavailable', async (t) => {
|
|
369
|
+
// 这一条锁死真机事故:桌面节点曾经在 NAS 不可达时 epoch=2 自立为 leader
|
|
370
|
+
const cluster = await startCluster(t, { count: 2, heartbeatMs: 5_000, tickMs: 60_000 });
|
|
371
|
+
const [a, b] = cluster.nodes;
|
|
372
|
+
await a.server.close();
|
|
373
|
+
|
|
374
|
+
const out = await b.server.group.elect({ reason: 'test-lonely' });
|
|
375
|
+
assert.equal(out.ok, false, JSON.stringify(out));
|
|
376
|
+
assert.equal(out.reason, 'quorum-unavailable');
|
|
377
|
+
assert.equal(out.votes, 1, '只有自己那一票');
|
|
378
|
+
assert.equal(out.needed, 2, 'N=2 → floor(2/2)+1 = 2');
|
|
379
|
+
assert.equal(roleOf(b).role, 'follower', '绝不能自立为 leader');
|
|
380
|
+
assert.equal(roleOf(b).leaderPeerId, '');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('S3 N=3:两台可达能当选(2 ≥ 2),只剩一台不能(1 < 2)', async (t) => {
|
|
384
|
+
const cluster = await startCluster(t, { count: 3, heartbeatMs: 5_000, tickMs: 60_000 });
|
|
385
|
+
const [a, b, c] = cluster.nodes;
|
|
386
|
+
await a.server.close();
|
|
387
|
+
|
|
388
|
+
const won = await b.server.group.elect({ reason: 'test-two-alive' });
|
|
389
|
+
assert.equal(won.ok, true, JSON.stringify(won));
|
|
390
|
+
assert.equal(won.votes, 2);
|
|
391
|
+
assert.equal(won.needed, 2);
|
|
392
|
+
assert.equal(roleOf(b).role, 'leader');
|
|
393
|
+
|
|
394
|
+
await b.server.close();
|
|
395
|
+
const lonely = await c.server.group.elect({ reason: 'test-one-alive' });
|
|
396
|
+
assert.equal(lonely.ok, false, JSON.stringify(lonely));
|
|
397
|
+
assert.equal(lonely.reason, 'quorum-unavailable');
|
|
398
|
+
assert.equal(lonely.votes, 1);
|
|
399
|
+
assert.equal(lonely.needed, 2);
|
|
400
|
+
assert.notEqual(roleOf(c).role, 'leader');
|
|
401
|
+
});
|
|
334
402
|
// 说明:post 走 per-peer 签名,这里顺带确认 leader 真的能收(详情见 group-commit.test.mjs)
|
|
335
403
|
test('S3 leader 就绪:hub leader 的 post 通道可用(202/201 由 commit 测试覆盖)', async (t) => {
|
|
336
404
|
const cluster = await startCluster(t, { count: 2, quorum: 'none' });
|
package/host/test/group.test.mjs
CHANGED
|
@@ -31,11 +31,15 @@ const OUTSIDER = peerFixture('peer-group-2');
|
|
|
31
31
|
async function startHub(t, overrides = {}) {
|
|
32
32
|
const stateDir = overrides.stateDir ?? makeTempStateDir();
|
|
33
33
|
seedPeers(stateDir, [MEMBER, OUTSIDER]);
|
|
34
|
+
// S3:majority 下 post 走 propose→commit,假 peerId 成员没有投票端点必然 503;
|
|
35
|
+
// 这个文件测的是 v0.5 端点语义,固定 quorum:none(写本地即提交)。
|
|
36
|
+
const group = { enabled: true, name: 'default', autoJoin: false, retentionDays: 7, quorum: 'none' };
|
|
34
37
|
const started = await startServer(t, {
|
|
35
38
|
stateDir,
|
|
36
39
|
allowRemotePrompts: true,
|
|
37
|
-
group: { enabled: true, name: 'default', autoJoin: false, retentionDays: 7 },
|
|
38
40
|
...overrides,
|
|
41
|
+
// overrides.group 只覆盖给出的字段,别把 quorum:none 顶掉
|
|
42
|
+
group: { ...group, ...(overrides.group ?? {}) },
|
|
39
43
|
});
|
|
40
44
|
return started;
|
|
41
45
|
}
|
|
@@ -373,7 +377,8 @@ test('同名成员在 state / history / admin 门面里都能区分(渲染层
|
|
|
373
377
|
const { baseUrl, server } = await startServer(t, {
|
|
374
378
|
stateDir,
|
|
375
379
|
allowRemotePrompts: true,
|
|
376
|
-
|
|
380
|
+
// S3:假 peerId 成员没有投票端点 → 固定 quorum:none(与 startHub 同一口径)
|
|
381
|
+
group: { enabled: true, autoJoin: true, quorum: 'none' },
|
|
377
382
|
});
|
|
378
383
|
|
|
379
384
|
const join = (peer) => request(baseUrl, {
|
|
@@ -661,7 +666,8 @@ test('§6 进度镜像:start/finish 两条落进群,且不含 prompt 原文
|
|
|
661
666
|
const { baseUrl: hubUrl, server: hub } = await startServer(t, {
|
|
662
667
|
stateDir: hubState,
|
|
663
668
|
allowRemotePrompts: true,
|
|
664
|
-
|
|
669
|
+
// S3:镜像用例同样固定 quorum:none(写本地即提交,与 v0.5 语义一致)
|
|
670
|
+
group: { enabled: true, autoJoin: true, quorum: 'none' },
|
|
665
671
|
});
|
|
666
672
|
hub.group.addMember(MEMBER.peerId, MEMBER.name);
|
|
667
673
|
|
package/lib/admin-group.test.mjs
CHANGED
|
@@ -417,10 +417,13 @@ test('hub 侧:未启用群的 host 一律回 enabled:false(契约 §7.3)',
|
|
|
417
417
|
test('成员侧:GET/post 由插件进程代理到 hub(含 groupKey 剥离),浏览器不直连对端', async (t) => {
|
|
418
418
|
const hubDir = makeTempStateDir();
|
|
419
419
|
seedPeers(hubDir, [{ peerId: 'member-inst', name: 'me', secret: 'H2M', inboundSecret: 'M2H', baseUrl: 'http://127.0.0.1:1' }]);
|
|
420
|
+
// S3 起 majority 两阶段提交生效:本夹具里成员没有起真实服务器(baseUrl 打不通),
|
|
421
|
+
// 拿不到 ack → hub 按契约 §5/§9.3 回 503 是正确行为,而这条测试考的是「管理接口
|
|
422
|
+
// 代理 + groupKey 剥离」,所以固定 quorum:none(可写优先)以隔离该路径。
|
|
420
423
|
const hub = await startServer(t, {
|
|
421
424
|
stateDir: hubDir,
|
|
422
425
|
name: 'hub-box',
|
|
423
|
-
group: { enabled: true, hub: '', name: '家里', autoJoin: true },
|
|
426
|
+
group: { enabled: true, hub: '', name: '家里', autoJoin: true, quorum: 'none' },
|
|
424
427
|
});
|
|
425
428
|
hub.server.group.addMember('member-inst', 'me');
|
|
426
429
|
|
|
@@ -750,8 +753,8 @@ test('S3 · GET 追加 role/leaseUntil/quorum/commitSeq/pendingCount/degraded:
|
|
|
750
753
|
assert.equal(out.json.pendingCount, 2, '未提交条数来自门面');
|
|
751
754
|
assert.equal(out.json.degraded, false, '有 leader 且租约未过期');
|
|
752
755
|
|
|
753
|
-
//
|
|
754
|
-
assert.deepEqual(out.json.leader, { peerId: 'peer-hub', name: 'hub-box', epoch:
|
|
756
|
+
// 旧字段的取法不变,但 epoch 改为以角色门面为准(这里是 9,群元数据是 4)
|
|
757
|
+
assert.deepEqual(out.json.leader, { peerId: 'peer-hub', name: 'hub-box', epoch: 9 });
|
|
755
758
|
assert.equal(out.json.selfRole, 'member');
|
|
756
759
|
assert.equal(out.json.lastSeq, 8);
|
|
757
760
|
assert.deepEqual(out.json.messages, MESSAGES);
|
|
@@ -823,6 +826,43 @@ test('S3 · degraded:租约过期 / 门面明确没有 leader 才报警,缺
|
|
|
823
826
|
assert.equal(none.json.quorum, 'none', 'group.quorum=none 如实透给面板');
|
|
824
827
|
});
|
|
825
828
|
|
|
829
|
+
test('S3 · leader.epoch 以角色门面为准:角色 3 / 群元数据 1 → 3;门面缺席回退旧值', async () => {
|
|
830
|
+
// 真机(NAS 0.7.0)现象:group.json 的 keyVersion 世代是 1,角色文件已经是 3。
|
|
831
|
+
const metadataEpochOne = () => ({
|
|
832
|
+
groupId: 'g-1', name: 'default', epoch: 1, members: MEMBERS, messages: MESSAGES, lastSeq: 8,
|
|
833
|
+
});
|
|
834
|
+
const roleEpochThree = () => ({
|
|
835
|
+
role: 'leader', epoch: 3, leaderPeerId: 'inst-self', leaseUntil: Date.now() + 9000,
|
|
836
|
+
commitSeq: 8, pendingCount: 0,
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
const hubSide = await hit(stubAdmin({
|
|
840
|
+
group: { enabled: true, hub: '' },
|
|
841
|
+
api: { ...makeApi(), isHub: true, state: metadataEpochOne },
|
|
842
|
+
facade: { enabled: true, isHub: true, role: roleEpochThree },
|
|
843
|
+
}).admin, 'GET', '/api/lan-peers/group');
|
|
844
|
+
assert.equal(hubSide.json.leader.epoch, 3, 'leader.epoch 必须是选举世代,不是 group.json 的 keyVersion');
|
|
845
|
+
assert.equal(hubSide.json.leader.peerId, 'inst-self', 'peerId 取法不变');
|
|
846
|
+
assert.equal(hubSide.json.leader.name, 'self', 'name 取法不变');
|
|
847
|
+
assert.equal(hubSide.json.role, 'leader');
|
|
848
|
+
|
|
849
|
+
const memberSide = await hit(stubAdmin({
|
|
850
|
+
group: { enabled: true, hub: 'hub-box' },
|
|
851
|
+
api: { ...makeApi(), isHub: false, state: metadataEpochOne },
|
|
852
|
+
peers: HUB_PEERS,
|
|
853
|
+
facade: { enabled: false, isHub: false, role: () => ({ role: 'follower', epoch: 3, leaderPeerId: 'peer-hub' }) },
|
|
854
|
+
}).admin, 'GET', '/api/lan-peers/group');
|
|
855
|
+
assert.equal(memberSide.json.leader.epoch, 3, '成员侧同样以角色世代为准');
|
|
856
|
+
assert.equal(memberSide.json.leader.peerId, 'peer-hub', '成员侧 peerId 仍取 group.hub 指向的 peer');
|
|
857
|
+
|
|
858
|
+
// 角色门面缺席(老 host / M 未接线):回到原来那个值,旧行为不破
|
|
859
|
+
const fallback = await hit(stubAdmin({
|
|
860
|
+
group: { enabled: true, hub: '' },
|
|
861
|
+
api: { ...makeApi(), isHub: true, state: metadataEpochOne },
|
|
862
|
+
}).admin, 'GET', '/api/lan-peers/group');
|
|
863
|
+
assert.equal(fallback.json.leader.epoch, 1, '没有角色门面时回退到群元数据的 epoch');
|
|
864
|
+
});
|
|
865
|
+
|
|
826
866
|
test('S3 · 群状态快照里的角色字段是第二来源(门面缺席时也能显示)', async () => {
|
|
827
867
|
const leaseUntil = Date.now() + 9000;
|
|
828
868
|
const api = apiWith({ isHub: true, lastSeq: 8 });
|
package/lib/admin.mjs
CHANGED
|
@@ -339,9 +339,15 @@ export function createAdmin({
|
|
|
339
339
|
return typeof fromConfig === 'string' ? fromConfig : '';
|
|
340
340
|
});
|
|
341
341
|
|
|
342
|
-
/**
|
|
343
|
-
|
|
344
|
-
|
|
342
|
+
/**
|
|
343
|
+
* S1:leader —— hub 侧就是本机,成员侧就是 group.hub 指向的那个 peer。
|
|
344
|
+
*
|
|
345
|
+
* S3:epoch 优先取角色门面的 `epoch`(选举世代),只有角色门面缺席时才回退到
|
|
346
|
+
* 群元数据的 epoch。两者含义不同(群元数据那份是 group.json 的 keyVersion 世代),
|
|
347
|
+
* 真机上出现过「leader.epoch=1 而状态行 epoch=3」的显示打架,这里统一到选举世代。
|
|
348
|
+
*/
|
|
349
|
+
const leaderOf = (snapshot, resolved, face = null) => {
|
|
350
|
+
const epoch = asSeqOrNull(face?.epoch) ?? asSeqOrNull(snapshot?.epoch);
|
|
345
351
|
if (resolved.isHub === true) {
|
|
346
352
|
return {
|
|
347
353
|
peerId: String(store?.instanceId ?? ''),
|
|
@@ -395,8 +401,8 @@ export function createAdmin({
|
|
|
395
401
|
* 来源优先级:host 门面 > 群状态快照 > 本文件兜底(hub=leader,成员=follower)。
|
|
396
402
|
* 缺字段时给保守值而不是编造报警:只有「明确没有 leader」或「租约已过期」才算 degraded。
|
|
397
403
|
*/
|
|
398
|
-
const roleView = ({ isHub, settings, snapshot = null, leader = null, lastSeq = null }) => {
|
|
399
|
-
const face = roleFace() ?? {};
|
|
404
|
+
const roleView = ({ isHub, settings, snapshot = null, leader = null, lastSeq = null, state = null }) => {
|
|
405
|
+
const face = state ?? roleFace() ?? {};
|
|
400
406
|
const snap = snapshot && typeof snapshot === 'object' ? snapshot : {};
|
|
401
407
|
const known = [face.role, snap.role].find((value) => GROUP_ROLES.includes(String(value ?? '')));
|
|
402
408
|
const leaseUntil = asSeqOrNull(face.leaseUntil) ?? asSeqOrNull(snap.leaseUntil);
|
|
@@ -709,6 +715,9 @@ export function createAdmin({
|
|
|
709
715
|
// v0.6.0 S1/S2 的可见性字段:只加不改,未启用分支保持旧形状。
|
|
710
716
|
const selfRole = isHub ? 'hub' : 'member';
|
|
711
717
|
const advertise = advertiseAddress();
|
|
718
|
+
// S3:一次请求只读一次角色门面 —— leader.epoch 与状态行的 epoch 必须同源,
|
|
719
|
+
// 否则两次读取之间换 leader 就会在同一条响应里自相矛盾。
|
|
720
|
+
const roleState = roleFace();
|
|
712
721
|
// v0.7.0 S3(契约 §7):角色/租约/quorum/提交进度只加不改;失败分支也带上,
|
|
713
722
|
// 面板据此显示 degraded,而不是留一片空白。
|
|
714
723
|
const extras = (leader, replica, selfBaseUrl, snapshot = null, lastSeq = null) => ({
|
|
@@ -723,6 +732,7 @@ export function createAdmin({
|
|
|
723
732
|
snapshot,
|
|
724
733
|
leader,
|
|
725
734
|
lastSeq,
|
|
735
|
+
state: roleState,
|
|
726
736
|
settings: resolved.settings ?? groupSettings(),
|
|
727
737
|
}),
|
|
728
738
|
});
|
|
@@ -750,7 +760,7 @@ export function createAdmin({
|
|
|
750
760
|
const view = memberView(member);
|
|
751
761
|
return { ...view, lag: view.lastSeq == null ? null : Math.max(0, lastSeq - view.lastSeq) };
|
|
752
762
|
});
|
|
753
|
-
const leader = leaderOf(snapshot, resolved);
|
|
763
|
+
const leader = leaderOf(snapshot, resolved, roleState);
|
|
754
764
|
const replica = isHub
|
|
755
765
|
? { lastSeq, leaderLastSeq: lastSeq, lag: 0 }
|
|
756
766
|
: replicaView(coreReplica() ?? readReplica(), lastSeq);
|