@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/README.md +5 -1
- package/index.ts +314 -76
- package/openclaw.plugin.json +59 -0
- package/package.json +3 -3
- package/src/channel.ts +515 -101
- package/src/core/config-schema.ts +6 -0
- package/src/core/types.ts +3 -0
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
|
|
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 =
|
|
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 =
|
|
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
|
|
1174
|
-
const key =
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
1348
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
1404
|
-
const key =
|
|
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<
|
|
1596
|
+
return await new Promise<'acked' | 'timeout'>((resolve) => {
|
|
1409
1597
|
const timer = setTimeout(() => {
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2391
|
-
respond(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
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(
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
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(
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
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(
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
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 =
|
|
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,
|