teleportxr 1.0.93 → 1.0.95

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/client/client.js CHANGED
@@ -67,6 +67,12 @@ class Client {
67
67
  this.webRtcConnection=null;
68
68
  this.currentOriginState=new OriginState();
69
69
  this.currentLightingState=new LightingState();
70
+ // Latest head pose reported by the client (node.Pose), or null before the first
71
+ // NodePosesMessage. Used server-side for proximity-based audio selection.
72
+ this.currentHeadPose=null;
73
+ // When true, the SetupCommand tells the client to send its microphone track
74
+ // (needed for the audio SFU). Set by the host before Start(); default off.
75
+ this.acceptMicrophone=false;
70
76
  this.next_ack_id=BigInt(1);
71
77
  this.clientStartMs=Date.now();
72
78
  this.webRtcConnectedAtMs=0;
@@ -119,6 +125,13 @@ class Client {
119
125
  else
120
126
  {
121
127
  this.webRtcConnected=false;
128
+ // Rebaseline the connect-attempt clock so hasWebRtcConnectionTimedOut() gives the
129
+ // low-level WebRtcConnection.reconnect() (webrtcconnection.js) a fresh
130
+ // WEBRTC_CONNECT_TIMEOUT_MS window to recover, instead of comparing against the
131
+ // original connection-start time from potentially many seconds/minutes earlier —
132
+ // which would otherwise make any post-connect ICE failure time out almost instantly
133
+ // on the next UpdateStreaming() tick and force-disconnect the client mid-reconnect.
134
+ this.webRtcConnectionInitiatedAtMs=Date.now();
122
135
  if (this.webRtcConnection)
123
136
  {
124
137
  try { this.webRtcConnection.stopSceneAudio(); } catch (e) {}
@@ -211,6 +224,9 @@ class Client {
211
224
  this.clientStartMs=Date.now();
212
225
  console.log("[T+0ms] Client.Start() — sending SetupCommand for client "+this.clientID);
213
226
  this.setupCommand=new command.SetupCommand();
227
+ // Accept the client's microphone media track when the host has enabled it
228
+ // (required for the audio SFU to receive and forward this client's voice).
229
+ this.setupCommand.uint8_audio_input_enabled = this.acceptMicrophone ? 1 : 0;
214
230
  this.clientDynamicLighting=new core.ClientDynamicLighting();
215
231
  // Session is (re)starting; the client has zero state, so retract any
216
232
  // outstanding ack tracking from a previous session and force a resend.
@@ -385,6 +401,15 @@ class Client {
385
401
  ProcessNodePoses(headPose,numPoses,nodePoses)
386
402
  {
387
403
  //console.log("Client: ProcessNodePoses ", numPoses, " poses.");
404
+ // Retain the head pose as this client's current world position, for
405
+ // proximity-based audio source selection (see src/mic-router.js).
406
+ if (headPose)
407
+ this.currentHeadPose = headPose;
408
+ }
409
+ // This client's current world position {x,y,z}, or null if not yet known.
410
+ GetHeadPosition()
411
+ {
412
+ return (this.currentHeadPose && this.currentHeadPose.position) ? this.currentHeadPose.position : null;
388
413
  }
389
414
  UpdateStreaming()
390
415
  {
@@ -111,14 +111,22 @@ class GeometryService {
111
111
  // this client should stream node uid.
112
112
  var res = GeometryService.GetOrCreateTrackedResource(uid);
113
113
  var index = clientIDToIndex.get(this.clientID);
114
+ if (res.clientNeeds.get(index)) {
115
+ // Already streaming for this client — nothing to do.
116
+ return;
117
+ }
114
118
  res.clientNeeds.set(index, true);
115
119
  // Add to the list of nodes this client should eventually receive:
116
120
  this.nodesToStreamEventually.add(uid);
117
121
  }
118
122
  UnstreamNode(uid) {
119
123
  var index = clientIDToIndex.get(this.clientID);
120
- if (GeometryService.trackedResources.has(uid)) {
121
- var res = GeometryService.GetOrCreateTrackedResource(uid);
124
+ var res = GeometryService.trackedResources.get(uid);
125
+ if (res) {
126
+ if (!res.clientNeeds.get(index)) {
127
+ // Already unstreamed for this client — nothing to do.
128
+ return;
129
+ }
122
130
  res.clientNeeds.set(index, false);
123
131
  }
124
132
  // Should certainly be in this set:
@@ -226,9 +226,22 @@ class WebRtcConnection extends EventEmitter
226
226
  const pc = this.peerConnection;
227
227
  try
228
228
  {
229
+ // Hand the client the same ICE server list (including any TURN entries) this
230
+ // connection is using, so the client doesn't need TURN credentials hardcoded
231
+ // or locally configured — this server is the single source of truth. Sent
232
+ // before the offer so it's ready by the time the client builds its
233
+ // RTCConfiguration.
234
+ const iceServersMessage = JSON.stringify({"teleport-signal-type": "ice-servers", "iceServers": this.iceServers});
235
+ this.sendConfigMessage(this.id, iceServersMessage);
236
+
229
237
  const offer = await pc.createOffer();
230
238
  if (this.peerConnection !== pc) return;
231
- await pc.setLocalDescription(offer);
239
+ // Rename each node-audio m-line's mid to the emitting node's uid
240
+ // (see docs/protocol/audio.rst) before it is committed, so the binding
241
+ // is intrinsic to the track. Munging pre-setLocalDescription is accepted
242
+ // by wrtc and reflected in pc.localDescription.
243
+ const mungedSdp = this._mungeAudioMids(offer.sdp);
244
+ await pc.setLocalDescription({ type: 'offer', sdp: mungedSdp });
232
245
  if (this.peerConnection !== pc) return;
233
246
  // Use pc.localDescription.sdp rather than the createOffer() result: the
234
247
  // ICE ufrag/pwd in the SDP returned by createOffer() are provisional and
@@ -499,6 +512,98 @@ class WebRtcConnection extends EventEmitter
499
512
  this._localAudioTrack = null;
500
513
  }
501
514
  this._audioSource = null;
515
+ if (this._nodeAudioSources) {
516
+ for (const v of this._nodeAudioSources.values()) {
517
+ try { v.track.stop(); } catch (e) {}
518
+ }
519
+ this._nodeAudioSources.clear();
520
+ }
521
+ }
522
+ // ── Per-node forwarded audio tracks ────────────────────────────────────────
523
+ // A node may emit audio (a participant's microphone, but equally an object,
524
+ // ambient source or announcer). Each such source is its own sendonly
525
+ // transceiver whose SDP mid is set to the emitting node's uid (see
526
+ // docs/protocol/audio.rst), so the client binds and spatialises it by node.
527
+ // The caller pushes that node's PCM into the returned RTCAudioSource. Call
528
+ // renegotiate() after adding/removing to re-offer.
529
+ // sourceNodeUid: BigInt|number|string. Returns the RTCAudioSource (or null).
530
+ addNodeAudioSource(sourceNodeUid) {
531
+ const uid = BigInt(sourceNodeUid);
532
+ if (!uid) return null;
533
+ if (!this._nodeAudioSources) this._nodeAudioSources = new Map();
534
+ const existing = this._nodeAudioSources.get(uid);
535
+ if (existing) return existing.audioSource;
536
+ if (!this.peerConnection) return null;
537
+ try {
538
+ const audioSource = new wrtc.nonstandard.RTCAudioSource();
539
+ const track = audioSource.createTrack();
540
+ const transceiver = this.peerConnection.addTransceiver(track, { direction: 'sendonly' });
541
+ this._nodeAudioSources.set(uid, { audioSource, track, transceiver, trackId: track.id });
542
+ return audioSource;
543
+ } catch (e) {
544
+ console.error('addNodeAudioSource failed for node ' + uid + ': ' + e.message);
545
+ return null;
546
+ }
547
+ }
548
+ removeNodeAudioSource(sourceNodeUid) {
549
+ const uid = BigInt(sourceNodeUid);
550
+ if (!this._nodeAudioSources) return;
551
+ const v = this._nodeAudioSources.get(uid);
552
+ if (!v) return;
553
+ try { v.track.stop(); } catch (e) {}
554
+ // The m-line cannot be removed, but going inactive/silent retires the source.
555
+ try { if (v.transceiver) v.transceiver.direction = 'inactive'; } catch (e) {}
556
+ this._nodeAudioSources.delete(uid);
557
+ }
558
+ // The node uids of the node-audio sources currently attached to this listener.
559
+ getNodeAudioSourceUids() {
560
+ if (!this._nodeAudioSources) return [];
561
+ return [...this._nodeAudioSources.keys()];
562
+ }
563
+ // Re-offer to apply added/removed node-audio tracks. No-op unless the connection
564
+ // is in a stable signalling state (a pending offer will be picked up next tick).
565
+ async renegotiate() {
566
+ const pc = this.peerConnection;
567
+ if (!pc || typeof pc.signalingState === 'string' && pc.signalingState !== 'stable') return;
568
+ await this.doOffer();
569
+ }
570
+ // Rewrite each node-audio m-line's mid to its emitting node uid, correlating
571
+ // m-line -> node by the track id carried in the m-line's a=msid, and repair the
572
+ // BUNDLE group. Idempotent: an m-line already carrying its node-uid mid is skipped.
573
+ _mungeAudioMids(sdp) {
574
+ if (!this._nodeAudioSources || this._nodeAudioSources.size === 0) return sdp;
575
+ const byTrack = new Map();
576
+ for (const [uid, v] of this._nodeAudioSources) byTrack.set(v.trackId, uid.toString());
577
+ const lines = sdp.split(/\r\n|\n/);
578
+ const renames = new Map(); // oldMid -> newMid
579
+ let i = 0;
580
+ while (i < lines.length) {
581
+ if (!lines[i].startsWith('m=')) { i++; continue; }
582
+ let j = i + 1, midIdx = -1, oldMid = null, trackId = null;
583
+ while (j < lines.length && !lines[j].startsWith('m=')) {
584
+ const l = lines[j];
585
+ if (l.startsWith('a=mid:')) { midIdx = j; oldMid = l.slice(6).trim(); }
586
+ else if (l.startsWith('a=msid:')) {
587
+ const parts = l.slice(7).trim().split(/\s+/); // "- <trackId>"
588
+ if (parts.length >= 2) trackId = parts[1];
589
+ }
590
+ j++;
591
+ }
592
+ if (midIdx >= 0 && trackId && byTrack.has(trackId)) {
593
+ const newMid = byTrack.get(trackId);
594
+ if (newMid !== oldMid) { lines[midIdx] = 'a=mid:' + newMid; renames.set(oldMid, newMid); }
595
+ }
596
+ i = j;
597
+ }
598
+ if (renames.size === 0) return sdp;
599
+ for (let k = 0; k < lines.length; k++) {
600
+ if (!lines[k].startsWith('a=group:BUNDLE')) continue;
601
+ const toks = lines[k].split(/\s+/);
602
+ for (let t = 1; t < toks.length; t++)
603
+ if (renames.has(toks[t])) toks[t] = renames.get(toks[t]);
604
+ lines[k] = toks.join(' ');
605
+ }
606
+ return lines.join('\r\n');
502
607
  }
503
608
  // Start streaming all SoundComponents in the given scene as a single mixed
504
609
  // 48 kHz / mono / int16 PCM track via the outbound audio media track. Idempotent
package/core/core.js CHANGED
@@ -32,6 +32,10 @@ function SizeOfType(member) {
32
32
  return [16, "struct"];
33
33
  case "uid":
34
34
  return [8, "uint64"];
35
+ case "int16":
36
+ return [2, "int16"];
37
+ case "uint16":
38
+ return [2, "uint16"];
35
39
  case "int32":
36
40
  return [4, "int32"];
37
41
  case "uint32":
@@ -42,6 +46,8 @@ function SizeOfType(member) {
42
46
  return [8, "int64"];
43
47
  case "VideoConfig":
44
48
  return [89, "struct"];
49
+ case "AudioConfig":
50
+ return [17, "struct"];
45
51
  case "VideoCodec":
46
52
  case "AxesStandard":
47
53
  case "GeometryPayloadType":
@@ -67,6 +73,12 @@ function encodeIntoDataView(obj, dataView, byteOffset) {
67
73
  else if (tp == "int8") {
68
74
  dataView.setInt8(byteOffset, value, endian);
69
75
  }
76
+ else if (tp == "uint16") {
77
+ dataView.setUint16(byteOffset, value, endian);
78
+ }
79
+ else if (tp == "int16") {
80
+ dataView.setInt16(byteOffset, value, endian);
81
+ }
70
82
  else if (tp == "uint32") {
71
83
  dataView.setUint32(byteOffset, value, endian);
72
84
  }
@@ -110,6 +122,14 @@ function decodeFromDataView(obj, dataView, byteOffset) {
110
122
  var value = dataView.getInt8(byteOffset);
111
123
  obj[key] = value;
112
124
  }
125
+ else if (tp == "uint16") {
126
+ var value = dataView.getUint16(byteOffset, endian);
127
+ obj[key] = value;
128
+ }
129
+ else if (tp == "int16") {
130
+ var value = dataView.getInt16(byteOffset, endian);
131
+ obj[key] = value;
132
+ }
113
133
  else if (tp == "uint32") {
114
134
  var value = dataView.getUint32(byteOffset, endian);
115
135
  obj[key] = value;
@@ -395,6 +415,24 @@ class VideoConfig {
395
415
  }
396
416
  }; // 89 bytes
397
417
 
418
+ //! Audio configuration carried inside SetupCommand (17 bytes).
419
+ //! See docs/protocol/audio.rst §AudioConfig for the full specification.
420
+ class AudioConfig {
421
+ constructor() {
422
+ this.uint8_codec = 1; //!< 0=disabled; 1=Opus
423
+ this.uint8_rtpPayloadType = 111;
424
+ this.uint32_sampleRateHz = 48000;
425
+ this.uint8_channelCount = 1;
426
+ this.uint8_frameDurationMs = 20;
427
+ this.uint8_flags = 3; //!< bit0=FEC, bit1=DTX
428
+ this.uint8_maxInboundStreams = 0;
429
+ this.uint8_selectionPolicy = 0; //!< 0=All
430
+ this.float32_proximityRadiusMetres = 0.0;
431
+ this.uint16_evictionGraceMs = 0;
432
+ }
433
+ static sizeof() { return 17; }
434
+ };
435
+
398
436
  //! Setup for dynamically-lit objects on the client.
399
437
  class ClientDynamicLighting {
400
438
  constructor() {
@@ -568,7 +606,7 @@ module.exports = {
568
606
  , vec4, BackgroundMode, AxesStandard, AxesStandardToCubemapSuffix
569
607
  , ConvertPosition, ConvertRotation, ConvertScale, ConvertPose
570
608
  , GeometryPayloadType, DisplayInfo, RenderingFeatures, LightingMode, VideoCodec
571
- , VideoConfig, ClientDynamicLighting, encodeToUint8Array, decodeFromUint8Array
609
+ , VideoConfig, AudioConfig, ClientDynamicLighting, encodeToUint8Array, decodeFromUint8Array
572
610
  , generateUid, getStartTimeUnixUs, getTimestampUs,
573
611
  unixTimeToUTCString, put_float32, put_uint16, put_int32, put_uint32
574
612
  , put_uint64, put_uint8, put_vec2, put_vec3, put_vec4, put_string
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "name": "teleportxr",
18
18
  "description": "Teleport Spatial Server on node.js",
19
- "version": "1.0.93",
19
+ "version": "1.0.95",
20
20
  "repository": {
21
21
  "type": "git",
22
22
  "url": "git+https://github.com/simul/teleport-nodejs.git"
@@ -21,7 +21,9 @@ const CommandPayloadType =
21
21
  AssignNodePosePath:14,
22
22
  SetupInputs:15,
23
23
  PingForLatency:16,
24
- SetOriginNode:128
24
+ AudioSourceMapping:17,
25
+ AudioParticipantStateChange:18,
26
+ SetOriginNode:128
25
27
  };
26
28
  class Command{
27
29
  constructor(){
@@ -44,19 +46,20 @@ class SetupCommand extends Command
44
46
  this.int32_requiredLatencyMs = 0; //!< 9+4=13
45
47
  this.uint32_idle_connection_timeout = 5000.0; //!< 13+4=17
46
48
  this.uint64_session_id = BigInt.asUintN(64, BigInt(0)); //!< 17+8=25 The server's session id changes when the server session changes. 37 bytes
47
- this.VideoConfig_video_config=new core.VideoConfig(); //!< 25+89=114 Video setup structure. 41+89=130 bytes
48
- this.float32_draw_distance = 0.0; //!< 114+4=118 Maximum distance in metres to render locally. 134
49
- this.AxesStandard_axesStandard = core.AxesStandard.NotInitialized; //!< 118+1=119 The axis standard that the server uses, may be different from the client's. 147
50
- this.uint8_audio_input_enabled = 0; //!< 119+1=120 Server accepts audio stream from client.
51
- this.bool_using_ssl = true; //!< 120+1=121 Not in use, for later.
52
- this.int64_startTimestamp_utc_unix_us = BigInt.asUintN(64, BigInt(0)); //!< 121+8=129 UTC Unix Timestamp in microseconds when the server session began.
53
- // TODO: replace this with a background Material, which MAY contain video, te xture and/or plain colours.
54
- this.BackgroundMode_backgroundMode=core.BackgroundMode.COLOUR; //!< 129+1=130 Whether the server supplies a background, and of which type.
55
- this.vec4_backgroundColour=new core.vec4(); //!< 130+16=146 If the background is of the COLOUR type, which colour to use.
56
- this.uid_backgroundTexture=BigInt(0);
49
+ this.VideoConfig_video_config=new core.VideoConfig(); //!< 25+89=114 Video setup structure.
50
+ this.AudioConfig_audio_config=new core.AudioConfig(); //!< 114+17=131 Audio media-track config.
51
+ this.float32_draw_distance = 0.0; //!< 131+4=135 Maximum distance in metres to render locally.
52
+ this.AxesStandard_axesStandard = core.AxesStandard.NotInitialized; //!< 135+1=136 The axis standard that the server uses.
53
+ this.uint8_audio_input_enabled = 0; //!< 136+1=137 Server accepts a microphone media track from the client.
54
+ this.bool_using_ssl = true; //!< 137+1=138 Not in use, for later.
55
+ this.int64_startTimestamp_utc_unix_us = BigInt.asUintN(64, BigInt(0)); //!< 138+8=146 UTC Unix Timestamp in microseconds when the server session began.
56
+ // TODO: replace this with a background Material, which MAY contain video, texture and/or plain colours.
57
+ this.BackgroundMode_backgroundMode=core.BackgroundMode.COLOUR; //!< 146+1=147 Whether the server supplies a background, and of which type.
58
+ this.vec4_backgroundColour=new core.vec4(); //!< 147+16=163 If the background is of the COLOUR type, which colour to use.
59
+ this.uid_backgroundTexture=BigInt(0); //!< 163+8=171
57
60
  }
58
61
  static sizeof(){
59
- return 154;
62
+ return 171;
60
63
  }
61
64
  size(){
62
65
  return SetupCommand.sizeof();
@@ -143,4 +146,33 @@ class SetLightingCommand extends AckedCommand
143
146
  }
144
147
 
145
148
 
146
- module.exports= {Command,CommandPayloadType,SetupCommand,AcknowledgeHandshakeCommand,SetOriginNodeCommand,SetLightingCommand};
149
+ //! Sent from server to client when the set of audio tracks delivered to a client changes.
150
+ //! The fixed-size header is followed on the wire by addedCount AddedEntry records
151
+ //! (uint8 midLen + midLen UTF-8 bytes + uint64 sourceClientUid) then removedCount
152
+ //! RemovedEntry records (uint8 midLen + midLen UTF-8 bytes).
153
+ class AudioSourceMappingCommand extends Command
154
+ {
155
+ constructor(){
156
+ super();
157
+ this.CommandPayloadType_commandPayloadType = CommandPayloadType.AudioSourceMapping;
158
+ this.uint16_addedCount = 0;
159
+ this.uint16_removedCount = 0;
160
+ }
161
+ static sizeof() { return 5; } // 1 tag + 2 addedCount + 2 removedCount
162
+ size() { return AudioSourceMappingCommand.sizeof(); }
163
+ }
164
+
165
+ //! Sent from server to client to report user-visible audio state changes for participants.
166
+ //! Followed by updateCount × 10-byte Update records (uint64 sourceClientUid + uint8 state + uint8 reason).
167
+ class AudioParticipantStateChangeCommand extends Command
168
+ {
169
+ constructor(){
170
+ super();
171
+ this.CommandPayloadType_commandPayloadType = CommandPayloadType.AudioParticipantStateChange;
172
+ this.uint16_updateCount = 0;
173
+ }
174
+ static sizeof() { return 3; } // 1 tag + 2 updateCount
175
+ size() { return AudioParticipantStateChangeCommand.sizeof(); }
176
+ }
177
+
178
+ module.exports= {Command,CommandPayloadType,SetupCommand,AcknowledgeHandshakeCommand,SetOriginNodeCommand,SetLightingCommand,AudioSourceMappingCommand,AudioParticipantStateChangeCommand};
package/scene/node.js CHANGED
@@ -12,7 +12,8 @@ const NodeDataType =
12
12
  SubScene:5,
13
13
  Skeleton:6,
14
14
  Link:7,
15
- Script:8
15
+ Script:8,
16
+ AudioEmitter:9 // Reserved. Audio is bound via the track SDP mid = emitting node uid; not carried in the node payload.
16
17
  };
17
18
 
18
19
  class Pose
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ // Regression tests for GeometryService.StreamNode / UnstreamNode idempotency.
3
+ //
4
+ // Before this fix, UnstreamNode unconditionally rewrote the clientNeeds bit,
5
+ // deleted from nodesToStreamEventually/streamedNodes, and logged — even when
6
+ // the node was already unstreamed for this client. A caller that re-evaluates
7
+ // a streaming decision every tick (e.g. distance-based visibility in
8
+ // teleport-nodejs-server-example's CustomClient.ProcessNodePoses) would then
9
+ // log and touch shared state on every tick it stayed on one side of the
10
+ // threshold, rather than only on the actual transition. StreamNode had the
11
+ // same gap in the other direction. Both now return early — no state writes,
12
+ // no log — when the call would not change anything.
13
+
14
+ const test = require('node:test');
15
+ const assert = require('node:assert');
16
+ const { GeometryService } = require('../client/geometry_service');
17
+
18
+ function makeService(clientID) {
19
+ // Reset the static trackedResources map between tests so uids don't leak
20
+ // across cases.
21
+ GeometryService.trackedResources = new Map();
22
+ return new GeometryService(clientID);
23
+ }
24
+
25
+ test('UnstreamNode logs and clears state on the real transition, then is silent on repeat calls', () => {
26
+ const svc = makeService(201);
27
+ svc.StreamNode(31n);
28
+ const res = GeometryService.GetOrCreateTrackedResource(31n);
29
+ assert.ok(res.IsNeededByClient(201));
30
+
31
+ const originalLog = console.log;
32
+ const logs = [];
33
+ console.log = (...args) => logs.push(args.join(''));
34
+ try {
35
+ svc.UnstreamNode(31n);
36
+ assert.ok(!res.IsNeededByClient(201));
37
+ assert.strictEqual(logs.length, 1, 'the first UnstreamNode call for a streamed node must log once');
38
+
39
+ svc.UnstreamNode(31n);
40
+ svc.UnstreamNode(31n);
41
+ assert.strictEqual(logs.length, 1,
42
+ 'repeated UnstreamNode calls while already unstreamed must not log again');
43
+ } finally {
44
+ console.log = originalLog;
45
+ }
46
+ });
47
+
48
+ test('UnstreamNode is a no-op on the redundant path: it must not re-touch nodesToStreamEventually/streamedNodes', () => {
49
+ const svc = makeService(202);
50
+ svc.StreamNode(33n);
51
+ svc.UnstreamNode(33n);
52
+
53
+ // Simulate some other subsystem re-adding bookkeeping for this uid after
54
+ // the real unstream, so a later no-op UnstreamNode call would visibly
55
+ // disturb it if the guard were missing.
56
+ svc.nodesToStreamEventually.add(33n);
57
+ svc.streamedNodes.set(33n, 1);
58
+
59
+ svc.UnstreamNode(33n);
60
+ assert.ok(svc.nodesToStreamEventually.has(33n),
61
+ 'a redundant UnstreamNode call (already unstreamed) must not delete from nodesToStreamEventually');
62
+ assert.ok(svc.streamedNodes.has(33n),
63
+ 'a redundant UnstreamNode call (already unstreamed) must not delete from streamedNodes');
64
+ });
65
+
66
+ test('StreamNode is idempotent: a second call for an already-needed node does not resurrect nodesToStreamEventually', () => {
67
+ const svc = makeService(203);
68
+ svc.StreamNode(41n);
69
+ assert.ok(svc.nodesToStreamEventually.has(41n));
70
+
71
+ // Simulate the resource having been picked up and removed from the
72
+ // "eventually" queue by UpdateNodesToStream, as happens in production.
73
+ svc.nodesToStreamEventually.delete(41n);
74
+
75
+ // A redundant StreamNode call (client still needs it) must not re-add it —
76
+ // only the real transition (need false -> true) should do that.
77
+ svc.StreamNode(41n);
78
+ assert.ok(!svc.nodesToStreamEventually.has(41n),
79
+ 'redundant StreamNode call must not resurrect nodesToStreamEventually for an already-needed node');
80
+ });
81
+
82
+ test('StreamNode after UnstreamNode is a real transition and does re-add to nodesToStreamEventually', () => {
83
+ const svc = makeService(204);
84
+ svc.StreamNode(43n);
85
+ svc.UnstreamNode(43n);
86
+ svc.nodesToStreamEventually.delete(43n); // UnstreamNode already does this; assert the starting state.
87
+ assert.ok(!svc.nodesToStreamEventually.has(43n));
88
+
89
+ svc.StreamNode(43n);
90
+ assert.ok(svc.nodesToStreamEventually.has(43n),
91
+ 'StreamNode must still re-add the uid on an actual false -> true transition');
92
+ const res = GeometryService.GetOrCreateTrackedResource(43n);
93
+ assert.ok(res.IsNeededByClient(204));
94
+ });
@@ -95,6 +95,25 @@ test('ClientManager.disconnectClient removes client from map', () => {
95
95
  assert.strictEqual(cm.clients.has(42), false, 'client must be removed from the map');
96
96
  });
97
97
 
98
+ test('streamingConnectionStateChanged rebaselines the connect-attempt clock on post-connect failure', () => {
99
+ // Simulate a client that connected a long time ago (well past the timeout window),
100
+ // then later has its ICE connection fail. Before the fix, hasWebRtcConnectionTimedOut()
101
+ // would compare against the original, stale webRtcConnectionInitiatedAtMs and report an
102
+ // immediate timeout, force-disconnecting the client before its own reconnect logic
103
+ // (WebRtcConnection.reconnect(), in webrtcconnection.js) gets a chance to run.
104
+ const c = makeStubClient({
105
+ webRtcConnectionInitiatedAtMs: Date.now() - 100000, // original connect attempt, long ago
106
+ });
107
+ c.webRtcConnected = true;
108
+ c.webRtcConnectedAtMs = Date.now() - 90000;
109
+
110
+ c.streamingConnectionStateChanged('failed');
111
+
112
+ assert.strictEqual(c.webRtcConnected, false, 'webRtcConnected must be false after a failure');
113
+ assert.strictEqual(c.hasWebRtcConnectionTimedOut(), false,
114
+ 'must not report a timeout immediately after rebaselining, so reconnect gets a fresh window');
115
+ });
116
+
98
117
  test('ClientManager.UpdateStreaming removes timed-out clients', async () => {
99
118
  const cm = new ClientManager();
100
119
  const clientsRemoved = [];