@xmoxmo/bncr 0.2.1 → 0.2.2

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/src/channel.ts CHANGED
@@ -73,8 +73,13 @@ import {
73
73
  resolveBncrOutboundTarget,
74
74
  } from './messaging/outbound/target-resolver.ts';
75
75
  const BRIDGE_VERSION = 2;
76
- const BNCR_PUSH_EVENT = 'bncr.push';
76
+ const BNCR_PUSH_EVENT = 'plugin.bncr.push';
77
+ const BNCR_FILE_INIT_EVENT = 'plugin.bncr.file.init';
78
+ const BNCR_FILE_CHUNK_EVENT = 'plugin.bncr.file.chunk';
79
+ const BNCR_FILE_COMPLETE_EVENT = 'plugin.bncr.file.complete';
80
+ const BNCR_FILE_ABORT_EVENT = 'plugin.bncr.file.abort';
77
81
  const CONNECT_TTL_MS = 120_000;
82
+ const RECENT_INBOUND_SEND_WINDOW_MS = 60_000;
78
83
  const MAX_RETRY = 10;
79
84
  const PUSH_DRAIN_INTERVAL_MS = 500;
80
85
  const PUSH_ACK_TIMEOUT_MS = 30_000;
@@ -103,6 +108,8 @@ type FileSendTransferState = {
103
108
  status: 'init' | 'transferring' | 'completed' | 'aborted';
104
109
  ackedChunks: Set<number>;
105
110
  failedChunks: Map<number, string>;
111
+ ownerConnId?: string;
112
+ ownerClientId?: string;
106
113
  completedPath?: string;
107
114
  error?: string;
108
115
  };
@@ -122,6 +129,8 @@ type FileRecvTransferState = {
122
129
  status: 'init' | 'transferring' | 'completed' | 'aborted';
123
130
  bufferByChunk: Map<number, Buffer>;
124
131
  receivedChunks: Set<number>;
132
+ ownerConnId?: string;
133
+ ownerClientId?: string;
125
134
  completedPath?: string;
126
135
  error?: string;
127
136
  };
