dsh-lan-peers 0.7.3 → 0.7.4

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/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.3';
99
+ export const PLUGIN_VERSION = '0.7.4';
100
100
 
101
101
  /** §4.1 允许的时钟偏移 */
102
102
  export const CLOCK_SKEW_MS = 60_000;
@@ -1095,10 +1095,16 @@ export class GroupStore {
1095
1095
  return name;
1096
1096
  }
1097
1097
 
1098
- /** 追加一条消息:seq = ++lastSeq(消息被删也不回收号) */
1098
+ /**
1099
+ * 追加一条消息(**单阶段写**:quorum:none、以及管理接口/面板的直写路径)。
1100
+ * 取号必须避开 pending 已预留的空号:两阶段提交的提案先占住 lastSeq+1,
1101
+ * 此时若再来一条直写也取 lastSeq+1,两条消息就会共用同一个 seq ——
1102
+ * 客户端按 seq 去重,后一条会被静默丢弃。
1103
+ */
1099
1104
  append({ peerId, name, kind, text = '', meta = {} }) {
1100
1105
  const data = this.#load();
1101
- const seq = Number(data.lastSeq) + 1;
1106
+ const reserved = data.pending.reduce((max, entry) => Math.max(max, Number(entry?.seq) || 0), 0);
1107
+ const seq = Math.max(Number(data.lastSeq) || 0, reserved) + 1;
1102
1108
  data.lastSeq = seq;
1103
1109
  const message = {
1104
1110
  seq,
@@ -3086,11 +3092,13 @@ export function createPeerServer(overrides = {}) {
3086
3092
  const id = String(peerId ?? '');
3087
3093
  if (id === '') return '';
3088
3094
  if (id === store.instanceId) return localAdvertiseBaseUrl();
3089
- const learned = String(peerBaseUrls.get(id) ?? '').trim().replace(/\/+$/, '');
3090
- if (learned !== '') return learned;
3095
+ // 群成员表优先:它同样是成员自报的地址(hub 写 group.json / 成员写 group-key.json),
3096
+ // 但它是**持久化且运维可见可覆盖**的那一份;学到的缓存只做兜底(两者来源相同,顺序不影响线上)。
3091
3097
  const fromGroup = String(localGroupContext()?.members?.[id]?.baseUrl ?? '')
3092
3098
  .trim().replace(/\/+$/, '');
3093
3099
  if (fromGroup !== '') return fromGroup;
3100
+ const learned = String(peerBaseUrls.get(id) ?? '').trim().replace(/\/+$/, '');
3101
+ if (learned !== '') return learned;
3094
3102
  const recorded = String(groupStore ? groupStore.state().members[id]?.baseUrl ?? '' : '')
3095
3103
  .trim().replace(/\/+$/, '');
3096
3104
  if (recorded !== '') return recorded;
@@ -49,6 +49,8 @@ test('S3 majority:propose→commit 全链路,三台都可见且 commitSeq
49
49
  assert.equal(a.server.group.state().pendingCount, 0, '提交后 pending 必须清空');
50
50
 
51
51
  // 日志:一行 post、一行 commit、至少一次心跳;不含正文/密钥
52
+ // 心跳按 heartbeatMs(默认 120ms)广播,用例本身只要 ~200ms —— 必须**等事件**,不能靠时序。
53
+ await waitFor(() => lines.some((line) => line.startsWith('[group] heartbeat ')), { timeoutMs: 3_000 });
52
54
  const logText = lines.join('\n');
53
55
  assert.match(logText, /\[group\] post kind=chat seq=1 /);
54
56
  assert.match(logText, /\[group\] commit seq=1 /);
@@ -203,7 +205,8 @@ test('S3 seq 连续无重复:失败的提案不消耗号,恢复后重试拿
203
205
  assert.equal(first.status, 201);
204
206
  assert.equal(first.json.seq, 1);
205
207
 
206
- // 把两台成员的地址改成打不通:只拿得到自己一票 → 503
208
+ // 把两台成员的地址改成打不通:只拿得到自己一票 → 503。
209
+ // 群成员表里的 baseUrl 是权威寻址(baseUrlOf 优先读它),所以这里改完立刻生效。
207
210
  const members = a.server.group.state().members;
208
211
  members[b.peerId].baseUrl = 'http://127.0.0.1:1';
209
212
  members[c.peerId].baseUrl = 'http://127.0.0.1:1';
@@ -264,6 +267,50 @@ test('S3 成员当 leader 且要求加密时:post 走 X-Peer-Enc 也能两阶
264
267
  // hub 侧靠 commit 转正(加密通道同理)
265
268
  await waitFor(() => a.server.group.history({ sinceSeq: 0 }).length === 1);
266
269
  });
270
+ test('S3 取号闸门:pending 占着的号不会被单阶段写(面板/管理接口)复用', async (t) => {
271
+ const cluster = await startCluster(t, { count: 3, commitTimeoutMs: 2_000 });
272
+ const [a, b, c] = cluster.nodes;
273
+ // 黑洞:提案会一直挂在 pending 里,直到 commitTimeoutMs 才丢弃
274
+ const sockets = new Set();
275
+ const blackhole = net.createServer((socket) => {
276
+ sockets.add(socket);
277
+ socket.on('close', () => sockets.delete(socket));
278
+ socket.on('error', () => sockets.delete(socket));
279
+ });
280
+ await new Promise((resolve) => blackhole.listen(0, '127.0.0.1', resolve));
281
+ t.after(() => {
282
+ for (const socket of sockets) socket.destroy();
283
+ blackhole.close();
284
+ });
285
+ blackhole.unref();
286
+ const holeUrl = `http://127.0.0.1:${blackhole.address().port}`;
287
+ const members = a.server.group.state().members;
288
+ members[b.peerId].baseUrl = holeUrl;
289
+ members[c.peerId].baseUrl = holeUrl;
290
+
291
+ const inflight = postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'slow-proposal' });
292
+ await waitFor(() => a.server.group.state().pendingCount === 1, { timeoutMs: 1_500 });
293
+ const pendingSeq = a.server.group.state().lastSeq + 1;
294
+ assert.equal(a.server.group.state().lastSeq, 0, '提案未提交,lastSeq 不推进');
295
+
296
+ // 面板/管理接口走单阶段写(server.group.post → GroupStore.append):必须避开 pending 占的号
297
+ const panel = a.server.group.post({ peerId: 'peer-panel', name: 'panel', kind: 'chat', text: 'panel-msg' });
298
+ assert.equal(panel.seq, pendingSeq + 1, '单阶段写不得复用 pending 占着的 seq');
299
+
300
+ const failed = await inflight;
301
+ assert.equal(failed.status, 503, failed.text);
302
+ assert.equal(a.server.group.state().pendingCount, 0, '超时后 pending 必须清空');
303
+
304
+ // 恢复可达后继续写:号必须继续往前走,且任何两条消息不共用 seq
305
+ members[b.peerId].baseUrl = b.baseUrl;
306
+ members[c.peerId].baseUrl = c.baseUrl;
307
+ const next = await postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'after-drop' });
308
+ assert.equal(next.status, 201, next.text);
309
+ assert.equal(next.json.seq, panel.seq + 1);
310
+ const seqs = a.server.group.history({ sinceSeq: 0 }).map((m) => m.seq);
311
+ assert.deepEqual(seqs, [panel.seq, panel.seq + 1]);
312
+ assert.equal(new Set(seqs).size, seqs.length, 'seq 不得重复(客户端按 seq 去重)');
313
+ });
267
314
  test('S3 提交串行化:连续多条 post 的 seq 严格连续(并发下也不撞号)', async (t) => {
268
315
  const cluster = await startCluster(t, { count: 3 });
269
316
  const [a, b] = cluster.nodes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lan-peers",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "局域网 DSH 协同:一次性配对互认、HMAC 签名 + AES-256-GCM 加密、agent 互派任务",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",