@livedesk/hub 0.1.75 → 0.1.76
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/package.json
CHANGED
package/src/console-direct.js
CHANGED
|
@@ -481,8 +481,9 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
481
481
|
|
|
482
482
|
const isOwnerActive = owner => !owner.retired
|
|
483
483
|
&& owner.hubEpoch === hubEpoch
|
|
484
|
-
&& peers.get(owner.
|
|
484
|
+
&& peers.get(owner.key) === owner
|
|
485
485
|
&& owner.generation > 0
|
|
486
|
+
&& (!owner.parent || isOwnerActive(owner.parent))
|
|
486
487
|
&& ownerWorkspaceAccessCurrent(owner);
|
|
487
488
|
|
|
488
489
|
const memberFenceKey = (workspaceId, userId) => `${String(workspaceId || '').trim()}:${String(userId || '').trim()}`;
|
|
@@ -520,6 +521,12 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
520
521
|
hubEpoch: owner.hubEpoch
|
|
521
522
|
});
|
|
522
523
|
|
|
524
|
+
const sendMediaSignal = (parent, mediaId, signal) => isOwnerActive(parent)
|
|
525
|
+
&& sendControl(parent, { type: 'media-signal', connectionId: parent.connectionId,
|
|
526
|
+
hubEpoch: parent.hubEpoch, mediaId, signal });
|
|
527
|
+
const sendPeerSignal = (owner, signal) => owner.parent
|
|
528
|
+
? sendMediaSignal(owner.parent, owner.consoleId, signal) : sendSignal(signal);
|
|
529
|
+
|
|
523
530
|
const disposeAssemblerTimer = channelState => {
|
|
524
531
|
if (channelState.assemblyTimer) clearTimeout(channelState.assemblyTimer);
|
|
525
532
|
channelState.assemblyTimer = null;
|
|
@@ -575,15 +582,21 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
575
582
|
String(options.reason || 'console-direct-channel-closed').slice(0, 120)
|
|
576
583
|
);
|
|
577
584
|
try { channelState.channel.close(); } catch { /* exact channel is already closed */ }
|
|
585
|
+
if (owner.parent && !owner.retired && owner.channels.size === 0) {
|
|
586
|
+
retirePeer(owner, String(options.reason || 'console-direct-media-lane-idle'));
|
|
587
|
+
}
|
|
578
588
|
};
|
|
579
589
|
|
|
580
590
|
const retirePeer = (owner, reason = 'console-direct-peer-closed', options = {}) => {
|
|
581
591
|
if (!owner || owner.retired) return;
|
|
582
592
|
owner.retired = true;
|
|
593
|
+
for (const child of [...owner.mediaPeers.values()]) retirePeer(child, reason, { notify: false });
|
|
594
|
+
owner.mediaPeers.clear();
|
|
595
|
+
if (owner.parent?.mediaPeers.get(owner.consoleId) === owner) owner.parent.mediaPeers.delete(owner.consoleId);
|
|
583
596
|
owner.iceSetupGate?.close();
|
|
584
597
|
void owner.routerMapping?.release();
|
|
585
598
|
retainConsoleIceRetirement(recentPeerConnections, owner, reason);
|
|
586
|
-
if (peers.get(owner.
|
|
599
|
+
if (peers.get(owner.key) === owner) peers.delete(owner.key);
|
|
587
600
|
clearTimeout(owner.iceTimer);
|
|
588
601
|
owner.iceTimer = null;
|
|
589
602
|
clearTimeout(owner.disconnectTimer);
|
|
@@ -609,13 +622,13 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
609
622
|
owner.control = null;
|
|
610
623
|
}
|
|
611
624
|
const terminalBudget = owner.wireBudget.inspect();
|
|
612
|
-
if (terminalBudget.retainedBytes !== 0 || terminalBudget.reservationCount !== 0) {
|
|
625
|
+
if (!owner.parent && (terminalBudget.retainedBytes !== 0 || terminalBudget.reservationCount !== 0)) {
|
|
613
626
|
wireBudgetCleanupFailures += 1;
|
|
614
627
|
lastError = 'console-direct-wire-budget-not-zero-after-peer-close';
|
|
615
628
|
}
|
|
616
629
|
try { owner.peer.close(); } catch { /* exact peer is already closed */ }
|
|
617
630
|
if (options.notify !== false) {
|
|
618
|
-
|
|
631
|
+
sendPeerSignal(owner, { type: 'rtc-close', ...ownerEnvelope(owner), reason: String(reason).slice(0, 120) });
|
|
619
632
|
}
|
|
620
633
|
};
|
|
621
634
|
|
|
@@ -631,7 +644,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
631
644
|
const generation = owner.generation;
|
|
632
645
|
owner.accessTimer = setTimeout(() => {
|
|
633
646
|
owner.accessTimer = null;
|
|
634
|
-
if (peers.get(owner.
|
|
647
|
+
if (peers.get(owner.key) !== owner || owner.generation !== generation || owner.retired) return;
|
|
635
648
|
retirePeer(owner, 'console-workspace-access-expired');
|
|
636
649
|
}, delay);
|
|
637
650
|
owner.accessTimer.unref?.();
|
|
@@ -643,6 +656,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
643
656
|
|
|
644
657
|
const retireNegotiatingPeers = reason => {
|
|
645
658
|
for (const owner of [...peers.values()]) {
|
|
659
|
+
if (owner.parent) continue; // Child negotiation uses the healthy direct parent, not the Worker.
|
|
646
660
|
if (!owner.control || !channelIsOpen(owner.control.channel)) {
|
|
647
661
|
retirePeer(owner, reason, { notify: false });
|
|
648
662
|
}
|
|
@@ -690,6 +704,13 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
690
704
|
}
|
|
691
705
|
};
|
|
692
706
|
|
|
707
|
+
const familyBufferedAmount = owner => {
|
|
708
|
+
const root = owner.parent || owner;
|
|
709
|
+
let total = ownerBufferedAmount(root);
|
|
710
|
+
for (const child of root.mediaPeers.values()) total += ownerBufferedAmount(child);
|
|
711
|
+
return total;
|
|
712
|
+
};
|
|
713
|
+
|
|
693
714
|
const releaseLogicalBacklogForControl = (owner, requiredBytes) => {
|
|
694
715
|
const purposePriority = { frame: 0, atlas: 1, audio: 2, input: 3 };
|
|
695
716
|
const candidates = [...owner.channels.values()]
|
|
@@ -743,7 +764,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
743
764
|
const partialMedia = channelState.purpose === 'frame' || channelState.purpose === 'atlas';
|
|
744
765
|
if (partialMedia
|
|
745
766
|
&& (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
|
|
746
|
-
||
|
|
767
|
+
|| familyBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES)) {
|
|
747
768
|
dataChannelBackpressureCloses += 1;
|
|
748
769
|
if (options.closeOnFailure !== false) {
|
|
749
770
|
closeLogicalChannel(owner, channelState, {
|
|
@@ -776,7 +797,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
776
797
|
releaseLogicalBacklogForControl(owner, wireBytes);
|
|
777
798
|
}
|
|
778
799
|
if (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
|
|
779
|
-
||
|
|
800
|
+
|| familyBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
|
|
780
801
|
dataChannelBackpressureCloses += 1;
|
|
781
802
|
if (options.closeOnFailure !== false) {
|
|
782
803
|
if (channelState === owner.control) retirePeer(owner, 'console-direct-control-backpressure');
|
|
@@ -1105,7 +1126,12 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1105
1126
|
const handleControlPayload = (owner, text) => {
|
|
1106
1127
|
const payload = parseJson(text);
|
|
1107
1128
|
if (!payload?.type) throw new Error('console-direct-control-invalid');
|
|
1129
|
+
if (payload.type === 'media-signal') {
|
|
1130
|
+
handleMediaSignal(owner, payload);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1108
1133
|
if (payload.type === 'http-request') {
|
|
1134
|
+
if (owner.parent) throw new Error('console-direct-media-http-forbidden');
|
|
1109
1135
|
void handleHttpRequest(owner, payload);
|
|
1110
1136
|
return;
|
|
1111
1137
|
}
|
|
@@ -1218,7 +1244,9 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1218
1244
|
} else {
|
|
1219
1245
|
const identity = parseLogicalWebSocketLabel(label);
|
|
1220
1246
|
if (!identity
|
|
1221
|
-
|| owner.channels.size >= MAX_LOGICAL_CHANNELS_PER_PEER
|
|
1247
|
+
|| owner.channels.size + owner.mediaPeers.size >= MAX_LOGICAL_CHANNELS_PER_PEER
|
|
1248
|
+
|| (owner.parent && (identity.channelId !== owner.consoleId
|
|
1249
|
+
|| !['frame', 'atlas'].includes(identity.purpose)))
|
|
1222
1250
|
|| owner.channels.has(identity?.channelId)) {
|
|
1223
1251
|
try { channel.close(); } catch {}
|
|
1224
1252
|
return;
|
|
@@ -1276,7 +1304,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1276
1304
|
sendControl(owner, {
|
|
1277
1305
|
type: 'direct-ready',
|
|
1278
1306
|
connectionId: owner.connectionId,
|
|
1279
|
-
hubEpoch: owner.hubEpoch
|
|
1307
|
+
hubEpoch: owner.hubEpoch,
|
|
1308
|
+
...(!owner.parent ? { mediaPeerVersion: 1 } : {})
|
|
1280
1309
|
});
|
|
1281
1310
|
} else {
|
|
1282
1311
|
consumePendingLogicalControl(owner, channelState);
|
|
@@ -1284,12 +1313,16 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1284
1313
|
});
|
|
1285
1314
|
};
|
|
1286
1315
|
|
|
1287
|
-
const createPeerOwner = payload => {
|
|
1316
|
+
const createPeerOwner = (payload, parent = null) => {
|
|
1288
1317
|
const consoleId = String(payload?.consoleId || '');
|
|
1289
1318
|
const connectionId = String(payload?.connectionId || '');
|
|
1290
1319
|
const ownerHubEpoch = String(payload?.hubEpoch || '');
|
|
1291
1320
|
const description = payload?.description;
|
|
1292
|
-
|
|
1321
|
+
// A child receives authority only from the exact authenticated parent.
|
|
1322
|
+
// Browser-supplied workspace/member fields can never elevate it.
|
|
1323
|
+
const workspaceAccess = parent ? parent.workspaceAccess : consoleWorkspaceAccess(payload);
|
|
1324
|
+
const reply = message => parent ? sendMediaSignal(parent, consoleId, message) : sendSignal(message);
|
|
1325
|
+
const key = parent ? `${parent.key}/${consoleId}` : consoleId;
|
|
1293
1326
|
if (!UUID_PATTERN.test(consoleId)
|
|
1294
1327
|
|| !UUID_PATTERN.test(connectionId)
|
|
1295
1328
|
|| ownerHubEpoch !== hubEpoch
|
|
@@ -1298,7 +1331,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1298
1331
|
|| typeof description.sdp !== 'string'
|
|
1299
1332
|
|| byteLength(description.sdp) > SDP_MAX_BYTES) {
|
|
1300
1333
|
if (UUID_PATTERN.test(consoleId) && UUID_PATTERN.test(connectionId)) {
|
|
1301
|
-
|
|
1334
|
+
reply({
|
|
1302
1335
|
type: 'rtc-close',
|
|
1303
1336
|
consoleId,
|
|
1304
1337
|
connectionId,
|
|
@@ -1309,7 +1342,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1309
1342
|
return null;
|
|
1310
1343
|
}
|
|
1311
1344
|
if (memberIsFenced(workspaceAccess)) {
|
|
1312
|
-
|
|
1345
|
+
reply({
|
|
1313
1346
|
type: 'rtc-close',
|
|
1314
1347
|
consoleId,
|
|
1315
1348
|
connectionId,
|
|
@@ -1320,7 +1353,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1320
1353
|
}
|
|
1321
1354
|
if (candidateIsRelay(description.sdp)) {
|
|
1322
1355
|
rejectedRelayCandidates += 1;
|
|
1323
|
-
|
|
1356
|
+
reply({
|
|
1324
1357
|
type: 'rtc-close',
|
|
1325
1358
|
consoleId,
|
|
1326
1359
|
connectionId,
|
|
@@ -1329,14 +1362,14 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1329
1362
|
});
|
|
1330
1363
|
return null;
|
|
1331
1364
|
}
|
|
1332
|
-
const existing = peers.get(
|
|
1365
|
+
const existing = peers.get(key);
|
|
1333
1366
|
if (existing
|
|
1334
1367
|
&& existing.connectionId === connectionId
|
|
1335
1368
|
&& existing.hubEpoch === ownerHubEpoch
|
|
1336
1369
|
&& !existing.retired) {
|
|
1337
1370
|
const current = existing.peer?.localDescription?.();
|
|
1338
1371
|
if (current?.type === 'answer' && typeof current.sdp === 'string') {
|
|
1339
|
-
|
|
1372
|
+
reply({
|
|
1340
1373
|
type: 'rtc-answer',
|
|
1341
1374
|
...ownerEnvelope(existing),
|
|
1342
1375
|
description: { type: 'answer', sdp: current.sdp }
|
|
@@ -1344,8 +1377,16 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1344
1377
|
}
|
|
1345
1378
|
return existing;
|
|
1346
1379
|
}
|
|
1347
|
-
if (
|
|
1348
|
-
|
|
1380
|
+
if (parent && existing) {
|
|
1381
|
+
reply({ type: 'rtc-close', consoleId, connectionId, hubEpoch: ownerHubEpoch,
|
|
1382
|
+
reason: 'console-direct-media-owner-already-current' });
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
const rootPeerCount = [...peers.values()].filter(entry => !entry.parent).length;
|
|
1386
|
+
if (!existing && (parent
|
|
1387
|
+
? parent.channels.size + parent.mediaPeers.size >= MAX_LOGICAL_CHANNELS_PER_PEER
|
|
1388
|
+
: rootPeerCount >= MAX_PEERS)) {
|
|
1389
|
+
reply({
|
|
1349
1390
|
type: 'rtc-close',
|
|
1350
1391
|
consoleId,
|
|
1351
1392
|
connectionId,
|
|
@@ -1357,6 +1398,9 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1357
1398
|
if (existing) retirePeer(existing, 'console-direct-peer-replaced');
|
|
1358
1399
|
let peer;
|
|
1359
1400
|
const owner = {
|
|
1401
|
+
key,
|
|
1402
|
+
parent,
|
|
1403
|
+
mediaPeers: new Map(),
|
|
1360
1404
|
consoleId,
|
|
1361
1405
|
connectionId,
|
|
1362
1406
|
hubEpoch: ownerHubEpoch,
|
|
@@ -1367,7 +1411,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1367
1411
|
channels: new Map(),
|
|
1368
1412
|
pendingLogicalControl: new Map(),
|
|
1369
1413
|
pendingHttp: new Map(),
|
|
1370
|
-
wireBudget: createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
|
|
1414
|
+
wireBudget: parent?.wireBudget || createDirectConsoleWireBudget({ maxBytes: MAX_SHARED_ASSEMBLY_BYTES }),
|
|
1371
1415
|
iceTimer: null,
|
|
1372
1416
|
disconnectTimer: null,
|
|
1373
1417
|
accessTimer: null,
|
|
@@ -1380,11 +1424,12 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1380
1424
|
retired: false,
|
|
1381
1425
|
createdAt: Date.now()
|
|
1382
1426
|
};
|
|
1383
|
-
peers.set(
|
|
1427
|
+
peers.set(key, owner);
|
|
1428
|
+
if (parent) parent.mediaPeers.set(consoleId, owner);
|
|
1384
1429
|
owner.routerMapping = routerMapping.createOwner(`${consoleId}:${connectionId}:${owner.generation}`,
|
|
1385
|
-
{ portRange: icePortRange });
|
|
1430
|
+
{ portRange: icePortRange, ...(parent ? { queue: true } : {}) });
|
|
1386
1431
|
if (owner.routerMapping?.handoff) owner.iceSetupGate = createConsoleIceSetupGate();
|
|
1387
|
-
armOwnerAccessDeadline(owner);
|
|
1432
|
+
if (!parent) armOwnerAccessDeadline(owner);
|
|
1388
1433
|
owner.iceTimer = setTimeout(() => {
|
|
1389
1434
|
owner.iceTimer = null;
|
|
1390
1435
|
if (isOwnerActive(owner)
|
|
@@ -1419,7 +1464,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1419
1464
|
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1420
1465
|
return;
|
|
1421
1466
|
}
|
|
1422
|
-
const sent =
|
|
1467
|
+
const sent = sendPeerSignal(owner, {
|
|
1423
1468
|
type: 'rtc-answer',
|
|
1424
1469
|
...ownerEnvelope(owner),
|
|
1425
1470
|
description: { type: 'answer', sdp: String(sdp) }
|
|
@@ -1444,7 +1489,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1444
1489
|
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1445
1490
|
return;
|
|
1446
1491
|
}
|
|
1447
|
-
const sent =
|
|
1492
|
+
const sent = sendPeerSignal(owner, {
|
|
1448
1493
|
type: 'rtc-ice',
|
|
1449
1494
|
...ownerEnvelope(owner),
|
|
1450
1495
|
candidate: candidateText,
|
|
@@ -1507,6 +1552,49 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1507
1552
|
return owner;
|
|
1508
1553
|
};
|
|
1509
1554
|
|
|
1555
|
+
const handlePeerSignal = (owner, payload) => {
|
|
1556
|
+
if (payload.type === 'rtc-close') {
|
|
1557
|
+
retirePeer(owner, String(payload.reason || 'console-direct-remote-closed').slice(0, 120), { notify: false });
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (payload.type !== 'rtc-ice') return;
|
|
1561
|
+
const candidate = String(payload.candidate || '');
|
|
1562
|
+
if (byteLength(candidate) > ICE_CANDIDATE_MAX_BYTES || String(payload.sdpMid || '').length > 256) {
|
|
1563
|
+
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
if (candidateIsRelay(candidate)) {
|
|
1567
|
+
rejectedRelayCandidates += 1;
|
|
1568
|
+
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
try {
|
|
1572
|
+
if (candidate) {
|
|
1573
|
+
recordConsoleIceCandidate(owner.iceEvidence.remote, candidate);
|
|
1574
|
+
if (!owner.iceSetupGate?.defer(candidate, String(payload.sdpMid || ''))) {
|
|
1575
|
+
owner.peer.addRemoteCandidate(candidate, String(payload.sdpMid || ''));
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
} catch { retirePeer(owner, 'console-direct-candidate-failed'); }
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1581
|
+
const handleMediaSignal = (parent, envelope) => {
|
|
1582
|
+
if (parent.parent || !isOwnerActive(parent) || !lifecycleOwnerMatches(parent, envelope)) return;
|
|
1583
|
+
const mediaId = String(envelope.mediaId || '');
|
|
1584
|
+
const payload = envelope.signal;
|
|
1585
|
+
if (!UUID_PATTERN.test(mediaId) || !payload || byteLength(JSON.stringify(payload)) > SIGNAL_MESSAGE_MAX_BYTES
|
|
1586
|
+
|| !UUID_PATTERN.test(String(payload.connectionId || '')) || payload.hubEpoch !== parent.hubEpoch) return;
|
|
1587
|
+
if (payload.type === 'rtc-offer') {
|
|
1588
|
+
// Only the media lane ID and the validated RTC fields cross this boundary.
|
|
1589
|
+
createPeerOwner({ consoleId: mediaId, connectionId: payload.connectionId,
|
|
1590
|
+
hubEpoch: parent.hubEpoch, description: payload.description }, parent);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const child = parent.mediaPeers.get(mediaId);
|
|
1594
|
+
if (!child || child.connectionId !== payload.connectionId || !isOwnerActive(child)) return;
|
|
1595
|
+
handlePeerSignal(child, payload);
|
|
1596
|
+
};
|
|
1597
|
+
|
|
1510
1598
|
const handleSignalMessage = (raw, ownerSignalGeneration, socket) => {
|
|
1511
1599
|
if (typeof raw !== 'string' && !Buffer.isBuffer(raw)) return;
|
|
1512
1600
|
const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
|
|
@@ -1560,6 +1648,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1560
1648
|
return;
|
|
1561
1649
|
}
|
|
1562
1650
|
owner.workspaceAccess = refreshedAccess;
|
|
1651
|
+
for (const child of owner.mediaPeers.values()) child.workspaceAccess = refreshedAccess;
|
|
1563
1652
|
armOwnerAccessDeadline(owner);
|
|
1564
1653
|
return;
|
|
1565
1654
|
}
|
|
@@ -1630,32 +1719,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1630
1719
|
|| owner.connectionId !== connectionId
|
|
1631
1720
|
|| owner.hubEpoch !== ownerHubEpoch
|
|
1632
1721
|
|| !isOwnerActive(owner)) return;
|
|
1633
|
-
|
|
1634
|
-
const candidate = String(payload.candidate || '');
|
|
1635
|
-
if (byteLength(candidate) > ICE_CANDIDATE_MAX_BYTES) {
|
|
1636
|
-
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1637
|
-
return;
|
|
1638
|
-
}
|
|
1639
|
-
if (candidateIsRelay(candidate)) {
|
|
1640
|
-
rejectedRelayCandidates += 1;
|
|
1641
|
-
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1642
|
-
return;
|
|
1643
|
-
}
|
|
1644
|
-
try {
|
|
1645
|
-
if (candidate) {
|
|
1646
|
-
recordConsoleIceCandidate(owner.iceEvidence.remote, candidate);
|
|
1647
|
-
if (!owner.iceSetupGate?.defer(candidate, String(payload.sdpMid || ''))) {
|
|
1648
|
-
owner.peer.addRemoteCandidate(candidate, String(payload.sdpMid || ''));
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
} catch {
|
|
1652
|
-
retirePeer(owner, 'console-direct-candidate-failed');
|
|
1653
|
-
}
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
if (payload.type === 'rtc-close') {
|
|
1657
|
-
retirePeer(owner, String(payload.reason || 'console-direct-remote-closed'), { notify: false });
|
|
1658
|
-
}
|
|
1722
|
+
handlePeerSignal(owner, payload);
|
|
1659
1723
|
};
|
|
1660
1724
|
|
|
1661
1725
|
const cancelAccessTokenAttempt = () => {
|
|
@@ -2033,6 +2097,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2033
2097
|
pruneRevokedMembers();
|
|
2034
2098
|
let retired = 0;
|
|
2035
2099
|
for (const owner of [...peers.values()]) {
|
|
2100
|
+
if (owner.retired) continue;
|
|
2036
2101
|
if (owner.workspaceAccess?.workspaceId !== normalizedWorkspaceId
|
|
2037
2102
|
|| owner.workspaceAccess?.userId !== normalizedUserId) continue;
|
|
2038
2103
|
retirePeer(owner, String(reason || 'workspace-member-revoked').slice(0, 120));
|
|
@@ -2067,10 +2132,13 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2067
2132
|
if (channelState.localSocket) localWebSocketChannels += 1;
|
|
2068
2133
|
if (channelState.assemblyTimer) assemblyDeadlineTimers += 1;
|
|
2069
2134
|
}
|
|
2070
|
-
retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
|
|
2135
|
+
if (!owner.parent) retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
|
|
2071
2136
|
bufferedSendBytes += ownerBufferedAmount(owner);
|
|
2072
2137
|
peerDiagnostics.push({
|
|
2073
2138
|
consoleId: owner.consoleId,
|
|
2139
|
+
transportRole: owner.parent ? 'media' : 'control',
|
|
2140
|
+
parentConsoleId: owner.parent?.consoleId || null,
|
|
2141
|
+
parentConnectionId: owner.parent?.connectionId || null,
|
|
2074
2142
|
connectionId: owner.connectionId,
|
|
2075
2143
|
generation: owner.generation,
|
|
2076
2144
|
peerState: owner.peerState,
|
|
@@ -2106,6 +2174,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2106
2174
|
connected: state === 'connected',
|
|
2107
2175
|
hubEpoch,
|
|
2108
2176
|
peerConnections: peers.size,
|
|
2177
|
+
consoleConnections: [...peers.values()].filter(owner => !owner.parent).length,
|
|
2178
|
+
mediaPeerConnections: [...peers.values()].filter(owner => owner.parent).length,
|
|
2109
2179
|
controlChannels,
|
|
2110
2180
|
logicalWebSocketChannels,
|
|
2111
2181
|
localWebSocketChannels,
|
|
@@ -298,6 +298,120 @@ function offer(signal, connectionId = CONNECTION_ONE, sdp = 'v=0\r\na=fake-offer
|
|
|
298
298
|
return FakePeerConnection.instances.at(-1);
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
function openPeerControl(peer) {
|
|
302
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
303
|
+
peer.callbacks.dataChannel(control);
|
|
304
|
+
control.open();
|
|
305
|
+
return control;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function mediaSignal(control, mediaId, signal, parentConnectionId = CONNECTION_ONE) {
|
|
309
|
+
sendWire(control, { type: 'media-signal', connectionId: parentConnectionId,
|
|
310
|
+
hubEpoch: HUB_EPOCH, mediaId, signal: { hubEpoch: HUB_EPOCH, ...signal } });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
test('dedicated media peers isolate congestion and retirement from HTTP, siblings and other Consoles', async () => {
|
|
314
|
+
const { direct, signal } = await connectedDirect();
|
|
315
|
+
try {
|
|
316
|
+
const root = offer(signal), control = openPeerControl(root);
|
|
317
|
+
const externalSignals = signal.sent.length;
|
|
318
|
+
mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO,
|
|
319
|
+
description: { type: 'offer', sdp: 'v=0\r\n' } });
|
|
320
|
+
const first = FakePeerConnection.instances.at(-1), firstControl = openPeerControl(first);
|
|
321
|
+
mediaSignal(control, CHANNEL_ID_TWO, { type: 'rtc-offer', connectionId: REQUEST_ID,
|
|
322
|
+
description: { type: 'offer', sdp: 'v=0\r\n' } });
|
|
323
|
+
const second = FakePeerConnection.instances.at(-1); openPeerControl(second);
|
|
324
|
+
assert.notEqual(first, root);
|
|
325
|
+
assert.notEqual(first, second);
|
|
326
|
+
assert.equal(signal.sent.length, externalSignals, 'media SDP/ICE never goes through the Worker');
|
|
327
|
+
assert.equal(direct.inspect().consoleConnections, 1);
|
|
328
|
+
assert.equal(direct.inspect().mediaPeerConnections, 2);
|
|
329
|
+
const replies = decodeWireMessages(control.sent).map(m => JSON.parse(m.data));
|
|
330
|
+
assert.ok(replies.some(m => m.type === 'media-signal' && m.mediaId === CHANNEL_ID
|
|
331
|
+
&& m.signal.type === 'rtc-answer' && m.connectionId === CONNECTION_ONE));
|
|
332
|
+
firstControl.buffered = 900_000;
|
|
333
|
+
sendWire(control, { type: 'http-request', requestId: REQUEST_ID, method: 'GET', path: '/api/remote/status' });
|
|
334
|
+
await settle();
|
|
335
|
+
assert.ok(decodeWireMessages(control.sent).map(m => JSON.parse(m.data)).some(m => m.type === 'http-response'));
|
|
336
|
+
first.emitState('failed');
|
|
337
|
+
assert.equal(first.closed, true);
|
|
338
|
+
assert.equal(root.closed, false);
|
|
339
|
+
assert.equal(second.closed, false);
|
|
340
|
+
assert.equal(direct.inspect().mediaPeerConnections, 1);
|
|
341
|
+
mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_ONE,
|
|
342
|
+
description: { type: 'offer', sdp: 'v=0\r\n' } });
|
|
343
|
+
const replacement = FakePeerConnection.instances.at(-1); openPeerControl(replacement);
|
|
344
|
+
mediaSignal(control, CHANNEL_ID, { type: 'rtc-close', connectionId: CONNECTION_TWO });
|
|
345
|
+
assert.equal(replacement.closed, false, 'old media close cannot retire a replacement');
|
|
346
|
+
mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO,
|
|
347
|
+
description: { type: 'offer', sdp: 'v=0\r\n' } });
|
|
348
|
+
assert.equal(replacement.closed, false, 'an older offer cannot replace the active media owner');
|
|
349
|
+
assert.equal(FakePeerConnection.instances.at(-1), replacement);
|
|
350
|
+
root.emitState('closed');
|
|
351
|
+
assert.equal(second.closed, true);
|
|
352
|
+
assert.equal(replacement.closed, true);
|
|
353
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
354
|
+
assert.equal(direct.inspect().retainedAssemblyBytes, 0);
|
|
355
|
+
assert.equal(direct.inspect().wireBudgetCleanupFailures, 0);
|
|
356
|
+
} finally { direct.close(); }
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('media offers cannot cross parent authority, nest children or consume the four Console slots', async () => {
|
|
360
|
+
const { direct, signal } = await connectedDirect();
|
|
361
|
+
try {
|
|
362
|
+
const root = offer(signal), control = openPeerControl(root);
|
|
363
|
+
const description = { type: 'offer', sdp: 'v=0\r\n' };
|
|
364
|
+
mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO, description }, CONNECTION_TWO);
|
|
365
|
+
assert.equal(direct.inspect().peerConnections, 1, 'stale parent rejected');
|
|
366
|
+
let childControl;
|
|
367
|
+
for (let n = 0; n < 16; n++) {
|
|
368
|
+
const id = `bbbbbbbb-bbbb-4bbb-8bbb-${String(n).padStart(12, '0')}`;
|
|
369
|
+
mediaSignal(control, id, { type: 'rtc-offer', connectionId: CONNECTION_TWO, description });
|
|
370
|
+
childControl = openPeerControl(FakePeerConnection.instances.at(-1));
|
|
371
|
+
}
|
|
372
|
+
const child = FakePeerConnection.instances.at(-1);
|
|
373
|
+
mediaSignal(childControl, CHANNEL_ID, { type: 'rtc-offer', connectionId: REQUEST_ID, description }, CONNECTION_TWO);
|
|
374
|
+
assert.equal(direct.inspect().mediaPeerConnections, 16, 'child cannot create a grandchild');
|
|
375
|
+
const forbiddenInput = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:input`);
|
|
376
|
+
child.callbacks.dataChannel(forbiddenInput);
|
|
377
|
+
assert.equal(forbiddenInput.closed, true, 'media child cannot acquire input');
|
|
378
|
+
mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO, description });
|
|
379
|
+
assert.equal(direct.inspect().mediaPeerConnections, 16);
|
|
380
|
+
for (let n = 1; n <= 3; n++) {
|
|
381
|
+
offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: `aaaaaaaa-aaaa-4aaa-8aaa-${String(n).padStart(12, '0')}` });
|
|
382
|
+
}
|
|
383
|
+
assert.equal(direct.inspect().consoleConnections, 4);
|
|
384
|
+
assert.equal(direct.inspect().peerConnections, 20);
|
|
385
|
+
offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: CHANNEL_ID });
|
|
386
|
+
assert.equal(direct.inspect().consoleConnections, 4);
|
|
387
|
+
} finally { direct.close(); }
|
|
388
|
+
assert.equal(direct.inspect().peerConnections, 0);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('media assembly shares one Console budget and closing a child releases only its reservations', async () => {
|
|
392
|
+
const { direct, signal } = await connectedDirect();
|
|
393
|
+
try {
|
|
394
|
+
const root = offer(signal), control = openPeerControl(root);
|
|
395
|
+
mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO,
|
|
396
|
+
description: { type: 'offer', sdp: 'v=0\r\n' } });
|
|
397
|
+
const child = FakePeerConnection.instances.at(-1), childControl = openPeerControl(child);
|
|
398
|
+
const partial = encodeDirectConsoleWireMessage('x'.repeat(40_000), { messageId: 77, kind: 'control' })[0];
|
|
399
|
+
childControl.receive(partial);
|
|
400
|
+
const childBytes = direct.inspect().retainedAssemblyBytes;
|
|
401
|
+
assert.ok(childBytes > 0);
|
|
402
|
+
control.receive(partial);
|
|
403
|
+
assert.equal(direct.inspect().retainedAssemblyBytes, childBytes * 2,
|
|
404
|
+
'a shared budget is counted once, not once per transport');
|
|
405
|
+
child.emitState('failed');
|
|
406
|
+
assert.equal(direct.inspect().retainedAssemblyBytes, childBytes);
|
|
407
|
+
assert.equal(direct.inspect().wireBudgetCleanupFailures, 0);
|
|
408
|
+
assert.equal(root.closed, false);
|
|
409
|
+
} finally { direct.close(); }
|
|
410
|
+
assert.equal(direct.inspect().retainedAssemblyBytes, 0);
|
|
411
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
412
|
+
assert.equal(direct.inspect().wireBudgetCleanupFailures, 0);
|
|
413
|
+
});
|
|
414
|
+
|
|
301
415
|
test('native buffered sends complete each media message without closing input or control', async () => {
|
|
302
416
|
const { direct, signal } = await connectedDirect();
|
|
303
417
|
try {
|
|
@@ -792,7 +906,8 @@ test('reliable control channel bridges bounded HTTP over fragmented wire message
|
|
|
792
906
|
assert.deepEqual(JSON.parse(messages[0].data), {
|
|
793
907
|
type: 'direct-ready',
|
|
794
908
|
connectionId: CONNECTION_ONE,
|
|
795
|
-
hubEpoch: HUB_EPOCH
|
|
909
|
+
hubEpoch: HUB_EPOCH,
|
|
910
|
+
mediaPeerVersion: 1
|
|
796
911
|
});
|
|
797
912
|
|
|
798
913
|
sendWire(control, {
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { discoverConsoleUpnpGateway, reserveConsoleIceSocket, isPrivateRouterIPv4, isPublicRouterIPv4 } from './console-upnp-gateway.mjs';
|
|
3
3
|
|
|
4
4
|
export const CONSOLE_ROUTER_MAPPING_BOUNDS = Object.freeze({
|
|
5
|
-
owners: 4, candidates: 8, setupMs: 8000, cleanupMs: 1800, leaseSeconds: 120, history: 8
|
|
5
|
+
owners: 4, queuedOwners: 64, candidates: 8, setupMs: 8000, cleanupMs: 1800, leaseSeconds: 120, history: 8
|
|
6
6
|
});
|
|
7
7
|
|
|
8
8
|
export function parseRouterMappingCandidate(value) {
|
|
@@ -34,6 +34,7 @@ export function createConsoleRouterMapping(options = {}) {
|
|
|
34
34
|
const setupMs = Math.min(bounds.setupMs, Math.max(1, Number(options.setupMs) || bounds.setupMs));
|
|
35
35
|
const cleanupMs = Math.min(bounds.cleanupMs, Math.max(1, Number(options.cleanupMs) || bounds.cleanupMs));
|
|
36
36
|
const owners = new Set();
|
|
37
|
+
const waiting = new Set();
|
|
37
38
|
const reservedPorts = new Map();
|
|
38
39
|
const history = [];
|
|
39
40
|
let historyEpoch = 0;
|
|
@@ -48,6 +49,19 @@ export function createConsoleRouterMapping(options = {}) {
|
|
|
48
49
|
owner.gateway = null;
|
|
49
50
|
owner.candidates.length = 0;
|
|
50
51
|
owner.finished = true;
|
|
52
|
+
owner.resolveDone();
|
|
53
|
+
for (const next of waiting) {
|
|
54
|
+
if (owners.size >= bounds.owners) break;
|
|
55
|
+
waiting.delete(next);
|
|
56
|
+
if (!next.controller.signal.aborted && enabled()) start(next);
|
|
57
|
+
else next.release();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function start(owner) {
|
|
62
|
+
owner.state = 'discovering';
|
|
63
|
+
owners.add(owner);
|
|
64
|
+
void run(owner);
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
const own = (entry, owner) => entry && entry.localAddress === owner.gateway.localAddress
|
|
@@ -128,25 +142,26 @@ export function createConsoleRouterMapping(options = {}) {
|
|
|
128
142
|
}
|
|
129
143
|
}
|
|
130
144
|
|
|
131
|
-
function createOwner(key, { portRange } = {}) {
|
|
132
|
-
if (!enabled() || owners.size >= bounds.owners || typeof key !== 'string'
|
|
145
|
+
function createOwner(key, { portRange, queue = false } = {}) {
|
|
146
|
+
if (!enabled() || (owners.size >= bounds.owners && (!queue || waiting.size >= bounds.queuedOwners)) || typeof key !== 'string'
|
|
133
147
|
|| !/^[a-f0-9-]{36}:[a-f0-9-]{36}:\d{1,12}$/i.test(key)) return null;
|
|
134
148
|
const owner = { key, historyEpoch, portRange, socket: null, state: 'discovering', cleanup: 'not-needed', finished: false,
|
|
135
149
|
controller: new AbortController(), description: `VuvoDesk-ICE-${randomUUID()}`,
|
|
136
150
|
candidates: [], gateway: null, port: 0, reservation: '', attempted: false,
|
|
137
151
|
setupTimer: null, cleanupTimer: null, promise: null, startedAt: Date.now() };
|
|
138
152
|
const ready = new Promise(resolve => { owner.resolveReady = resolve; });
|
|
139
|
-
|
|
153
|
+
owner.promise = new Promise(resolve => { owner.resolveDone = resolve; });
|
|
140
154
|
const release = () => {
|
|
141
155
|
if (owner.finished) return owner.promise || Promise.resolve();
|
|
142
156
|
owner.state = 'released';
|
|
143
157
|
owner.controller.abort();
|
|
144
158
|
owner.resolveReady();
|
|
145
|
-
if (!owner.
|
|
159
|
+
if (!owners.has(owner)) { waiting.delete(owner); finish(owner); }
|
|
146
160
|
return owner.promise || Promise.resolve();
|
|
147
161
|
};
|
|
148
162
|
owner.release = release;
|
|
149
|
-
|
|
163
|
+
if (owners.size < bounds.owners) start(owner);
|
|
164
|
+
else { owner.state = 'queued'; waiting.add(owner); }
|
|
150
165
|
return Object.freeze({
|
|
151
166
|
ready,
|
|
152
167
|
async handoff() {
|
|
@@ -175,9 +190,13 @@ export function createConsoleRouterMapping(options = {}) {
|
|
|
175
190
|
createOwner,
|
|
176
191
|
releaseAll: ({ forgetHistory = false } = {}) => {
|
|
177
192
|
if (forgetHistory) { historyEpoch += 1; history.length = 0; }
|
|
178
|
-
|
|
193
|
+
// Remove waiting owners before releasing active slots: a shutdown must
|
|
194
|
+
// never start a queued router operation while draining the active ones.
|
|
195
|
+
const pending = [...waiting];
|
|
196
|
+
waiting.clear();
|
|
197
|
+
return Promise.all([...pending, ...owners].map(owner => owner.release()));
|
|
179
198
|
},
|
|
180
|
-
inspect: () => ({ enabled: Boolean(enabled()), activeOwners: owners.size,
|
|
199
|
+
inspect: () => ({ enabled: Boolean(enabled()), activeOwners: owners.size, queuedOwners: waiting.size,
|
|
181
200
|
activeMappings: [...owners].filter(owner => owner.state === 'mapped').length,
|
|
182
201
|
pendingOperations: [...owners].filter(owner => owner.promise).length,
|
|
183
202
|
resourceTimers: [...owners].reduce((n, owner) => n + Number(!!owner.setupTimer) + Number(!!owner.cleanupTimer), 0),
|
|
@@ -63,6 +63,42 @@ test('exact concurrent owners release only their own actual candidate ports, wit
|
|
|
63
63
|
assert.doesNotMatch(JSON.stringify(manager.inspect()), /192\.168|VuvoDesk-ICE-/);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
test('media setup waits for one of four router slots and cancellation cannot start stale work', async () => {
|
|
67
|
+
const { manager, calls } = fixture();
|
|
68
|
+
const active = Array.from({length:4}, (_,n)=>manager.createOwner(key(n+1)));
|
|
69
|
+
try {
|
|
70
|
+
await Promise.all(active.map(owner=>owner.ready));
|
|
71
|
+
const queued = manager.createOwner(key(5), {queue:true});
|
|
72
|
+
const cancelled = manager.createOwner(key(6), {queue:true});
|
|
73
|
+
assert.equal(manager.inspect().activeOwners,4);
|
|
74
|
+
assert.equal(manager.inspect().queuedOwners,2);
|
|
75
|
+
assert.equal(manager.inspect().resourceSockets,4);
|
|
76
|
+
assert.equal(manager.createOwner(key(7)),null, 'legacy caller still has four immediate slots');
|
|
77
|
+
await cancelled.release();
|
|
78
|
+
await active[0].release();
|
|
79
|
+
await queued.ready;
|
|
80
|
+
assert.equal(manager.inspect().activeOwners,4);
|
|
81
|
+
assert.equal(manager.inspect().queuedOwners,0);
|
|
82
|
+
assert.equal(calls.filter(call=>call[0]==='add').length,5);
|
|
83
|
+
} finally { await manager.releaseAll(); }
|
|
84
|
+
assert.equal(manager.inspect().activeOwners,0);
|
|
85
|
+
assert.equal(manager.inspect().resourceSockets,0);
|
|
86
|
+
assert.equal(manager.inspect().resourceTimers,0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('queued setup is bounded and closing the family starts no queued router operations', async () => {
|
|
90
|
+
const { manager, calls } = fixture();
|
|
91
|
+
const active=Array.from({length:4},(_,n)=>manager.createOwner(key(n+1)));
|
|
92
|
+
await Promise.all(active.map(owner=>owner.ready));
|
|
93
|
+
for(let n=0;n<64;n++) assert.ok(manager.createOwner(key(n+5),{queue:true}));
|
|
94
|
+
assert.equal(manager.createOwner(key(100),{queue:true}),null);
|
|
95
|
+
await manager.releaseAll();
|
|
96
|
+
assert.equal(calls.filter(call=>call[0]==='add').length,4);
|
|
97
|
+
assert.equal(manager.inspect().queuedOwners,0);
|
|
98
|
+
assert.equal(manager.inspect().activeOwners,0);
|
|
99
|
+
assert.equal(manager.inspect().resourceTimers,0);
|
|
100
|
+
});
|
|
101
|
+
|
|
66
102
|
test('existing mappings and changed ownership are never overwritten or deleted', async () => {
|
|
67
103
|
const { entries, calls, manager } = fixture();
|
|
68
104
|
const foreign = { port: 54443, localAddress: '192.168.0.9', description: 'another-program', leaseSeconds: 0 };
|