@@ -354,7 +363,13 @@ class BncrBridgeRuntime {
354
363
  private saveTimer: NodeJS.Timeout | null = null;
355
364
  private pushTimer: NodeJS.Timeout | null = null;
356
365
  private pushDrainRunningAccounts = new Set<string>();
357
- private waiters = new Map<string, Array<() => void>>();
366
+ private messageAckWaiters = new Map<
367
+ string,
368
+ {
369
+ resolve: (result: 'acked' | 'timeout') => void;
370
+ timer: NodeJS.Timeout;
371
+ }
372
+ >();
358
373
  private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
359
374
 
360
375
  // 文件互传状态(V1:尽力而为,重连不续传)
@@ -660,6 +675,45 @@ class BncrBridgeRuntime {
660
675
  return { stale: true, reason: 'mismatch' as const };
661
676
  }
662
677
 
678
+ private shouldIgnoreStaleEvent(params: {
679
+ kind:
680
+ | 'inbound'
681
+ | 'activity'
682
+ | 'ack'
683
+ | 'file.init'
684
+ | 'file.chunk'
685
+ | 'file.complete'
686
+ | 'file.abort';
687
+ payload: { leaseId?: string; connectionEpoch?: number };
688
+ accountId: string;
689
+ connId: string;
690
+ clientId?: string;
691
+ }) {
692
+ const observed = this.observeLease(params.kind, params.payload);
693
+ if (!observed.stale) return false;
694
+ this.logWarn(
695
+ 'stale',
696
+ `ignore kind=${params.kind} accountId=${params.accountId} connId=${params.connId} clientId=${params.clientId || '-'} reason=${observed.reason}`,
697
+ { debugOnly: true },
698
+ );
699
+ return true;
700
+ }
701
+
702
+ private matchesTransferOwner(params: {
703
+ ownerConnId?: string;
704
+ ownerClientId?: string;
705
+ connId: string;
706
+ clientId?: string;
707
+ }) {
708
+ const sameConn = !!params.ownerConnId && params.ownerConnId === params.connId;
709
+ const sameClient =
710
+ !params.ownerConnId &&
711
+ !!params.ownerClientId &&
712
+ !!params.clientId &&
713
+ params.ownerClientId === params.clientId;
714
+ return sameConn || sameClient;
715
+ }
716
+
663
717
  private buildExtendedDiagnostics(accountId: string) {
664
718
  const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
665
719
  return {
@@ -722,7 +776,7 @@ class BncrBridgeRuntime {
722
776
  this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
723
777
  await this.loadState();
724
778
  try {
725
- const cfg = await this.api.runtime.config.loadConfig();
779
+ const cfg = this.api.runtime.config.current();
726
780
  this.initializeCanonicalAgentId(cfg);
727
781
  } catch {
728
782
  // ignore startup canonical agent initialization errors
@@ -750,6 +804,25 @@ class BncrBridgeRuntime {
750
804
  this.logInfo('debug', 'service stopped', { debugOnly: true });
751
805
  };
752
806
 
807
+ shutdown() {
808
+ if (this.saveTimer) {
809
+ clearTimeout(this.saveTimer);
810
+ this.saveTimer = null;
811
+ }
812
+ if (this.pushTimer) {
813
+ clearTimeout(this.pushTimer);
814
+ this.pushTimer = null;
815
+ }
816
+ for (const waiter of this.messageAckWaiters.values()) {
817
+ clearTimeout(waiter.timer);
818
+ }
819
+ this.messageAckWaiters.clear();
820
+ for (const waiter of this.fileAckWaiters.values()) {
821
+ clearTimeout(waiter.timer);
822
+ }
823
+ this.fileAckWaiters.clear();
824
+ }
825
+
753
826
  private scheduleSave() {
754
827
  if (this.saveTimer) return;
755
828
  this.saveTimer = setTimeout(() => {
@@ -769,7 +842,7 @@ class BncrBridgeRuntime {
769
842
 
770
843
  private async refreshDebugFlagFromConfig(options?: { forceLog?: boolean }) {
771
844
  try {
772
- const cfg = await this.api.runtime.config.loadConfig();
845
+ const cfg = this.api.runtime.config.current();
773
846
  const raw = (cfg as any)?.channels?.[CHANNEL_ID]?.debug?.verbose;
774
847
  const next = typeof raw === 'boolean' ? raw : false;
775
848
  const changed = next !== BNCR_DEBUG_VERBOSE;
@@ -1170,18 +1243,32 @@ class BncrBridgeRuntime {
1170
1243
  await writeJsonFileAtomically(this.statePath, data);
1171
1244
  }
1172
1245
 
1173
- private wakeAccountWaiters(accountId: string) {
1174
- const key = normalizeAccountId(accountId);
1175
- const waits = this.waiters.get(key);
1176
- if (!waits?.length) return;
1177
- this.waiters.delete(key);
1178
- for (const resolve of waits) resolve();
1246
+ private resolveMessageAck(messageId: string, result: 'acked' | 'timeout' = 'acked') {
1247
+ const key = asString(messageId).trim();
1248
+ if (!key) return false;
1249
+ const waiter = this.messageAckWaiters.get(key);
1250
+ if (!waiter) return false;
1251
+ this.messageAckWaiters.delete(key);
1252
+ clearTimeout(waiter.timer);
1253
+ waiter.resolve(result);
1254
+ return true;
1179
1255
  }
1180
1256
 
1181
1257
  private rememberGatewayContext(context: GatewayRequestHandlerOptions['context']) {
1182
1258
  if (context) this.gatewayContext = context;
1183
1259
  }
1184
1260
 
1261
+ private resolveOutboxPushOwner(accountId: string): BncrConnection | null {
1262
+ const acc = normalizeAccountId(accountId);
1263
+ const t = now();
1264
+ const primaryKey = this.activeConnectionByAccount.get(acc);
1265
+ if (!primaryKey) return null;
1266
+ const primary = this.connections.get(primaryKey);
1267
+ if (!primary?.connId) return null;
1268
+ if (t - primary.lastSeenAt > CONNECT_TTL_MS) return null;
1269
+ return primary;
1270
+ }
1271
+
1185
1272
  private resolvePushConnIds(accountId: string): Set<string> {
1186
1273
  const acc = normalizeAccountId(accountId);
1187
1274
  const t = now();
@@ -1207,6 +1294,31 @@ class BncrBridgeRuntime {
1207
1294
  return connIds;
1208
1295
  }
1209
1296
 
1297
+ private hasRecentInboundReachability(accountId: string): boolean {
1298
+ const acc = normalizeAccountId(accountId);
1299
+ const t = now();
1300
+ const lastInboundAt = this.lastInboundByAccount.get(acc) || 0;
1301
+ const lastActivityAt = this.lastActivityByAccount.get(acc) || 0;
1302
+ const lastReachableAt = Math.max(lastInboundAt, lastActivityAt);
1303
+ return lastReachableAt > 0 && t - lastReachableAt <= RECENT_INBOUND_SEND_WINDOW_MS;
1304
+ }
1305
+
1306
+ private resolveRecentInboundConnIds(accountId: string): Set<string> {
1307
+ const acc = normalizeAccountId(accountId);
1308
+ const t = now();
1309
+ const connIds = new Set<string>();
1310
+ if (!this.hasRecentInboundReachability(acc)) return connIds;
1311
+
1312
+ for (const c of this.connections.values()) {
1313
+ if (c.accountId !== acc) continue;
1314
+ if (!c.connId) continue;
1315
+ if (t - c.lastSeenAt > CONNECT_TTL_MS * 2) continue;
1316
+ connIds.add(c.connId);
1317
+ }
1318
+
1319
+ return connIds;
1320
+ }
1321
+
1210
1322
  private tryPushEntry(entry: OutboxEntry): boolean {
1211
1323
  const ctx = this.gatewayContext;
1212
1324
  if (!ctx) {
@@ -1222,7 +1334,14 @@ class BncrBridgeRuntime {
1222
1334
  return false;
1223
1335
  }
1224
1336
 
1225
- const connIds = this.resolvePushConnIds(entry.accountId);
1337
+ const owner = this.resolveOutboxPushOwner(entry.accountId);
1338
+ let connIds = owner?.connId
1339
+ ? new Set([owner.connId])
1340
+ : this.resolvePushConnIds(entry.accountId);
1341
+ const recentInboundReachable = this.hasRecentInboundReachability(entry.accountId);
1342
+ if (!connIds.size && recentInboundReachable) {
1343
+ connIds = this.resolveRecentInboundConnIds(entry.accountId);
1344
+ }
1226
1345
  if (!connIds.size) {
1227
1346
  this.logInfo(
1228
1347
  'outbox',
@@ -1230,6 +1349,7 @@ class BncrBridgeRuntime {
1230
1349
  messageId: entry.messageId,
1231
1350
  accountId: entry.accountId,
1232
1351
  reason: 'no-active-connection',
1352
+ recentInboundReachable,
1233
1353
  })}`,
1234
1354
  { debugOnly: true },
1235
1355
  );
@@ -1243,18 +1363,26 @@ class BncrBridgeRuntime {
1243
1363
  };
1244
1364
 
1245
1365
  ctx.broadcastToConnIds(BNCR_PUSH_EVENT, payload, connIds);
1366
+ entry.lastPushAt = now();
1367
+ entry.lastPushConnId =
1368
+ owner?.connId || (connIds.size === 1 ? Array.from(connIds)[0] : undefined);
1369
+ entry.lastPushClientId = owner?.clientId;
1370
+ this.outbox.set(entry.messageId, entry);
1246
1371
  this.logInfo(
1247
1372
  'outbox',
1248
1373
  `push-ok ${JSON.stringify({
1249
1374
  messageId: entry.messageId,
1250
1375
  accountId: entry.accountId,
1251
1376
  connIds: Array.from(connIds),
1377
+ ownerConnId: entry.lastPushConnId || '',
1378
+ ownerClientId: entry.lastPushClientId || '',
1379
+ recentInboundReachable,
1252
1380
  event: BNCR_PUSH_EVENT,
1253
1381
  })}`,
1254
1382
  { debugOnly: true },
1255
1383
  );
1256
- this.lastOutboundByAccount.set(entry.accountId, now());
1257
- this.markActivity(entry.accountId);
1384
+ this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
1385
+ this.markActivity(entry.accountId, entry.lastPushAt);
1258
1386
  this.scheduleSave();
1259
1387
  return true;
1260
1388
  } catch (error) {
@@ -1282,6 +1410,42 @@ class BncrBridgeRuntime {
1282
1410
  }, delay);
1283
1411
  }
1284
1412
 
1413
+ private isOutboundAckRequired(accountId?: string) {
1414
+ try {
1415
+ const cfg = this.api.runtime.config.current();
1416
+ const channelCfg = (cfg as any)?.channels?.[CHANNEL_ID];
1417
+ const accountCfg =
1418
+ accountId && channelCfg?.accounts && typeof channelCfg.accounts === 'object'
1419
+ ? (channelCfg.accounts as Record<string, any>)[normalizeAccountId(accountId)]
1420
+ : null;
1421
+ const scoped = accountCfg?.outboundRequireAck;
1422
+ const global = channelCfg?.outboundRequireAck;
1423
+ if (typeof scoped === 'boolean') return scoped;
1424
+ if (typeof global === 'boolean') return global;
1425
+ return true;
1426
+ } catch {
1427
+ return true;
1428
+ }
1429
+ }
1430
+
1431
+ private buildRuntimeFlags(accountId?: string) {
1432
+ let ackPolicySource: 'channel' | 'default' = 'default';
1433
+ try {
1434
+ const cfg = this.api.runtime.config.current();
1435
+ const global = (cfg as any)?.channels?.[CHANNEL_ID]?.outboundRequireAck;
1436
+ if (typeof global === 'boolean') ackPolicySource = 'channel';
1437
+ } catch {
1438
+ // keep default source
1439
+ }
1440
+ return {
1441
+ outboundRequireAck: this.isOutboundAckRequired(accountId),
1442
+ ackPolicySource,
1443
+ messageAckTimeoutMs: PUSH_ACK_TIMEOUT_MS,
1444
+ fileAckTimeoutMs: FILE_ACK_TIMEOUT_MS,
1445
+ debugVerbose: BNCR_DEBUG_VERBOSE,
1446
+ };
1447
+ }
1448
+
1285
1449
  private async flushPushQueue(accountId?: string): Promise<void> {
1286
1450
  const filterAcc = accountId ? normalizeAccountId(accountId) : null;
1287
1451
  const targetAccounts = filterAcc
@@ -1307,12 +1471,14 @@ class BncrBridgeRuntime {
1307
1471
  for (const acc of targetAccounts) {
1308
1472
  if (!acc || this.pushDrainRunningAccounts.has(acc)) continue;
1309
1473
  const online = this.isOnline(acc);
1474
+ const recentInboundReachable = this.hasRecentInboundReachability(acc);
1310
1475
  this.logInfo(
1311
1476
  'outbox',
1312
1477
  `online ${JSON.stringify({
1313
1478
  bridge: this.bridgeId,
1314
1479
  accountId: acc,
1315
1480
  online,
1481
+ recentInboundReachable,
1316
1482
  connections: Array.from(this.connections.values()).map((c) => ({
1317
1483
  accountId: c.accountId,
1318
1484
  connId: c.connId,
@@ -1341,26 +1507,48 @@ class BncrBridgeRuntime {
1341
1507
  break;
1342
1508
  }
1343
1509
 
1344
- const onlineNow = this.isOnline(acc);
1510
+ const onlineNow = this.isOnline(acc) || this.hasRecentInboundReachability(acc);
1345
1511
  const pushed = this.tryPushEntry(entry);
1346
1512
  if (pushed) {
1347
- if (onlineNow) {
1348
- await this.waitForOutbound(acc, PUSH_ACK_TIMEOUT_MS);
1513
+ const requireAck = this.isOutboundAckRequired(acc);
1514
+ let ackResult: 'acked' | 'timeout' = requireAck ? 'timeout' : 'acked';
1515
+ if (onlineNow && requireAck) {
1516
+ ackResult = await this.waitForMessageAck(entry.messageId, PUSH_ACK_TIMEOUT_MS);
1349
1517
  }
1350
1518
 
1519
+ this.logInfo(
1520
+ 'outbox',
1521
+ `ack ${JSON.stringify({
1522
+ messageId: entry.messageId,
1523
+ accountId: entry.accountId,
1524
+ requireAck,
1525
+ ackResult,
1526
+ onlineNow,
1527
+ })}`,
1528
+ { debugOnly: true },
1529
+ );
1530
+
1351
1531
  if (!this.outbox.has(entry.messageId)) {
1352
1532
  await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1353
1533
  continue;
1354
1534
  }
1355
1535
 
1536
+ if (onlineNow && (!requireAck || ackResult !== 'timeout')) {
1537
+ await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
1538
+ continue;
1539
+ }
1540
+
1356
1541
  entry.retryCount += 1;
1357
1542
  entry.lastAttemptAt = now();
1358
1543
  if (entry.retryCount > MAX_RETRY) {
1359
- this.moveToDeadLetter(entry, entry.lastError || 'push-ack-timeout');
1544
+ this.moveToDeadLetter(
1545
+ entry,
1546
+ entry.lastError || (requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed'),
1547
+ );
1360
1548
  continue;
1361
1549
  }
1362
1550
  entry.nextAttemptAt = now() + backoffMs(entry.retryCount);
1363
- entry.lastError = entry.lastError || 'push-ack-timeout';
1551
+ entry.lastError = requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed';
1364
1552
  this.outbox.set(entry.messageId, entry);
1365
1553
  this.scheduleSave();
1366
1554
 
@@ -1400,29 +1588,18 @@ class BncrBridgeRuntime {
1400
1588
  if (globalNextDelay != null) this.schedulePushDrain(globalNextDelay);
1401
1589
  }
1402
1590
 
1403
- private async waitForOutbound(accountId: string, waitMs: number): Promise<void> {
1404
- const key = normalizeAccountId(accountId);
1591
+ private async waitForMessageAck(messageId: string, waitMs: number): Promise<'acked' | 'timeout'> {
1592
+ const key = asString(messageId).trim();
1405
1593
  const timeoutMs = Math.max(0, Math.min(waitMs, 25_000));
1406
- if (!timeoutMs) return;
1594
+ if (!key || !timeoutMs) return 'timeout';
1407
1595
 
1408
- await new Promise<void>((resolve) => {
1596
+ return await new Promise<'acked' | 'timeout'>((resolve) => {
1409
1597
  const timer = setTimeout(() => {
1410
- const arr = this.waiters.get(key) || [];
1411
- this.waiters.set(
1412
- key,
1413
- arr.filter((fn) => fn !== done),
1414
- );
1415
- resolve();
1598
+ this.messageAckWaiters.delete(key);
1599
+ resolve('timeout');
1416
1600
  }, timeoutMs);
1417
1601
 
1418
- const done = () => {
1419
- clearTimeout(timer);
1420
- resolve();
1421
- };
1422
-
1423
- const arr = this.waiters.get(key) || [];
1424
- arr.push(done);
1425
- this.waiters.set(key, arr);
1602
+ this.messageAckWaiters.set(key, { resolve, timer });
1426
1603
  });
1427
1604
  }
1428
1605
 
@@ -1723,7 +1900,17 @@ class BncrBridgeRuntime {
1723
1900
  if (!connIds.size || !this.gatewayContext) {
1724
1901
  throw new Error(`no active bncr connection for account=${accountId}`);
1725
1902
  }
1726
- this.gatewayContext.broadcastToConnIds(event, payload, connIds);
1903
+ const normalizedEvent =
1904
+ event === 'bncr.file.init'
1905
+ ? BNCR_FILE_INIT_EVENT
1906
+ : event === 'bncr.file.chunk'
1907
+ ? BNCR_FILE_CHUNK_EVENT
1908
+ : event === 'bncr.file.complete'
1909
+ ? BNCR_FILE_COMPLETE_EVENT
1910
+ : event === 'bncr.file.abort'
1911
+ ? BNCR_FILE_ABORT_EVENT
1912
+ : event;
1913
+ this.gatewayContext.broadcastToConnIds(normalizedEvent, payload, connIds);
1727
1914
  }
1728
1915
 
1729
1916
  private resolveInboundFileType(mimeType: string, fileName: string): string {
@@ -1890,7 +2077,6 @@ class BncrBridgeRuntime {
1890
2077
  this.logOutboundSummary(entry);
1891
2078
  this.outbox.set(entry.messageId, entry);
1892
2079
  this.scheduleSave();
1893
- this.wakeAccountWaiters(entry.accountId);
1894
2080
  this.flushPushQueue(entry.accountId);
1895
2081
  }
1896
2082
 
@@ -1902,6 +2088,7 @@ class BncrBridgeRuntime {
1902
2088
  this.deadLetter.push(dead);
1903
2089
  if (this.deadLetter.length > 1000) this.deadLetter = this.deadLetter.slice(-1000);
1904
2090
  this.outbox.delete(entry.messageId);
2091
+ this.resolveMessageAck(entry.messageId, 'timeout');
1905
2092
  this.scheduleSave();
1906
2093
  }
1907
2094
 
@@ -2077,6 +2264,7 @@ class BncrBridgeRuntime {
2077
2264
  const totalChunks = Math.ceil(size / chunkSize);
2078
2265
  const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
2079
2266
 
2267
+ const owner = this.resolveOutboxPushOwner(params.accountId);
2080
2268
  const st: FileSendTransferState = {
2081
2269
  transferId,
2082
2270
  accountId: normalizeAccountId(params.accountId),
@@ -2092,11 +2280,13 @@ class BncrBridgeRuntime {
2092
2280
  status: 'init',
2093
2281
  ackedChunks: new Set(),
2094
2282
  failedChunks: new Map(),
2283
+ ownerConnId: owner?.connId,
2284
+ ownerClientId: owner?.clientId,
2095
2285
  };
2096
2286
  this.fileSendTransfers.set(transferId, st);
2097
2287
 
2098
2288
  ctx.broadcastToConnIds(
2099
- 'bncr.file.init',
2289
+ BNCR_FILE_INIT_EVENT,
2100
2290
  {
2101
2291
  transferId,
2102
2292
  direction: 'oc2bncr',
@@ -2126,7 +2316,7 @@ class BncrBridgeRuntime {
2126
2316
  let lastErr: unknown = null;
2127
2317
  for (let attempt = 1; attempt <= 3; attempt++) {
2128
2318
  ctx.broadcastToConnIds(
2129
- 'bncr.file.chunk',
2319
+ BNCR_FILE_CHUNK_EVENT,
2130
2320
  {
2131
2321
  transferId,
2132
2322
  chunkIndex: idx,
@@ -2158,7 +2348,7 @@ class BncrBridgeRuntime {
2158
2348
  st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
2159
2349
  this.fileSendTransfers.set(transferId, st);
2160
2350
  ctx.broadcastToConnIds(
2161
- 'bncr.file.abort',
2351
+ BNCR_FILE_ABORT_EVENT,
2162
2352
  {
2163
2353
  transferId,
2164
2354
  reason: st.error,
@@ -2171,7 +2361,7 @@ class BncrBridgeRuntime {
2171
2361
  }
2172
2362
 
2173
2363
  ctx.broadcastToConnIds(
2174
- 'bncr.file.complete',
2364
+ BNCR_FILE_COMPLETE_EVENT,
2175
2365
  {
2176
2366
  transferId,
2177
2367
  ts: now(),
@@ -2329,6 +2519,11 @@ class BncrBridgeRuntime {
2329
2519
  pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
2330
2520
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
2331
2521
  diagnostics: this.buildExtendedDiagnostics(accountId),
2522
+ runtimeFlags: this.buildRuntimeFlags(accountId),
2523
+ waiters: {
2524
+ messageAck: this.messageAckWaiters.size,
2525
+ fileAck: this.fileAckWaiters.size,
2526
+ },
2332
2527
  leaseId: lease.leaseId,
2333
2528
  connectionEpoch: lease.connectionEpoch,
2334
2529
  protocolVersion: 2,
@@ -2347,13 +2542,9 @@ class BncrBridgeRuntime {
2347
2542
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2348
2543
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2349
2544
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2350
- this.rememberGatewayContext(context);
2351
- this.markSeen(accountId, connId, clientId);
2352
- this.observeLease('ack', params ?? {});
2353
- this.lastAckAtGlobal = now();
2354
- this.incrementCounter(this.ackEventsByAccount, accountId);
2355
-
2356
2545
  const messageId = asString(params?.messageId || '').trim();
2546
+ const staleObserved = this.observeLease('ack', params ?? {});
2547
+
2357
2548
  this.logInfo(
2358
2549
  'outbox',
2359
2550
  `ack ${JSON.stringify({
@@ -2362,6 +2553,7 @@ class BncrBridgeRuntime {
2362
2553
  ok: params?.ok !== false,
2363
2554
  fatal: params?.fatal === true,
2364
2555
  error: asString(params?.error || ''),
2556
+ stale: staleObserved.stale,
2365
2557
  })}`,
2366
2558
  { debugOnly: true },
2367
2559
  );
@@ -2372,7 +2564,7 @@ class BncrBridgeRuntime {
2372
2564
 
2373
2565
  const entry = this.outbox.get(messageId);
2374
2566
  if (!entry) {
2375
- respond(true, { ok: true, message: 'already-acked-or-missing' });
2567
+ respond(true, { ok: true, message: 'already-acked-or-missing', stale: staleObserved.stale });
2376
2568
  return;
2377
2569
  }
2378
2570
 
@@ -2381,20 +2573,52 @@ class BncrBridgeRuntime {
2381
2573
  return;
2382
2574
  }
2383
2575
 
2576
+ if (staleObserved.stale) {
2577
+ const sameConn = !!entry.lastPushConnId && entry.lastPushConnId === connId;
2578
+ const sameClient =
2579
+ !entry.lastPushConnId &&
2580
+ !!entry.lastPushClientId &&
2581
+ !!clientId &&
2582
+ entry.lastPushClientId === clientId;
2583
+ if (!(sameConn || sameClient)) {
2584
+ this.logWarn(
2585
+ 'stale',
2586
+ `ignore kind=ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} messageId=${messageId} reason=owner-mismatch lastPushConnId=${entry.lastPushConnId || '-'} lastPushClientId=${entry.lastPushClientId || '-'}`,
2587
+ { debugOnly: true },
2588
+ );
2589
+ respond(true, { ok: true, stale: true, ignored: true });
2590
+ return;
2591
+ }
2592
+ } else {
2593
+ this.rememberGatewayContext(context);
2594
+ this.markSeen(accountId, connId, clientId);
2595
+ }
2596
+ this.lastAckAtGlobal = now();
2597
+ this.incrementCounter(this.ackEventsByAccount, accountId);
2598
+
2384
2599
  const ok = params?.ok !== false;
2385
2600
  const fatal = params?.fatal === true;
2386
2601
 
2387
2602
  if (ok) {
2388
2603
  this.outbox.delete(messageId);
2389
2604
  this.scheduleSave();
2390
- this.wakeAccountWaiters(accountId);
2391
- respond(true, { ok: true });
2605
+ this.resolveMessageAck(messageId, 'acked');
2606
+ respond(
2607
+ true,
2608
+ staleObserved.stale ? { ok: true, stale: true, staleAccepted: true } : { ok: true },
2609
+ );
2610
+ this.flushPushQueue(accountId);
2392
2611
  return;
2393
2612
  }
2394
2613
 
2395
2614
  if (fatal) {
2396
2615
  this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
2397
- respond(true, { ok: true, movedToDeadLetter: true });
2616
+ respond(
2617
+ true,
2618
+ staleObserved.stale
2619
+ ? { ok: true, movedToDeadLetter: true, stale: true, staleAccepted: true }
2620
+ : { ok: true, movedToDeadLetter: true },
2621
+ );
2398
2622
  return;
2399
2623
  }
2400
2624
 
@@ -2403,7 +2627,12 @@ class BncrBridgeRuntime {
2403
2627
  this.outbox.set(messageId, entry);
2404
2628
  this.scheduleSave();
2405
2629
 
2406
- respond(true, { ok: true, willRetry: true });
2630
+ respond(
2631
+ true,
2632
+ staleObserved.stale
2633
+ ? { ok: true, willRetry: true, stale: true, staleAccepted: true }
2634
+ : { ok: true, willRetry: true },
2635
+ );
2407
2636
  };
2408
2637
 
2409
2638
  handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -2411,7 +2640,18 @@ class BncrBridgeRuntime {
2411
2640
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2412
2641
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2413
2642
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2414
- this.observeLease('activity', params ?? {});
2643
+ if (
2644
+ this.shouldIgnoreStaleEvent({
2645
+ kind: 'activity',
2646
+ payload: params ?? {},
2647
+ accountId,
2648
+ connId,
2649
+ clientId,
2650
+ })
2651
+ ) {
2652
+ respond(true, { accountId, ok: true, event: 'activity', stale: true, ignored: true });
2653
+ return;
2654
+ }
2415
2655
  this.lastActivityAtGlobal = now();
2416
2656
  this.logInfo(
2417
2657
  'activity',
@@ -2439,11 +2679,12 @@ class BncrBridgeRuntime {
2439
2679
  deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
2440
2680
  now: now(),
2441
2681
  });
2682
+ this.flushPushQueue(accountId);
2442
2683
  };
2443
2684
 
2444
2685
  handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
2445
2686
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2446
- const cfg = await this.api.runtime.config.loadConfig();
2687
+ const cfg = this.api.runtime.config.current();
2447
2688
  const runtime = this.getAccountRuntimeSnapshot(accountId);
2448
2689
  const diagnostics = this.buildExtendedDiagnostics(accountId);
2449
2690
  const permissions = buildBncrPermissionSummary(cfg ?? {});
@@ -2468,6 +2709,11 @@ class BncrBridgeRuntime {
2468
2709
  accountId,
2469
2710
  runtime,
2470
2711
  diagnostics,
2712
+ runtimeFlags: this.buildRuntimeFlags(accountId),
2713
+ waiters: {
2714
+ messageAck: this.messageAckWaiters.size,
2715
+ fileAck: this.fileAckWaiters.size,
2716
+ },
2471
2717
  permissions,
2472
2718
  probe,
2473
2719
  now: now(),
@@ -2478,9 +2724,20 @@ class BncrBridgeRuntime {
2478
2724
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2479
2725
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2480
2726
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2727
+ if (
2728
+ this.shouldIgnoreStaleEvent({
2729
+ kind: 'file.init',
2730
+ payload: params ?? {},
2731
+ accountId,
2732
+ connId,
2733
+ clientId,
2734
+ })
2735
+ ) {
2736
+ respond(true, { ok: true, stale: true, ignored: true });
2737
+ return;
2738
+ }
2481
2739
  this.rememberGatewayContext(context);
2482
2740
  this.markSeen(accountId, connId, clientId);
2483
- this.observeLease('file.init', params ?? {});
2484
2741
  this.markActivity(accountId);
2485
2742
 
2486
2743
  const transferId = asString(params?.transferId || '').trim();
@@ -2536,6 +2793,8 @@ class BncrBridgeRuntime {
2536
2793
  status: 'init',
2537
2794
  bufferByChunk: new Map(),
2538
2795
  receivedChunks: new Set(),
2796
+ ownerConnId: connId,
2797
+ ownerClientId: clientId,
2539
2798
  });
2540
2799
 
2541
2800
  respond(true, {
@@ -2549,10 +2808,6 @@ class BncrBridgeRuntime {
2549
2808
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2550
2809
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2551
2810
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2552
- this.rememberGatewayContext(context);
2553
- this.markSeen(accountId, connId, clientId);
2554
- this.observeLease('file.chunk', params ?? {});
2555
- this.markActivity(accountId);
2556
2811
 
2557
2812
  const transferId = asString(params?.transferId || '').trim();
2558
2813
  const chunkIndex = Number(params?.chunkIndex ?? -1);
@@ -2572,6 +2827,30 @@ class BncrBridgeRuntime {
2572
2827
  return;
2573
2828
  }
2574
2829
 
2830
+ const staleObserved = this.observeLease('file.chunk', params ?? {});
2831
+ if (staleObserved.stale) {
2832
+ if (
2833
+ !this.matchesTransferOwner({
2834
+ ownerConnId: st.ownerConnId,
2835
+ ownerClientId: st.ownerClientId,
2836
+ connId,
2837
+ clientId,
2838
+ })
2839
+ ) {
2840
+ this.logWarn(
2841
+ 'stale',
2842
+ `ignore kind=file.chunk accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} reason=owner-mismatch ownerConnId=${st.ownerConnId || '-'} ownerClientId=${st.ownerClientId || '-'}`,
2843
+ { debugOnly: true },
2844
+ );
2845
+ respond(true, { ok: true, stale: true, ignored: true });
2846
+ return;
2847
+ }
2848
+ } else {
2849
+ this.rememberGatewayContext(context);
2850
+ this.markSeen(accountId, connId, clientId);
2851
+ this.markActivity(accountId);
2852
+ }
2853
+
2575
2854
  try {
2576
2855
  const buf = Buffer.from(base64, 'base64');
2577
2856
  if (size > 0 && buf.length !== size) {
@@ -2586,14 +2865,28 @@ class BncrBridgeRuntime {
2586
2865
  st.status = 'transferring';
2587
2866
  this.fileRecvTransfers.set(transferId, st);
2588
2867
 
2589
- respond(true, {
2590
- ok: true,
2591
- transferId,
2592
- chunkIndex,
2593
- offset,
2594
- received: st.receivedChunks.size,
2595
- totalChunks: st.totalChunks,
2596
- });
2868
+ respond(
2869
+ true,
2870
+ staleObserved.stale
2871
+ ? {
2872
+ ok: true,
2873
+ transferId,
2874
+ chunkIndex,
2875
+ offset,
2876
+ received: st.receivedChunks.size,
2877
+ totalChunks: st.totalChunks,
2878
+ stale: true,
2879
+ staleAccepted: true,
2880
+ }
2881
+ : {
2882
+ ok: true,
2883
+ transferId,
2884
+ chunkIndex,
2885
+ offset,
2886
+ received: st.receivedChunks.size,
2887
+ totalChunks: st.totalChunks,
2888
+ },
2889
+ );
2597
2890
  } catch (error) {
2598
2891
  respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
2599
2892
  }
@@ -2608,10 +2901,6 @@ class BncrBridgeRuntime {
2608
2901
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2609
2902
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2610
2903
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2611
- this.rememberGatewayContext(context);
2612
- this.markSeen(accountId, connId, clientId);
2613
- this.observeLease('file.complete', params ?? {});
2614
- this.markActivity(accountId);
2615
2904
 
2616
2905
  const transferId = asString(params?.transferId || '').trim();
2617
2906
  if (!transferId) {
@@ -2625,6 +2914,30 @@ class BncrBridgeRuntime {
2625
2914
  return;
2626
2915
  }
2627
2916
 
2917
+ const staleObserved = this.observeLease('file.complete', params ?? {});
2918
+ if (staleObserved.stale) {
2919
+ if (
2920
+ !this.matchesTransferOwner({
2921
+ ownerConnId: st.ownerConnId,
2922
+ ownerClientId: st.ownerClientId,
2923
+ connId,
2924
+ clientId,
2925
+ })
2926
+ ) {
2927
+ this.logWarn(
2928
+ 'stale',
2929
+ `ignore kind=file.complete accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} reason=owner-mismatch ownerConnId=${st.ownerConnId || '-'} ownerClientId=${st.ownerClientId || '-'}`,
2930
+ { debugOnly: true },
2931
+ );
2932
+ respond(true, { ok: true, stale: true, ignored: true });
2933
+ return;
2934
+ }
2935
+ } else {
2936
+ this.rememberGatewayContext(context);
2937
+ this.markSeen(accountId, connId, clientId);
2938
+ this.markActivity(accountId);
2939
+ }
2940
+
2628
2941
  try {
2629
2942
  if (st.receivedChunks.size < st.totalChunks) {
2630
2943
  throw new Error(
@@ -2655,15 +2968,30 @@ class BncrBridgeRuntime {
2655
2968
  st.status = 'completed';
2656
2969
  this.fileRecvTransfers.set(transferId, st);
2657
2970
 
2658
- respond(true, {
2659
- ok: true,
2660
- transferId,
2661
- path: saved.path,
2662
- size: merged.length,
2663
- fileName: st.fileName,
2664
- mimeType: st.mimeType,
2665
- fileSha256: digest,
2666
- });
2971
+ respond(
2972
+ true,
2973
+ staleObserved.stale
2974
+ ? {
2975
+ ok: true,
2976
+ transferId,
2977
+ path: saved.path,
2978
+ size: merged.length,
2979
+ fileName: st.fileName,
2980
+ mimeType: st.mimeType,
2981
+ fileSha256: digest,
2982
+ stale: true,
2983
+ staleAccepted: true,
2984
+ }
2985
+ : {
2986
+ ok: true,
2987
+ transferId,
2988
+ path: saved.path,
2989
+ size: merged.length,
2990
+ fileName: st.fileName,
2991
+ mimeType: st.mimeType,
2992
+ fileSha256: digest,
2993
+ },
2994
+ );
2667
2995
  } catch (error) {
2668
2996
  st.status = 'aborted';
2669
2997
  st.error = String((error as any)?.message || error || 'complete failed');
@@ -2676,10 +3004,6 @@ class BncrBridgeRuntime {
2676
3004
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2677
3005
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2678
3006
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2679
- this.rememberGatewayContext(context);
2680
- this.markSeen(accountId, connId, clientId);
2681
- this.observeLease('file.abort', params ?? {});
2682
- this.markActivity(accountId);
2683
3007
 
2684
3008
  const transferId = asString(params?.transferId || '').trim();
2685
3009
  if (!transferId) {
@@ -2693,24 +3017,56 @@ class BncrBridgeRuntime {
2693
3017
  return;
2694
3018
  }
2695
3019
 
3020
+ const staleObserved = this.observeLease('file.abort', params ?? {});
3021
+ if (staleObserved.stale) {
3022
+ if (
3023
+ !this.matchesTransferOwner({
3024
+ ownerConnId: st.ownerConnId,
3025
+ ownerClientId: st.ownerClientId,
3026
+ connId,
3027
+ clientId,
3028
+ })
3029
+ ) {
3030
+ this.logWarn(
3031
+ 'stale',
3032
+ `ignore kind=file.abort accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} reason=owner-mismatch ownerConnId=${st.ownerConnId || '-'} ownerClientId=${st.ownerClientId || '-'}`,
3033
+ { debugOnly: true },
3034
+ );
3035
+ respond(true, { ok: true, stale: true, ignored: true });
3036
+ return;
3037
+ }
3038
+ } else {
3039
+ this.rememberGatewayContext(context);
3040
+ this.markSeen(accountId, connId, clientId);
3041
+ this.markActivity(accountId);
3042
+ }
3043
+
2696
3044
  st.status = 'aborted';
2697
3045
  st.error = asString(params?.reason || 'aborted');
2698
3046
  this.fileRecvTransfers.set(transferId, st);
2699
3047
 
2700
- respond(true, {
2701
- ok: true,
2702
- transferId,
2703
- status: 'aborted',
2704
- });
3048
+ respond(
3049
+ true,
3050
+ staleObserved.stale
3051
+ ? {
3052
+ ok: true,
3053
+ transferId,
3054
+ status: 'aborted',
3055
+ stale: true,
3056
+ staleAccepted: true,
3057
+ }
3058
+ : {
3059
+ ok: true,
3060
+ transferId,
3061
+ status: 'aborted',
3062
+ },
3063
+ );
2705
3064
  };
2706
3065
 
2707
3066
  handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
2708
3067
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
2709
3068
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2710
3069
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
2711
- this.rememberGatewayContext(context);
2712
- this.markSeen(accountId, connId, clientId);
2713
- this.markActivity(accountId);
2714
3070
 
2715
3071
  const transferId = asString(params?.transferId || '').trim();
2716
3072
  const stage = asString(params?.stage || '').trim();
@@ -2723,6 +3079,34 @@ class BncrBridgeRuntime {
2723
3079
  }
2724
3080
 
2725
3081
  const st = this.fileSendTransfers.get(transferId);
3082
+ const staleKind =
3083
+ stage === 'init'
3084
+ ? 'file.init'
3085
+ : stage === 'chunk'
3086
+ ? 'file.chunk'
3087
+ : stage === 'abort'
3088
+ ? 'file.abort'
3089
+ : 'file.complete';
3090
+ const staleObserved = this.observeLease(staleKind, params ?? {});
3091
+ if (staleObserved.stale) {
3092
+ const sameConn = !!st?.ownerConnId && st.ownerConnId === connId;
3093
+ const sameClient =
3094
+ !st?.ownerConnId && !!st?.ownerClientId && !!clientId && st.ownerClientId === clientId;
3095
+ if (!(sameConn || sameClient)) {
3096
+ this.logWarn(
3097
+ 'stale',
3098
+ `ignore kind=file.ack accountId=${accountId} connId=${connId} clientId=${clientId || '-'} transferId=${transferId} stage=${stage} reason=owner-mismatch ownerConnId=${st?.ownerConnId || '-'} ownerClientId=${st?.ownerClientId || '-'}`,
3099
+ { debugOnly: true },
3100
+ );
3101
+ respond(true, { ok: true, stale: true, ignored: true });
3102
+ return;
3103
+ }
3104
+ } else {
3105
+ this.rememberGatewayContext(context);
3106
+ this.markSeen(accountId, connId, clientId);
3107
+ this.markActivity(accountId);
3108
+ }
3109
+
2726
3110
  if (st) {
2727
3111
  if (!ok) {
2728
3112
  const code = asString(params?.errorCode || 'ACK_FAILED');
@@ -2759,12 +3143,24 @@ class BncrBridgeRuntime {
2759
3143
  ok,
2760
3144
  });
2761
3145
 
2762
- respond(true, {
2763
- ok: true,
2764
- transferId,
2765
- stage,
2766
- state: st?.status || 'late',
2767
- });
3146
+ respond(
3147
+ true,
3148
+ staleObserved.stale
3149
+ ? {
3150
+ ok: true,
3151
+ transferId,
3152
+ stage,
3153
+ state: st?.status || 'late',
3154
+ stale: true,
3155
+ staleAccepted: true,
3156
+ }
3157
+ : {
3158
+ ok: true,
3159
+ transferId,
3160
+ stage,
3161
+ state: st?.status || 'late',
3162
+ },
3163
+ );
2768
3164
  };
2769
3165
 
2770
3166
  handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
@@ -2790,9 +3186,26 @@ class BncrBridgeRuntime {
2790
3186
  } = parsed;
2791
3187
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
2792
3188
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
3189
+ if (
3190
+ this.shouldIgnoreStaleEvent({
3191
+ kind: 'inbound',
3192
+ payload: params ?? {},
3193
+ accountId,
3194
+ connId,
3195
+ clientId,
3196
+ })
3197
+ ) {
3198
+ respond(true, {
3199
+ accepted: false,
3200
+ stale: true,
3201
+ ignored: true,
3202
+ accountId,
3203
+ msgId: msgId ?? null,
3204
+ });
3205
+ return;
3206
+ }
2793
3207
  this.rememberGatewayContext(context);
2794
3208
  this.markSeen(accountId, connId, clientId);
2795
- this.observeLease('inbound', params ?? {});
2796
3209
  this.markActivity(accountId);
2797
3210
  this.lastInboundAtGlobal = now();
2798
3211
  this.incrementCounter(this.inboundEventsByAccount, accountId);
@@ -2811,7 +3224,7 @@ class BncrBridgeRuntime {
2811
3224
  return;
2812
3225
  }
2813
3226
 
2814
- const cfg = await this.api.runtime.config.loadConfig();
3227
+ const cfg = this.api.runtime.config.current();
2815
3228
  const gate = checkBncrMessageGate({
2816
3229
  parsed,
2817
3230
  cfg,
@@ -2876,6 +3289,7 @@ class BncrBridgeRuntime {
2876
3289
  msgId: msgId ?? null,
2877
3290
  taskKey: extracted.taskKey ?? null,
2878
3291
  });
3292
+ this.flushPushQueue(accountId);
2879
3293
 
2880
3294
  void dispatchBncrInbound({
2881
3295
  api: this.api,