@livedesk/hub 0.1.73 → 0.1.75

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.
@@ -10,6 +10,12 @@ import {
10
10
  } from '../../runtime-core/src/console-direct-wire.js';
11
11
  import { workspaceRoleCanControl, workspaceRoleCanRequest } from './auth/workspace-access.js';
12
12
  import { createConsoleFrameAdmission } from './console-direct-frame-admission.mjs';
13
+ import { createConsoleRouterMapping } from './console-router-mapping.mjs';
14
+ import { createConsoleIceSetupGate } from './console-ice-setup-gate.mjs';
15
+ import {
16
+ createConsoleIceEvidence, normalizeConsoleIcePortRange, recordConsoleIceCandidate, recordConsoleIceDescription,
17
+ retainConsoleIceRetirement, safeIceState, snapshotConsoleIceEvidence
18
+ } from './console-direct-ice-evidence.mjs';
13
19
 
14
20
  const DEFAULT_SIGNAL_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
15
21
  const DEFAULT_STUN_URLS = Object.freeze(['stun:stun.cloudflare.com:3478']);
@@ -328,6 +334,10 @@ export function createHubConsoleDirect(options = {}) {
328
334
  const httpBaseUrl = String(options.httpBaseUrl || '').replace(/\/+$/, '');
329
335
  const canonicalHttpBaseUrl = normalizeHttpBaseUrl(httpBaseUrl);
330
336
  const stunUrls = normalizeStunUrls(options.stunUrls);
337
+ const icePortRange = normalizeConsoleIcePortRange(options.icePortRange);
338
+ const routerMapping = options.routerMapping || createConsoleRouterMapping({
339
+ isEnabled: options.isRouterMappingEnabled
340
+ });
331
341
  const SignalingWebSocketImpl = options.SignalingWebSocketImpl || options.WebSocketImpl || WebSocket;
332
342
  const LocalWebSocketImpl = options.LocalWebSocketImpl || options.WebSocketImpl || WebSocket;
333
343
  const fetchImpl = options.fetchImpl || globalThis.fetch;
@@ -380,6 +390,7 @@ export function createHubConsoleDirect(options = {}) {
380
390
  Number(options.logicalBindTimeoutMs) || LOGICAL_BIND_TIMEOUT_MS
381
391
  );
382
392
  const peers = new Map();
393
+ const recentPeerConnections = [];
383
394
  const revokedMembers = new Map();
384
395
  let signalSocket = null;
385
396
  let signalGeneration = 0;
@@ -569,6 +580,9 @@ export function createHubConsoleDirect(options = {}) {
569
580
  const retirePeer = (owner, reason = 'console-direct-peer-closed', options = {}) => {
570
581
  if (!owner || owner.retired) return;
571
582
  owner.retired = true;
583
+ owner.iceSetupGate?.close();
584
+ void owner.routerMapping?.release();
585
+ retainConsoleIceRetirement(recentPeerConnections, owner, reason);
572
586
  if (peers.get(owner.consoleId) === owner) peers.delete(owner.consoleId);
573
587
  clearTimeout(owner.iceTimer);
574
588
  owner.iceTimer = null;
@@ -774,6 +788,7 @@ export function createHubConsoleDirect(options = {}) {
774
788
  }
775
789
  return false;
776
790
  }
791
+ const sendStartedAt = performance.now();
777
792
  for (const [chunkIndex, chunk] of chunks.entries()) {
778
793
  if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
779
794
  try {
@@ -810,6 +825,8 @@ export function createHubConsoleDirect(options = {}) {
810
825
  return false;
811
826
  }
812
827
  }
828
+ if (!isOwnerActive(owner) || channelState.closed) return false;
829
+ channelState.frameAdmission?.sent(wireBytes, sendStartedAt);
813
830
  return true;
814
831
  }
815
832
 
@@ -1252,6 +1269,8 @@ export function createHubConsoleDirect(options = {}) {
1252
1269
  channel.onOpen(() => {
1253
1270
  if (!isOwnerActive(owner) || channelState.closed) return;
1254
1271
  if (channelState === owner.control) {
1272
+ owner.controlOpened = true;
1273
+ void owner.routerMapping?.release();
1255
1274
  clearTimeout(owner.iceTimer);
1256
1275
  owner.iceTimer = null;
1257
1276
  sendControl(owner, {
@@ -1315,7 +1334,7 @@ export function createHubConsoleDirect(options = {}) {
1315
1334
  && existing.connectionId === connectionId
1316
1335
  && existing.hubEpoch === ownerHubEpoch
1317
1336
  && !existing.retired) {
1318
- const current = existing.peer.localDescription?.();
1337
+ const current = existing.peer?.localDescription?.();
1319
1338
  if (current?.type === 'answer' && typeof current.sdp === 'string') {
1320
1339
  sendSignal({
1321
1340
  type: 'rtc-answer',
@@ -1337,29 +1356,6 @@ export function createHubConsoleDirect(options = {}) {
1337
1356
  }
1338
1357
  if (existing) retirePeer(existing, 'console-direct-peer-replaced');
1339
1358
  let peer;
1340
- try {
1341
- peer = createPeerConnection(`console-${consoleId}`, {
1342
- iceServers: [...stunUrls],
1343
- iceTransportPolicy: 'all',
1344
- disableAutoNegotiation: false,
1345
- disableFingerprintVerification: false,
1346
- // Each native send is one wire chunk. libdatachannel also uses this
1347
- // setting as the minimum SCTP socket buffer size, below bufferedAmount.
1348
- // A whole-frame limit here hides megabytes from our admission gate.
1349
- // Complete frame/HTTP limits remain enforced by the wire assemblers.
1350
- maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES
1351
- });
1352
- } catch (error) {
1353
- lastError = error instanceof Error ? error.message : String(error);
1354
- sendSignal({
1355
- type: 'rtc-close',
1356
- consoleId,
1357
- connectionId,
1358
- hubEpoch: ownerHubEpoch,
1359
- reason: 'console-direct-peer-create-failed'
1360
- });
1361
- return null;
1362
- }
1363
1359
  const owner = {
1364
1360
  consoleId,
1365
1361
  connectionId,
@@ -1377,70 +1373,18 @@ export function createHubConsoleDirect(options = {}) {
1377
1373
  accessTimer: null,
1378
1374
  peerState: 'new',
1379
1375
  iceState: 'new',
1376
+ controlOpened: false,
1377
+ routerMapping: null,
1378
+ iceSetupGate: null,
1379
+ iceEvidence: createConsoleIceEvidence(),
1380
1380
  retired: false,
1381
1381
  createdAt: Date.now()
1382
1382
  };
1383
1383
  peers.set(consoleId, owner);
1384
+ owner.routerMapping = routerMapping.createOwner(`${consoleId}:${connectionId}:${owner.generation}`,
1385
+ { portRange: icePortRange });
1386
+ if (owner.routerMapping?.handoff) owner.iceSetupGate = createConsoleIceSetupGate();
1384
1387
  armOwnerAccessDeadline(owner);
1385
- peer.onLocalDescription((sdp, type) => {
1386
- if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
1387
- if (candidateIsRelay(sdp)) {
1388
- rejectedRelayCandidates += 1;
1389
- retirePeer(owner, 'console-direct-relay-candidate-rejected');
1390
- return;
1391
- }
1392
- sendSignal({
1393
- type: 'rtc-answer',
1394
- ...ownerEnvelope(owner),
1395
- description: { type: 'answer', sdp: String(sdp) }
1396
- });
1397
- });
1398
- peer.onLocalCandidate((candidate, sdpMid) => {
1399
- if (!isOwnerActive(owner)) return;
1400
- const candidateText = String(candidate || '');
1401
- if (byteLength(candidateText) > ICE_CANDIDATE_MAX_BYTES) {
1402
- retirePeer(owner, 'console-direct-candidate-invalid');
1403
- return;
1404
- }
1405
- if (candidateIsRelay(candidateText)) {
1406
- rejectedRelayCandidates += 1;
1407
- retirePeer(owner, 'console-direct-relay-candidate-rejected');
1408
- return;
1409
- }
1410
- sendSignal({
1411
- type: 'rtc-ice',
1412
- ...ownerEnvelope(owner),
1413
- candidate: candidateText,
1414
- sdpMid: sdpMid === undefined || sdpMid === null ? null : String(sdpMid).slice(0, 256)
1415
- });
1416
- });
1417
- peer.onDataChannel(channel => bindDataChannel(owner, channel));
1418
- peer.onStateChange(nextState => {
1419
- if (!isOwnerActive(owner)) return;
1420
- const normalized = String(nextState || '').toLowerCase();
1421
- owner.peerState = normalized;
1422
- if (normalized === 'connected') {
1423
- if (peerSelectedRelay(peer)) {
1424
- rejectedRelayCandidates += 1;
1425
- retirePeer(owner, 'console-direct-relay-pair-rejected');
1426
- return;
1427
- }
1428
- } else if (normalized === 'failed' || normalized === 'closed') {
1429
- retirePeer(owner, `console-direct-peer-${normalized}`);
1430
- return;
1431
- }
1432
- refreshPeerDisconnectDeadline(owner);
1433
- });
1434
- peer.onIceStateChange?.(nextState => {
1435
- if (!isOwnerActive(owner)) return;
1436
- const normalized = String(nextState || '').toLowerCase();
1437
- owner.iceState = normalized;
1438
- if (normalized === 'failed' || normalized === 'closed') {
1439
- retirePeer(owner, `console-direct-ice-${normalized}`);
1440
- return;
1441
- }
1442
- refreshPeerDisconnectDeadline(owner);
1443
- });
1444
1388
  owner.iceTimer = setTimeout(() => {
1445
1389
  owner.iceTimer = null;
1446
1390
  if (isOwnerActive(owner)
@@ -1449,13 +1393,117 @@ export function createHubConsoleDirect(options = {}) {
1449
1393
  }
1450
1394
  }, iceConnectTimeoutMs);
1451
1395
  owner.iceTimer.unref?.();
1452
- try {
1453
- peer.setRemoteDescription(description.sdp, 'offer');
1454
- } catch (error) {
1455
- lastError = error instanceof Error ? error.message : String(error);
1456
- retirePeer(owner, 'console-direct-description-failed');
1457
- return null;
1458
- }
1396
+ const initializePeer = reservedPort => {
1397
+ if (!isOwnerActive(owner)) return null;
1398
+ try {
1399
+ peer = createPeerConnection(`console-${consoleId}`, {
1400
+ iceServers: [...stunUrls],
1401
+ iceTransportPolicy: 'all',
1402
+ ...icePortRange,
1403
+ ...(reservedPort ? { portRangeBegin: reservedPort, portRangeEnd: reservedPort } : {}),
1404
+ disableAutoNegotiation: false,
1405
+ disableFingerprintVerification: false,
1406
+ // Keep the native SCTP socket's hidden buffer at the wire chunk bound.
1407
+ maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES
1408
+ });
1409
+ owner.peer = peer;
1410
+ } catch (error) {
1411
+ lastError = error instanceof Error ? error.message : String(error);
1412
+ retirePeer(owner, 'console-direct-peer-create-failed');
1413
+ return null;
1414
+ }
1415
+ peer.onLocalDescription((sdp, type) => {
1416
+ if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
1417
+ if (candidateIsRelay(sdp)) {
1418
+ rejectedRelayCandidates += 1;
1419
+ retirePeer(owner, 'console-direct-relay-candidate-rejected');
1420
+ return;
1421
+ }
1422
+ const sent = sendSignal({
1423
+ type: 'rtc-answer',
1424
+ ...ownerEnvelope(owner),
1425
+ description: { type: 'answer', sdp: String(sdp) }
1426
+ });
1427
+ owner.iceEvidence.localDescriptionSent = sent;
1428
+ recordConsoleIceDescription(owner.iceEvidence.local, sdp);
1429
+ if (!owner.controlOpened) {
1430
+ for (const line of sdp.split(/\r?\n/)) {
1431
+ if (line.startsWith('a=candidate:')) owner.routerMapping?.observe(line);
1432
+ }
1433
+ }
1434
+ });
1435
+ peer.onLocalCandidate((candidate, sdpMid) => {
1436
+ if (!isOwnerActive(owner)) return;
1437
+ const candidateText = String(candidate || '');
1438
+ if (byteLength(candidateText) > ICE_CANDIDATE_MAX_BYTES) {
1439
+ retirePeer(owner, 'console-direct-candidate-invalid');
1440
+ return;
1441
+ }
1442
+ if (candidateIsRelay(candidateText)) {
1443
+ rejectedRelayCandidates += 1;
1444
+ retirePeer(owner, 'console-direct-relay-candidate-rejected');
1445
+ return;
1446
+ }
1447
+ const sent = sendSignal({
1448
+ type: 'rtc-ice',
1449
+ ...ownerEnvelope(owner),
1450
+ candidate: candidateText,
1451
+ sdpMid: sdpMid === undefined || sdpMid === null ? null : String(sdpMid).slice(0, 256)
1452
+ });
1453
+ recordConsoleIceCandidate(owner.iceEvidence.local, candidateText);
1454
+ if (!owner.controlOpened) owner.routerMapping?.observe(candidateText);
1455
+ if (!sent) owner.iceEvidence.localCandidateSendFailures = Math.min(65_535,
1456
+ owner.iceEvidence.localCandidateSendFailures + 1);
1457
+ });
1458
+ peer.onGatheringStateChange?.(nextState => {
1459
+ if (isOwnerActive(owner)) owner.iceEvidence.gatheringState = safeIceState(String(nextState).toLowerCase());
1460
+ });
1461
+ peer.onDataChannel(channel => bindDataChannel(owner, channel));
1462
+ peer.onStateChange(nextState => {
1463
+ if (!isOwnerActive(owner)) return;
1464
+ const normalized = String(nextState || '').toLowerCase();
1465
+ owner.peerState = normalized;
1466
+ if (normalized === 'connected') {
1467
+ if (peerSelectedRelay(peer)) {
1468
+ rejectedRelayCandidates += 1;
1469
+ retirePeer(owner, 'console-direct-relay-pair-rejected');
1470
+ return;
1471
+ }
1472
+ } else if (normalized === 'failed' || normalized === 'closed') {
1473
+ retirePeer(owner, `console-direct-peer-${normalized}`);
1474
+ return;
1475
+ }
1476
+ refreshPeerDisconnectDeadline(owner);
1477
+ });
1478
+ peer.onIceStateChange?.(nextState => {
1479
+ if (!isOwnerActive(owner)) return;
1480
+ const normalized = String(nextState || '').toLowerCase();
1481
+ owner.iceState = normalized;
1482
+ if (normalized === 'failed' || normalized === 'closed') {
1483
+ retirePeer(owner, `console-direct-ice-${normalized}`);
1484
+ return;
1485
+ }
1486
+ refreshPeerDisconnectDeadline(owner);
1487
+ });
1488
+ try {
1489
+ peer.setRemoteDescription(description.sdp, 'offer');
1490
+ owner.iceEvidence.remoteDescriptionApplied = true;
1491
+ recordConsoleIceDescription(owner.iceEvidence.remote, description.sdp);
1492
+ owner.iceSetupGate?.flush((candidate, mid) => peer.addRemoteCandidate(candidate, mid),
1493
+ () => isOwnerActive(owner));
1494
+ } catch (error) {
1495
+ lastError = error instanceof Error ? error.message : String(error);
1496
+ retirePeer(owner, 'console-direct-description-failed');
1497
+ return null;
1498
+ }
1499
+ return owner;
1500
+ };
1501
+ if (owner.routerMapping?.handoff) {
1502
+ void owner.routerMapping.ready.then(async () => {
1503
+ const reservation = await owner.routerMapping.handoff();
1504
+ if (isOwnerActive(owner)) initializePeer(reservation?.port);
1505
+ }).catch(() => retirePeer(owner, 'console-direct-peer-create-failed'));
1506
+ } else initializePeer();
1459
1507
  return owner;
1460
1508
  };
1461
1509
 
@@ -1594,7 +1642,12 @@ export function createHubConsoleDirect(options = {}) {
1594
1642
  return;
1595
1643
  }
1596
1644
  try {
1597
- if (candidate) owner.peer.addRemoteCandidate(candidate, String(payload.sdpMid || ''));
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
+ }
1598
1651
  } catch {
1599
1652
  retirePeer(owner, 'console-direct-candidate-failed');
1600
1653
  }
@@ -1951,6 +2004,8 @@ export function createHubConsoleDirect(options = {}) {
1951
2004
  retryTimer = null;
1952
2005
  nextRetryAt = '';
1953
2006
  retireAllPeers('hub-shutdown');
2007
+ void routerMapping.releaseAll({ forgetHistory: true });
2008
+ recentPeerConnections.length = 0;
1954
2009
  hubEpoch = '';
1955
2010
  const socket = signalSocket;
1956
2011
  signalSocket = null;
@@ -1961,6 +2016,8 @@ export function createHubConsoleDirect(options = {}) {
1961
2016
 
1962
2017
  const invalidateWorkspaceAccess = (reason = 'console-workspace-access-invalidated') => {
1963
2018
  retireAllPeers(String(reason || 'console-workspace-access-invalidated').slice(0, 120));
2019
+ void routerMapping.releaseAll({ forgetHistory: true });
2020
+ recentPeerConnections.length = 0;
1964
2021
  refresh();
1965
2022
  };
1966
2023
 
@@ -2018,6 +2075,7 @@ export function createHubConsoleDirect(options = {}) {
2018
2075
  generation: owner.generation,
2019
2076
  peerState: owner.peerState,
2020
2077
  iceState: owner.iceState,
2078
+ iceEvidence: snapshotConsoleIceEvidence(owner.iceEvidence),
2021
2079
  rttMs: optionalPeerMetric(owner.peer, 'rtt'),
2022
2080
  bytesSent: optionalPeerMetric(owner.peer, 'bytesSent'),
2023
2081
  bytesReceived: optionalPeerMetric(owner.peer, 'bytesReceived'),
@@ -2041,6 +2099,7 @@ export function createHubConsoleDirect(options = {}) {
2041
2099
  const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
2042
2100
  const signalReadyDeadlineActive = Boolean(signalReadyDeadline);
2043
2101
  const signalHeartbeatTimerActive = Boolean(signalHeartbeat?.timer);
2102
+ const routerResources = routerMapping.inspect();
2044
2103
  return Object.freeze({
2045
2104
  enabled: signalUrl instanceof URL && Boolean(deviceId),
2046
2105
  state,
@@ -2055,6 +2114,8 @@ export function createHubConsoleDirect(options = {}) {
2055
2114
  retainedAssemblyBytes,
2056
2115
  bufferedSendBytes,
2057
2116
  peerDiagnostics,
2117
+ routerMapping: routerResources,
2118
+ recentPeerConnections: recentPeerConnections.map(entry => snapshotConsoleIceEvidence(entry)),
2058
2119
  iceConnectTimers,
2059
2120
  peerDisconnectTimers,
2060
2121
  assemblyDeadlineTimers,
@@ -2075,7 +2136,8 @@ export function createHubConsoleDirect(options = {}) {
2075
2136
  + workspaceAccessTimers
2076
2137
  + Number(Boolean(workspaceReauthAttempt?.timer))
2077
2138
  + Number(signalReadyDeadlineActive)
2078
- + Number(signalHeartbeatTimerActive),
2139
+ + Number(signalHeartbeatTimerActive)
2140
+ + Number(routerResources.resourceTimers || 0),
2079
2141
  rejectedRelayCandidates,
2080
2142
  dataChannelBackpressureCloses,
2081
2143
  dataChannelSendFailures,
@@ -2096,7 +2158,8 @@ export function createHubConsoleDirect(options = {}) {
2096
2158
  close,
2097
2159
  invalidateWorkspaceAccess,
2098
2160
  revokeWorkspaceMember,
2099
- inspect
2161
+ inspect,
2162
+ releaseRouterMappings: () => routerMapping.releaseAll()
2100
2163
  });
2101
2164
  }
2102
2165
 
@@ -158,6 +158,7 @@ class FakePeerConnection {
158
158
  onDataChannel(callback) { this.callbacks.dataChannel = callback; }
159
159
  onStateChange(callback) { this.callbacks.state = callback; }
160
160
  onIceStateChange(callback) { this.callbacks.iceState = callback; }
161
+ onGatheringStateChange(callback) { this.callbacks.gatheringState = callback; }
161
162
 
162
163
  setRemoteDescription(sdp, type) {
163
164
  this.remote = { sdp, type };
@@ -233,6 +234,8 @@ async function connectedDirect(options = {}) {
233
234
  hubInstanceId: options.hubInstanceId || HUB_EPOCH,
234
235
  httpBaseUrl: 'http://127.0.0.1:5179',
235
236
  stunUrls: options.stunUrls || ['stun:stun.example.test:3478'],
237
+ icePortRange: options.icePortRange,
238
+ routerMapping: options.routerMapping,
236
239
  getAccessToken: async () => 'access-token',
237
240
  ...(options.workspaceAccess ? { getWorkspaceAccess: () => options.workspaceAccess } : {}),
238
241
  ...(options.consoleProxyToken ? { consoleProxyToken: options.consoleProxyToken } : {}),
@@ -340,6 +343,8 @@ test('native buffered sends complete each media message without closing input or
340
343
  assert.equal(peer.closed, false);
341
344
  assert.equal(direct.inspect().dataChannelSendFailures, 0);
342
345
  assert.equal(direct.inspect().dataChannelBackpressureCloses, 0);
346
+ assert.ok(direct.inspect().peerDiagnostics[0].lanes.find(l => l.channelId === CHANNEL_ID).frameAdmission.sendBatches > 0,
347
+ 'accepted native false returns are accounted before the lane closes');
343
348
  lane.sendMessageBinary = () => { throw new Error('native-send-failed'); };
344
349
  local.receive(Buffer.alloc(10), true);
345
350
  assert.equal(lane.closed, true, 'an actual native exception still retires the exact lane');
@@ -353,11 +358,134 @@ test('native buffered sends complete each media message without closing input or
353
358
  }
354
359
  });
355
360
 
361
+ test('an explicit direct port range reaches the native peer without enabling relay or ICE TCP', async () => {
362
+ const { direct, signal } = await connectedDirect({ icePortRange: '54443-54450' });
363
+ try {
364
+ const peer = offer(signal);
365
+ assert.equal(peer.config.portRangeBegin, 54443);
366
+ assert.equal(peer.config.portRangeEnd, 54450);
367
+ assert.equal(peer.config.enableIceTcp, undefined);
368
+ assert.equal(peer.config.disableFingerprintVerification, false);
369
+ assert.deepEqual(peer.config.iceServers, ['stun:stun.example.test:3478']);
370
+ } finally { direct.close(); }
371
+ });
372
+
373
+ test('router setup belongs to the exact peer and ends on control ready without closing that peer', async () => {
374
+ const leases = [];
375
+ const routerMapping = {
376
+ createOwner(key) {
377
+ const lease = { key, candidates: [], released: false,
378
+ observe(candidate) { this.candidates.push(candidate); },
379
+ async release() { this.released = true; } };
380
+ leases.push(lease);
381
+ return lease;
382
+ },
383
+ async releaseAll() { await Promise.all(leases.map(lease => lease.release())); },
384
+ inspect() { return { resourceTimers: 0 }; }
385
+ };
386
+ const { direct, signal } = await connectedDirect({ routerMapping });
387
+ try {
388
+ const first = offer(signal);
389
+ const second = offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: CONNECTION_TWO });
390
+ first.emitLocalCandidate('candidate:1 1 udp 1 192.168.0.4 54443 typ host');
391
+ assert.equal(leases[0].candidates.length, 1);
392
+ assert.notEqual(leases[0].key, leases[1].key);
393
+ const control = new FakeDataChannel('livedesk-control-v1');
394
+ first.emitDataChannel(control); control.open();
395
+ assert.equal(leases[0].released, true);
396
+ assert.equal(leases[1].released, false);
397
+ assert.equal(first.closed, false);
398
+ assert.equal(second.closed, false);
399
+ first.emitLocalCandidate('candidate:2 1 udp 1 192.168.0.4 54444 typ host');
400
+ assert.equal(leases[0].candidates.length, 1, 'late gathering cannot reopen an established peer mapping');
401
+ direct.invalidateWorkspaceAccess();
402
+ await direct.releaseRouterMappings();
403
+ assert.ok(leases.every(lease => lease.released));
404
+ } finally { direct.close(); }
405
+ });
406
+
407
+ test('router readiness precedes native gathering and a retired peer cannot consume late setup completion', async () => {
408
+ const completions = [];
409
+ const routerMapping = {
410
+ createOwner() {
411
+ return { ready: new Promise(resolve => completions.push(resolve)),
412
+ async handoff() { return { port: 54443 }; }, observe() {}, release() {} };
413
+ },
414
+ async releaseAll() {},
415
+ inspect: () => ({ resourceTimers: 0 })
416
+ };
417
+ const { direct, signal } = await connectedDirect({ routerMapping });
418
+ const candidate = 'candidate:7 1 UDP 123 8.8.8.8 50000 typ srflx';
419
+ const sdp = `v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:0\r\na=${candidate}\r\n`;
420
+ try {
421
+ offer(signal, CONNECTION_ONE, sdp);
422
+ assert.equal(FakePeerConnection.instances.length, 0);
423
+ signal.receive(JSON.stringify({ type: 'rtc-ice', consoleId: CONSOLE_ID, connectionId: CONNECTION_ONE,
424
+ hubEpoch: HUB_EPOCH, candidate, sdpMid: '0' }));
425
+ assert.equal(FakePeerConnection.instances.length, 0);
426
+ completions[0](); await settle();
427
+ const first = FakePeerConnection.instances[0];
428
+ assert.equal(first.config.portRangeBegin, 54443);
429
+ assert.equal(first.config.portRangeEnd, 54443);
430
+ assert.equal(first.remote.sdp, sdp);
431
+ assert.deepEqual(first.remoteCandidates, [{ candidate, sdpMid: '0' }]);
432
+ offer(signal, CONNECTION_TWO, sdp);
433
+ direct.invalidateWorkspaceAccess();
434
+ completions[1](); await settle();
435
+ assert.equal(FakePeerConnection.instances.length, 1, 'retired setup must not create a native peer');
436
+ assert.equal(first.closed, true);
437
+ } finally { direct.close(); }
438
+ });
439
+
440
+ test('failed ICE owners retain bounded evidence while a healthy peer and signaling survive', async () => {
441
+ const { direct, signal } = await connectedDirect({ iceConnectTimeoutMs: 30 });
442
+ try {
443
+ const healthy = offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: CONNECTION_TWO });
444
+ const control = new FakeDataChannel('livedesk-control-v1');
445
+ healthy.emitDataChannel(control);
446
+ control.open();
447
+ const failed = offer(signal);
448
+ failed.emitLocalCandidate('candidate:1 1 udp 1 203.0.113.1 40000 typ srflx raddr 192.168.0.4 rport 40000');
449
+ failed.callbacks.gatheringState('complete');
450
+ failed.callbacks.iceState('checking');
451
+ signal.receive(JSON.stringify({ type: 'rtc-ice', consoleId: CONSOLE_ID,
452
+ connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH, sdpMid: '0',
453
+ candidate: 'candidate:2 1 udp 1 2001:db8::abcd 50000 typ host' }));
454
+ await waitFor(() => failed.closed);
455
+ const evidence = direct.inspect().recentPeerConnections.at(-1);
456
+ assert.equal(evidence.reason, 'console-direct-ice-timeout');
457
+ assert.equal(evidence.localDescriptionSent, true);
458
+ assert.equal(evidence.remoteDescriptionApplied, true);
459
+ assert.equal(evidence.gatheringState, 'complete');
460
+ assert.equal(evidence.local.srflx, 1);
461
+ assert.equal(evidence.remote.ipv6, 1);
462
+ assert.equal(evidence.controlOpened, false);
463
+ assert.equal(healthy.closed, false);
464
+ assert.equal(direct.inspect().controlChannels, 1);
465
+ assert.equal(direct.inspect().state, 'connected');
466
+ assert.equal(direct.inspect().iceConnectTimers, 0);
467
+ failed.emitLocalCandidate('candidate:3 1 udp 1 192.168.0.7 40000 typ host');
468
+ failed.callbacks.gatheringState('new');
469
+ assert.deepEqual(direct.inspect().recentPeerConnections.at(-1), evidence);
470
+ evidence.remote.ipv6 = 500;
471
+ assert.equal(direct.inspect().recentPeerConnections.at(-1).remote.ipv6, 1);
472
+ assert.doesNotMatch(JSON.stringify(direct.inspect().recentPeerConnections), /203\.0\.113|192\.168|2001:db8/);
473
+ direct.invalidateWorkspaceAccess();
474
+ assert.deepEqual(direct.inspect().recentPeerConnections, []);
475
+ } finally {
476
+ direct.close();
477
+ assert.equal(direct.inspect().resourceTimers, 0);
478
+ assert.deepEqual(direct.inspect().recentPeerConnections, []);
479
+ }
480
+ });
481
+
356
482
  test('STUN-only peer answers and rejects stale owner callbacks after replacement', async () => {
357
483
  const { direct, signal } = await connectedDirect();
358
484
  const firstPeer = offer(signal, CONNECTION_ONE);
359
485
  assert.deepEqual(firstPeer.config.iceServers, ['stun:stun.example.test:3478']);
360
486
  assert.equal(firstPeer.config.iceTransportPolicy, 'all');
487
+ assert.equal(firstPeer.config.portRangeBegin, undefined);
488
+ assert.equal(firstPeer.config.portRangeEnd, undefined);
361
489
  assert.equal(firstPeer.config.disableFingerprintVerification, false);
362
490
  assert.equal(firstPeer.config.maxMessageSize, DIRECT_CONSOLE_WIRE_CHUNK_BYTES,
363
491
  'native SCTP accepts wire chunks, not whole frames; a frame-sized setting silently enlarges its hidden buffers');
@@ -0,0 +1,30 @@
1
+ // The authenticated offer retains its original SDP while the native socket is
2
+ // prepared. Only bounded trickled candidates wait here, with no timer or peer.
3
+ export function createConsoleIceSetupGate() {
4
+ let waiting = true;
5
+ let closed = false;
6
+ const candidates = [];
7
+ function defer(candidate, mid) {
8
+ if (closed || !waiting) return false;
9
+ mid = String(mid || '');
10
+ if (candidate.length > 4096 || mid.length > 256) throw new Error('console-direct-candidate-invalid');
11
+ if (candidates.some(value => value.candidate === candidate && value.mid === mid)) return true;
12
+ if (candidates.length >= 16) throw new Error('console-direct-candidate-invalid');
13
+ candidates.push({ candidate, mid });
14
+ return true;
15
+ }
16
+ return {
17
+ defer,
18
+ flush(apply, isCurrent) {
19
+ if (closed) return;
20
+ waiting = false;
21
+ while (candidates.length > 0) {
22
+ const value = candidates.shift();
23
+ if (!isCurrent()) { candidates.length = 0; return; }
24
+ apply(value.candidate, value.mid);
25
+ }
26
+ },
27
+ close() { closed = true; waiting = false; candidates.length = 0; },
28
+ inspect: () => ({ waiting: waiting && !closed, candidates: candidates.length })
29
+ };
30
+ }