@xmoxmo/bncr 0.2.0 → 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/scripts/selfcheck.mjs +35 -2
- package/src/channel.ts +530 -103
- package/src/core/config-schema.ts +6 -0
- package/src/core/types.ts +3 -0
package/src/channel.ts
CHANGED
|
@@ -9,13 +9,12 @@ import type {
|
|
|
9
9
|
} from 'openclaw/plugin-sdk/core';
|
|
10
10
|
import {
|
|
11
11
|
applyAccountNameToChannelSection,
|
|
12
|
+
jsonResult,
|
|
12
13
|
setAccountEnabledInConfigSection,
|
|
13
14
|
} from 'openclaw/plugin-sdk/core';
|
|
14
15
|
import { readJsonFileWithFallback, writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
|
|
15
|
-
import type { ChannelMessageActionAdapter, ChatType } from 'openclaw/plugin-sdk/mattermost';
|
|
16
16
|
import { readStringParam } from 'openclaw/plugin-sdk/param-readers';
|
|
17
17
|
import { createDefaultChannelRuntimeState } from 'openclaw/plugin-sdk/status-helpers';
|
|
18
|
-
import { jsonResult } from 'openclaw/plugin-sdk/telegram-core';
|
|
19
18
|
import { extractToolSend } from 'openclaw/plugin-sdk/tool-send';
|
|
20
19
|
import {
|
|
21
20
|
BNCR_DEFAULT_ACCOUNT_ID,
|
|
@@ -74,8 +73,13 @@ import {
|
|
|
74
73
|
resolveBncrOutboundTarget,
|
|
75
74
|
} from './messaging/outbound/target-resolver.ts';
|
|
76
75
|
const BRIDGE_VERSION = 2;
|
|
77
|
-
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';
|
|
78
81
|
const CONNECT_TTL_MS = 120_000;
|
|
82
|
+
const RECENT_INBOUND_SEND_WINDOW_MS = 60_000;
|
|
79
83
|
const MAX_RETRY = 10;
|
|
80
84
|
const PUSH_DRAIN_INTERVAL_MS = 500;
|
|
81
85
|
const PUSH_ACK_TIMEOUT_MS = 30_000;
|
|
@@ -104,6 +108,8 @@ type FileSendTransferState = {
|
|
|
104
108
|
status: 'init' | 'transferring' | 'completed' | 'aborted';
|
|
105
109
|
ackedChunks: Set<number>;
|
|
106
110
|
failedChunks: Map<number, string>;
|
|
111
|
+
ownerConnId?: string;
|
|
112
|
+
ownerClientId?: string;
|
|
107
113
|
completedPath?: string;
|
|
108
114
|
error?: string;
|
|
109
115
|
};
|
|
@@ -123,10 +129,26 @@ type FileRecvTransferState = {
|
|
|
123
129
|
status: 'init' | 'transferring' | 'completed' | 'aborted';
|
|
124
130
|
bufferByChunk: Map<number, Buffer>;
|
|
125
131
|
receivedChunks: Set<number>;
|
|
132
|
+
ownerConnId?: string;
|
|
133
|
+
ownerClientId?: string;
|
|
126
134
|
completedPath?: string;
|
|
127
135
|
error?: string;
|
|
128
136
|
};
|
|
129
137
|
|
|
138
|
+
type ChatType = 'direct' | 'group' | (string & {});
|
|
139
|
+
|
|
140
|
+
type ChannelMessageActionAdapter = {
|
|
141
|
+
describeMessageTool: (ctx: { cfg: any }) => { actions: string[]; capabilities: unknown[] } | null;
|
|
142
|
+
supportsAction: (ctx: { action: string }) => boolean;
|
|
143
|
+
extractToolSend: (ctx: { args: unknown }) => unknown;
|
|
144
|
+
handleAction: (ctx: {
|
|
145
|
+
action: string;
|
|
146
|
+
params: unknown;
|
|
147
|
+
accountId: string;
|
|
148
|
+
mediaLocalRoots?: string[];
|
|
149
|
+
}) => Promise<unknown>;
|
|
150
|
+
};
|
|
151
|
+
|
|
130
152
|
type PersistedState = {
|
|
131
153
|
outbox: OutboxEntry[];
|
|
132
154
|
deadLetter: OutboxEntry[];
|
|
@@ -341,7 +363,13 @@ class BncrBridgeRuntime {
|
|
|
341
363
|
private saveTimer: NodeJS.Timeout | null = null;
|
|
342
364
|
private pushTimer: NodeJS.Timeout | null = null;
|
|
343
365
|
private pushDrainRunningAccounts = new Set<string>();
|
|
344
|
-
private
|
|
366
|
+
private messageAckWaiters = new Map<
|
|
367
|
+
string,
|
|
368
|
+
{
|
|
369
|
+
resolve: (result: 'acked' | 'timeout') => void;
|
|
370
|
+
timer: NodeJS.Timeout;
|
|
371
|
+
}
|
|
372
|
+
>();
|
|
345
373
|
private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
|
|
346
374
|
|
|
347
375
|
// 文件互传状态(V1:尽力而为,重连不续传)
|
|
@@ -647,6 +675,45 @@ class BncrBridgeRuntime {
|
|
|
647
675
|
return { stale: true, reason: 'mismatch' as const };
|
|
648
676
|
}
|
|
649
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
|
+
|
|
650
717
|
private buildExtendedDiagnostics(accountId: string) {
|
|
651
718
|
const diagnostics = this.buildIntegratedDiagnostics(accountId) as Record<string, any>;
|
|
652
719
|
return {
|
|
@@ -709,7 +776,7 @@ class BncrBridgeRuntime {
|
|
|
709
776
|
this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
|
|
710
777
|
await this.loadState();
|
|
711
778
|
try {
|
|
712
|
-
const cfg =
|
|
779
|
+
const cfg = this.api.runtime.config.current();
|
|
713
780
|
this.initializeCanonicalAgentId(cfg);
|
|
714
781
|
} catch {
|
|
715
782
|
// ignore startup canonical agent initialization errors
|
|
@@ -737,6 +804,25 @@ class BncrBridgeRuntime {
|
|
|
737
804
|
this.logInfo('debug', 'service stopped', { debugOnly: true });
|
|
738
805
|
};
|
|
739
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
|
+
|
|
740
826
|
private scheduleSave() {
|
|
741
827
|
if (this.saveTimer) return;
|
|
742
828
|
this.saveTimer = setTimeout(() => {
|
|
@@ -756,7 +842,7 @@ class BncrBridgeRuntime {
|
|
|
756
842
|
|
|
757
843
|
private async refreshDebugFlagFromConfig(options?: { forceLog?: boolean }) {
|
|
758
844
|
try {
|
|
759
|
-
const cfg =
|
|
845
|
+
const cfg = this.api.runtime.config.current();
|
|
760
846
|
const raw = (cfg as any)?.channels?.[CHANNEL_ID]?.debug?.verbose;
|
|
761
847
|
const next = typeof raw === 'boolean' ? raw : false;
|
|
762
848
|
const changed = next !== BNCR_DEBUG_VERBOSE;
|
|
@@ -1157,18 +1243,32 @@ class BncrBridgeRuntime {
|
|
|
1157
1243
|
await writeJsonFileAtomically(this.statePath, data);
|
|
1158
1244
|
}
|
|
1159
1245
|
|
|
1160
|
-
private
|
|
1161
|
-
const key =
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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;
|
|
1166
1255
|
}
|
|
1167
1256
|
|
|
1168
1257
|
private rememberGatewayContext(context: GatewayRequestHandlerOptions['context']) {
|
|
1169
1258
|
if (context) this.gatewayContext = context;
|
|
1170
1259
|
}
|
|
1171
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
|
+
|
|
1172
1272
|
private resolvePushConnIds(accountId: string): Set<string> {
|
|
1173
1273
|
const acc = normalizeAccountId(accountId);
|
|
1174
1274
|
const t = now();
|
|
@@ -1194,6 +1294,31 @@ class BncrBridgeRuntime {
|
|
|
1194
1294
|
return connIds;
|
|
1195
1295
|
}
|
|
1196
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
|
+
|
|
1197
1322
|
private tryPushEntry(entry: OutboxEntry): boolean {
|
|
1198
1323
|
const ctx = this.gatewayContext;
|
|
1199
1324
|
if (!ctx) {
|
|
@@ -1209,7 +1334,14 @@ class BncrBridgeRuntime {
|
|
|
1209
1334
|
return false;
|
|
1210
1335
|
}
|
|
1211
1336
|
|
|
1212
|
-
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
|
+
}
|
|
1213
1345
|
if (!connIds.size) {
|
|
1214
1346
|
this.logInfo(
|
|
1215
1347
|
'outbox',
|
|
@@ -1217,6 +1349,7 @@ class BncrBridgeRuntime {
|
|
|
1217
1349
|
messageId: entry.messageId,
|
|
1218
1350
|
accountId: entry.accountId,
|
|
1219
1351
|
reason: 'no-active-connection',
|
|
1352
|
+
recentInboundReachable,
|
|
1220
1353
|
})}`,
|
|
1221
1354
|
{ debugOnly: true },
|
|
1222
1355
|
);
|
|
@@ -1230,18 +1363,26 @@ class BncrBridgeRuntime {
|
|
|
1230
1363
|
};
|
|
1231
1364
|
|
|
1232
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);
|
|
1233
1371
|
this.logInfo(
|
|
1234
1372
|
'outbox',
|
|
1235
1373
|
`push-ok ${JSON.stringify({
|
|
1236
1374
|
messageId: entry.messageId,
|
|
1237
1375
|
accountId: entry.accountId,
|
|
1238
1376
|
connIds: Array.from(connIds),
|
|
1377
|
+
ownerConnId: entry.lastPushConnId || '',
|
|
1378
|
+
ownerClientId: entry.lastPushClientId || '',
|
|
1379
|
+
recentInboundReachable,
|
|
1239
1380
|
event: BNCR_PUSH_EVENT,
|
|
1240
1381
|
})}`,
|
|
1241
1382
|
{ debugOnly: true },
|
|
1242
1383
|
);
|
|
1243
|
-
this.lastOutboundByAccount.set(entry.accountId,
|
|
1244
|
-
this.markActivity(entry.accountId);
|
|
1384
|
+
this.lastOutboundByAccount.set(entry.accountId, entry.lastPushAt);
|
|
1385
|
+
this.markActivity(entry.accountId, entry.lastPushAt);
|
|
1245
1386
|
this.scheduleSave();
|
|
1246
1387
|
return true;
|
|
1247
1388
|
} catch (error) {
|
|
@@ -1269,6 +1410,42 @@ class BncrBridgeRuntime {
|
|
|
1269
1410
|
}, delay);
|
|
1270
1411
|
}
|
|
1271
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
|
+
|
|
1272
1449
|
private async flushPushQueue(accountId?: string): Promise<void> {
|
|
1273
1450
|
const filterAcc = accountId ? normalizeAccountId(accountId) : null;
|
|
1274
1451
|
const targetAccounts = filterAcc
|
|
@@ -1294,12 +1471,14 @@ class BncrBridgeRuntime {
|
|
|
1294
1471
|
for (const acc of targetAccounts) {
|
|
1295
1472
|
if (!acc || this.pushDrainRunningAccounts.has(acc)) continue;
|
|
1296
1473
|
const online = this.isOnline(acc);
|
|
1474
|
+
const recentInboundReachable = this.hasRecentInboundReachability(acc);
|
|
1297
1475
|
this.logInfo(
|
|
1298
1476
|
'outbox',
|
|
1299
1477
|
`online ${JSON.stringify({
|
|
1300
1478
|
bridge: this.bridgeId,
|
|
1301
1479
|
accountId: acc,
|
|
1302
1480
|
online,
|
|
1481
|
+
recentInboundReachable,
|
|
1303
1482
|
connections: Array.from(this.connections.values()).map((c) => ({
|
|
1304
1483
|
accountId: c.accountId,
|
|
1305
1484
|
connId: c.connId,
|
|
@@ -1328,26 +1507,48 @@ class BncrBridgeRuntime {
|
|
|
1328
1507
|
break;
|
|
1329
1508
|
}
|
|
1330
1509
|
|
|
1331
|
-
const onlineNow = this.isOnline(acc);
|
|
1510
|
+
const onlineNow = this.isOnline(acc) || this.hasRecentInboundReachability(acc);
|
|
1332
1511
|
const pushed = this.tryPushEntry(entry);
|
|
1333
1512
|
if (pushed) {
|
|
1334
|
-
|
|
1335
|
-
|
|
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);
|
|
1336
1517
|
}
|
|
1337
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
|
+
|
|
1338
1531
|
if (!this.outbox.has(entry.messageId)) {
|
|
1339
1532
|
await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
|
|
1340
1533
|
continue;
|
|
1341
1534
|
}
|
|
1342
1535
|
|
|
1536
|
+
if (onlineNow && (!requireAck || ackResult !== 'timeout')) {
|
|
1537
|
+
await this.sleepMs(PUSH_DRAIN_INTERVAL_MS);
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1343
1541
|
entry.retryCount += 1;
|
|
1344
1542
|
entry.lastAttemptAt = now();
|
|
1345
1543
|
if (entry.retryCount > MAX_RETRY) {
|
|
1346
|
-
this.moveToDeadLetter(
|
|
1544
|
+
this.moveToDeadLetter(
|
|
1545
|
+
entry,
|
|
1546
|
+
entry.lastError || (requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed'),
|
|
1547
|
+
);
|
|
1347
1548
|
continue;
|
|
1348
1549
|
}
|
|
1349
1550
|
entry.nextAttemptAt = now() + backoffMs(entry.retryCount);
|
|
1350
|
-
entry.lastError =
|
|
1551
|
+
entry.lastError = requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed';
|
|
1351
1552
|
this.outbox.set(entry.messageId, entry);
|
|
1352
1553
|
this.scheduleSave();
|
|
1353
1554
|
|
|
@@ -1387,29 +1588,18 @@ class BncrBridgeRuntime {
|
|
|
1387
1588
|
if (globalNextDelay != null) this.schedulePushDrain(globalNextDelay);
|
|
1388
1589
|
}
|
|
1389
1590
|
|
|
1390
|
-
private async
|
|
1391
|
-
const key =
|
|
1591
|
+
private async waitForMessageAck(messageId: string, waitMs: number): Promise<'acked' | 'timeout'> {
|
|
1592
|
+
const key = asString(messageId).trim();
|
|
1392
1593
|
const timeoutMs = Math.max(0, Math.min(waitMs, 25_000));
|
|
1393
|
-
if (!timeoutMs) return;
|
|
1594
|
+
if (!key || !timeoutMs) return 'timeout';
|
|
1394
1595
|
|
|
1395
|
-
await new Promise<
|
|
1596
|
+
return await new Promise<'acked' | 'timeout'>((resolve) => {
|
|
1396
1597
|
const timer = setTimeout(() => {
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
key,
|
|
1400
|
-
arr.filter((fn) => fn !== done),
|
|
1401
|
-
);
|
|
1402
|
-
resolve();
|
|
1598
|
+
this.messageAckWaiters.delete(key);
|
|
1599
|
+
resolve('timeout');
|
|
1403
1600
|
}, timeoutMs);
|
|
1404
1601
|
|
|
1405
|
-
|
|
1406
|
-
clearTimeout(timer);
|
|
1407
|
-
resolve();
|
|
1408
|
-
};
|
|
1409
|
-
|
|
1410
|
-
const arr = this.waiters.get(key) || [];
|
|
1411
|
-
arr.push(done);
|
|
1412
|
-
this.waiters.set(key, arr);
|
|
1602
|
+
this.messageAckWaiters.set(key, { resolve, timer });
|
|
1413
1603
|
});
|
|
1414
1604
|
}
|
|
1415
1605
|
|
|
@@ -1710,7 +1900,17 @@ class BncrBridgeRuntime {
|
|
|
1710
1900
|
if (!connIds.size || !this.gatewayContext) {
|
|
1711
1901
|
throw new Error(`no active bncr connection for account=${accountId}`);
|
|
1712
1902
|
}
|
|
1713
|
-
|
|
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);
|
|
1714
1914
|
}
|
|
1715
1915
|
|
|
1716
1916
|
private resolveInboundFileType(mimeType: string, fileName: string): string {
|
|
@@ -1877,7 +2077,6 @@ class BncrBridgeRuntime {
|
|
|
1877
2077
|
this.logOutboundSummary(entry);
|
|
1878
2078
|
this.outbox.set(entry.messageId, entry);
|
|
1879
2079
|
this.scheduleSave();
|
|
1880
|
-
this.wakeAccountWaiters(entry.accountId);
|
|
1881
2080
|
this.flushPushQueue(entry.accountId);
|
|
1882
2081
|
}
|
|
1883
2082
|
|
|
@@ -1889,6 +2088,7 @@ class BncrBridgeRuntime {
|
|
|
1889
2088
|
this.deadLetter.push(dead);
|
|
1890
2089
|
if (this.deadLetter.length > 1000) this.deadLetter = this.deadLetter.slice(-1000);
|
|
1891
2090
|
this.outbox.delete(entry.messageId);
|
|
2091
|
+
this.resolveMessageAck(entry.messageId, 'timeout');
|
|
1892
2092
|
this.scheduleSave();
|
|
1893
2093
|
}
|
|
1894
2094
|
|
|
@@ -2064,6 +2264,7 @@ class BncrBridgeRuntime {
|
|
|
2064
2264
|
const totalChunks = Math.ceil(size / chunkSize);
|
|
2065
2265
|
const fileSha256 = createHash('sha256').update(loaded.buffer).digest('hex');
|
|
2066
2266
|
|
|
2267
|
+
const owner = this.resolveOutboxPushOwner(params.accountId);
|
|
2067
2268
|
const st: FileSendTransferState = {
|
|
2068
2269
|
transferId,
|
|
2069
2270
|
accountId: normalizeAccountId(params.accountId),
|
|
@@ -2079,11 +2280,13 @@ class BncrBridgeRuntime {
|
|
|
2079
2280
|
status: 'init',
|
|
2080
2281
|
ackedChunks: new Set(),
|
|
2081
2282
|
failedChunks: new Map(),
|
|
2283
|
+
ownerConnId: owner?.connId,
|
|
2284
|
+
ownerClientId: owner?.clientId,
|
|
2082
2285
|
};
|
|
2083
2286
|
this.fileSendTransfers.set(transferId, st);
|
|
2084
2287
|
|
|
2085
2288
|
ctx.broadcastToConnIds(
|
|
2086
|
-
|
|
2289
|
+
BNCR_FILE_INIT_EVENT,
|
|
2087
2290
|
{
|
|
2088
2291
|
transferId,
|
|
2089
2292
|
direction: 'oc2bncr',
|
|
@@ -2113,7 +2316,7 @@ class BncrBridgeRuntime {
|
|
|
2113
2316
|
let lastErr: unknown = null;
|
|
2114
2317
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
2115
2318
|
ctx.broadcastToConnIds(
|
|
2116
|
-
|
|
2319
|
+
BNCR_FILE_CHUNK_EVENT,
|
|
2117
2320
|
{
|
|
2118
2321
|
transferId,
|
|
2119
2322
|
chunkIndex: idx,
|
|
@@ -2145,7 +2348,7 @@ class BncrBridgeRuntime {
|
|
|
2145
2348
|
st.error = String((lastErr as any)?.message || lastErr || `chunk-${idx}-failed`);
|
|
2146
2349
|
this.fileSendTransfers.set(transferId, st);
|
|
2147
2350
|
ctx.broadcastToConnIds(
|
|
2148
|
-
|
|
2351
|
+
BNCR_FILE_ABORT_EVENT,
|
|
2149
2352
|
{
|
|
2150
2353
|
transferId,
|
|
2151
2354
|
reason: st.error,
|
|
@@ -2158,7 +2361,7 @@ class BncrBridgeRuntime {
|
|
|
2158
2361
|
}
|
|
2159
2362
|
|
|
2160
2363
|
ctx.broadcastToConnIds(
|
|
2161
|
-
|
|
2364
|
+
BNCR_FILE_COMPLETE_EVENT,
|
|
2162
2365
|
{
|
|
2163
2366
|
transferId,
|
|
2164
2367
|
ts: now(),
|
|
@@ -2316,6 +2519,11 @@ class BncrBridgeRuntime {
|
|
|
2316
2519
|
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
|
|
2317
2520
|
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
2318
2521
|
diagnostics: this.buildExtendedDiagnostics(accountId),
|
|
2522
|
+
runtimeFlags: this.buildRuntimeFlags(accountId),
|
|
2523
|
+
waiters: {
|
|
2524
|
+
messageAck: this.messageAckWaiters.size,
|
|
2525
|
+
fileAck: this.fileAckWaiters.size,
|
|
2526
|
+
},
|
|
2319
2527
|
leaseId: lease.leaseId,
|
|
2320
2528
|
connectionEpoch: lease.connectionEpoch,
|
|
2321
2529
|
protocolVersion: 2,
|
|
@@ -2334,13 +2542,9 @@ class BncrBridgeRuntime {
|
|
|
2334
2542
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2335
2543
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2336
2544
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2337
|
-
this.rememberGatewayContext(context);
|
|
2338
|
-
this.markSeen(accountId, connId, clientId);
|
|
2339
|
-
this.observeLease('ack', params ?? {});
|
|
2340
|
-
this.lastAckAtGlobal = now();
|
|
2341
|
-
this.incrementCounter(this.ackEventsByAccount, accountId);
|
|
2342
|
-
|
|
2343
2545
|
const messageId = asString(params?.messageId || '').trim();
|
|
2546
|
+
const staleObserved = this.observeLease('ack', params ?? {});
|
|
2547
|
+
|
|
2344
2548
|
this.logInfo(
|
|
2345
2549
|
'outbox',
|
|
2346
2550
|
`ack ${JSON.stringify({
|
|
@@ -2349,6 +2553,7 @@ class BncrBridgeRuntime {
|
|
|
2349
2553
|
ok: params?.ok !== false,
|
|
2350
2554
|
fatal: params?.fatal === true,
|
|
2351
2555
|
error: asString(params?.error || ''),
|
|
2556
|
+
stale: staleObserved.stale,
|
|
2352
2557
|
})}`,
|
|
2353
2558
|
{ debugOnly: true },
|
|
2354
2559
|
);
|
|
@@ -2359,7 +2564,7 @@ class BncrBridgeRuntime {
|
|
|
2359
2564
|
|
|
2360
2565
|
const entry = this.outbox.get(messageId);
|
|
2361
2566
|
if (!entry) {
|
|
2362
|
-
respond(true, { ok: true, message: 'already-acked-or-missing' });
|
|
2567
|
+
respond(true, { ok: true, message: 'already-acked-or-missing', stale: staleObserved.stale });
|
|
2363
2568
|
return;
|
|
2364
2569
|
}
|
|
2365
2570
|
|
|
@@ -2368,20 +2573,52 @@ class BncrBridgeRuntime {
|
|
|
2368
2573
|
return;
|
|
2369
2574
|
}
|
|
2370
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
|
+
|
|
2371
2599
|
const ok = params?.ok !== false;
|
|
2372
2600
|
const fatal = params?.fatal === true;
|
|
2373
2601
|
|
|
2374
2602
|
if (ok) {
|
|
2375
2603
|
this.outbox.delete(messageId);
|
|
2376
2604
|
this.scheduleSave();
|
|
2377
|
-
this.
|
|
2378
|
-
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);
|
|
2379
2611
|
return;
|
|
2380
2612
|
}
|
|
2381
2613
|
|
|
2382
2614
|
if (fatal) {
|
|
2383
2615
|
this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
|
|
2384
|
-
respond(
|
|
2616
|
+
respond(
|
|
2617
|
+
true,
|
|
2618
|
+
staleObserved.stale
|
|
2619
|
+
? { ok: true, movedToDeadLetter: true, stale: true, staleAccepted: true }
|
|
2620
|
+
: { ok: true, movedToDeadLetter: true },
|
|
2621
|
+
);
|
|
2385
2622
|
return;
|
|
2386
2623
|
}
|
|
2387
2624
|
|
|
@@ -2390,7 +2627,12 @@ class BncrBridgeRuntime {
|
|
|
2390
2627
|
this.outbox.set(messageId, entry);
|
|
2391
2628
|
this.scheduleSave();
|
|
2392
2629
|
|
|
2393
|
-
respond(
|
|
2630
|
+
respond(
|
|
2631
|
+
true,
|
|
2632
|
+
staleObserved.stale
|
|
2633
|
+
? { ok: true, willRetry: true, stale: true, staleAccepted: true }
|
|
2634
|
+
: { ok: true, willRetry: true },
|
|
2635
|
+
);
|
|
2394
2636
|
};
|
|
2395
2637
|
|
|
2396
2638
|
handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
@@ -2398,7 +2640,18 @@ class BncrBridgeRuntime {
|
|
|
2398
2640
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2399
2641
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2400
2642
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2401
|
-
|
|
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
|
+
}
|
|
2402
2655
|
this.lastActivityAtGlobal = now();
|
|
2403
2656
|
this.logInfo(
|
|
2404
2657
|
'activity',
|
|
@@ -2426,11 +2679,12 @@ class BncrBridgeRuntime {
|
|
|
2426
2679
|
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
2427
2680
|
now: now(),
|
|
2428
2681
|
});
|
|
2682
|
+
this.flushPushQueue(accountId);
|
|
2429
2683
|
};
|
|
2430
2684
|
|
|
2431
2685
|
handleDiagnostics = async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
2432
2686
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2433
|
-
const cfg =
|
|
2687
|
+
const cfg = this.api.runtime.config.current();
|
|
2434
2688
|
const runtime = this.getAccountRuntimeSnapshot(accountId);
|
|
2435
2689
|
const diagnostics = this.buildExtendedDiagnostics(accountId);
|
|
2436
2690
|
const permissions = buildBncrPermissionSummary(cfg ?? {});
|
|
@@ -2455,6 +2709,11 @@ class BncrBridgeRuntime {
|
|
|
2455
2709
|
accountId,
|
|
2456
2710
|
runtime,
|
|
2457
2711
|
diagnostics,
|
|
2712
|
+
runtimeFlags: this.buildRuntimeFlags(accountId),
|
|
2713
|
+
waiters: {
|
|
2714
|
+
messageAck: this.messageAckWaiters.size,
|
|
2715
|
+
fileAck: this.fileAckWaiters.size,
|
|
2716
|
+
},
|
|
2458
2717
|
permissions,
|
|
2459
2718
|
probe,
|
|
2460
2719
|
now: now(),
|
|
@@ -2465,9 +2724,20 @@ class BncrBridgeRuntime {
|
|
|
2465
2724
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2466
2725
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2467
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
|
+
}
|
|
2468
2739
|
this.rememberGatewayContext(context);
|
|
2469
2740
|
this.markSeen(accountId, connId, clientId);
|
|
2470
|
-
this.observeLease('file.init', params ?? {});
|
|
2471
2741
|
this.markActivity(accountId);
|
|
2472
2742
|
|
|
2473
2743
|
const transferId = asString(params?.transferId || '').trim();
|
|
@@ -2523,6 +2793,8 @@ class BncrBridgeRuntime {
|
|
|
2523
2793
|
status: 'init',
|
|
2524
2794
|
bufferByChunk: new Map(),
|
|
2525
2795
|
receivedChunks: new Set(),
|
|
2796
|
+
ownerConnId: connId,
|
|
2797
|
+
ownerClientId: clientId,
|
|
2526
2798
|
});
|
|
2527
2799
|
|
|
2528
2800
|
respond(true, {
|
|
@@ -2536,10 +2808,6 @@ class BncrBridgeRuntime {
|
|
|
2536
2808
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2537
2809
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2538
2810
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2539
|
-
this.rememberGatewayContext(context);
|
|
2540
|
-
this.markSeen(accountId, connId, clientId);
|
|
2541
|
-
this.observeLease('file.chunk', params ?? {});
|
|
2542
|
-
this.markActivity(accountId);
|
|
2543
2811
|
|
|
2544
2812
|
const transferId = asString(params?.transferId || '').trim();
|
|
2545
2813
|
const chunkIndex = Number(params?.chunkIndex ?? -1);
|
|
@@ -2559,6 +2827,30 @@ class BncrBridgeRuntime {
|
|
|
2559
2827
|
return;
|
|
2560
2828
|
}
|
|
2561
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
|
+
|
|
2562
2854
|
try {
|
|
2563
2855
|
const buf = Buffer.from(base64, 'base64');
|
|
2564
2856
|
if (size > 0 && buf.length !== size) {
|
|
@@ -2573,14 +2865,28 @@ class BncrBridgeRuntime {
|
|
|
2573
2865
|
st.status = 'transferring';
|
|
2574
2866
|
this.fileRecvTransfers.set(transferId, st);
|
|
2575
2867
|
|
|
2576
|
-
respond(
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
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
|
+
);
|
|
2584
2890
|
} catch (error) {
|
|
2585
2891
|
respond(false, { error: String((error as any)?.message || error || 'chunk invalid') });
|
|
2586
2892
|
}
|
|
@@ -2595,10 +2901,6 @@ class BncrBridgeRuntime {
|
|
|
2595
2901
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2596
2902
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2597
2903
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2598
|
-
this.rememberGatewayContext(context);
|
|
2599
|
-
this.markSeen(accountId, connId, clientId);
|
|
2600
|
-
this.observeLease('file.complete', params ?? {});
|
|
2601
|
-
this.markActivity(accountId);
|
|
2602
2904
|
|
|
2603
2905
|
const transferId = asString(params?.transferId || '').trim();
|
|
2604
2906
|
if (!transferId) {
|
|
@@ -2612,6 +2914,30 @@ class BncrBridgeRuntime {
|
|
|
2612
2914
|
return;
|
|
2613
2915
|
}
|
|
2614
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
|
+
|
|
2615
2941
|
try {
|
|
2616
2942
|
if (st.receivedChunks.size < st.totalChunks) {
|
|
2617
2943
|
throw new Error(
|
|
@@ -2642,15 +2968,30 @@ class BncrBridgeRuntime {
|
|
|
2642
2968
|
st.status = 'completed';
|
|
2643
2969
|
this.fileRecvTransfers.set(transferId, st);
|
|
2644
2970
|
|
|
2645
|
-
respond(
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
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
|
+
);
|
|
2654
2995
|
} catch (error) {
|
|
2655
2996
|
st.status = 'aborted';
|
|
2656
2997
|
st.error = String((error as any)?.message || error || 'complete failed');
|
|
@@ -2663,10 +3004,6 @@ class BncrBridgeRuntime {
|
|
|
2663
3004
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2664
3005
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2665
3006
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2666
|
-
this.rememberGatewayContext(context);
|
|
2667
|
-
this.markSeen(accountId, connId, clientId);
|
|
2668
|
-
this.observeLease('file.abort', params ?? {});
|
|
2669
|
-
this.markActivity(accountId);
|
|
2670
3007
|
|
|
2671
3008
|
const transferId = asString(params?.transferId || '').trim();
|
|
2672
3009
|
if (!transferId) {
|
|
@@ -2680,24 +3017,56 @@ class BncrBridgeRuntime {
|
|
|
2680
3017
|
return;
|
|
2681
3018
|
}
|
|
2682
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
|
+
|
|
2683
3044
|
st.status = 'aborted';
|
|
2684
3045
|
st.error = asString(params?.reason || 'aborted');
|
|
2685
3046
|
this.fileRecvTransfers.set(transferId, st);
|
|
2686
3047
|
|
|
2687
|
-
respond(
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
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
|
+
);
|
|
2692
3064
|
};
|
|
2693
3065
|
|
|
2694
3066
|
handleFileAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
2695
3067
|
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
2696
3068
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2697
3069
|
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
2698
|
-
this.rememberGatewayContext(context);
|
|
2699
|
-
this.markSeen(accountId, connId, clientId);
|
|
2700
|
-
this.markActivity(accountId);
|
|
2701
3070
|
|
|
2702
3071
|
const transferId = asString(params?.transferId || '').trim();
|
|
2703
3072
|
const stage = asString(params?.stage || '').trim();
|
|
@@ -2710,6 +3079,34 @@ class BncrBridgeRuntime {
|
|
|
2710
3079
|
}
|
|
2711
3080
|
|
|
2712
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
|
+
|
|
2713
3110
|
if (st) {
|
|
2714
3111
|
if (!ok) {
|
|
2715
3112
|
const code = asString(params?.errorCode || 'ACK_FAILED');
|
|
@@ -2746,12 +3143,24 @@ class BncrBridgeRuntime {
|
|
|
2746
3143
|
ok,
|
|
2747
3144
|
});
|
|
2748
3145
|
|
|
2749
|
-
respond(
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
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
|
+
);
|
|
2755
3164
|
};
|
|
2756
3165
|
|
|
2757
3166
|
handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
@@ -2777,9 +3186,26 @@ class BncrBridgeRuntime {
|
|
|
2777
3186
|
} = parsed;
|
|
2778
3187
|
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
2779
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
|
+
}
|
|
2780
3207
|
this.rememberGatewayContext(context);
|
|
2781
3208
|
this.markSeen(accountId, connId, clientId);
|
|
2782
|
-
this.observeLease('inbound', params ?? {});
|
|
2783
3209
|
this.markActivity(accountId);
|
|
2784
3210
|
this.lastInboundAtGlobal = now();
|
|
2785
3211
|
this.incrementCounter(this.inboundEventsByAccount, accountId);
|
|
@@ -2798,7 +3224,7 @@ class BncrBridgeRuntime {
|
|
|
2798
3224
|
return;
|
|
2799
3225
|
}
|
|
2800
3226
|
|
|
2801
|
-
const cfg =
|
|
3227
|
+
const cfg = this.api.runtime.config.current();
|
|
2802
3228
|
const gate = checkBncrMessageGate({
|
|
2803
3229
|
parsed,
|
|
2804
3230
|
cfg,
|
|
@@ -2863,6 +3289,7 @@ class BncrBridgeRuntime {
|
|
|
2863
3289
|
msgId: msgId ?? null,
|
|
2864
3290
|
taskKey: extracted.taskKey ?? null,
|
|
2865
3291
|
});
|
|
3292
|
+
this.flushPushQueue(accountId);
|
|
2866
3293
|
|
|
2867
3294
|
void dispatchBncrInbound({
|
|
2868
3295
|
api: this.api,
|