teleportxr 1.0.94 → 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
@@ -125,6 +125,13 @@ class Client {
125
125
  else
126
126
  {
127
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();
128
135
  if (this.webRtcConnection)
129
136
  {
130
137
  try { this.webRtcConnection.stopSceneAudio(); } catch (e) {}
@@ -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,6 +226,14 @@ 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
239
  // Rename each node-audio m-line's mid to the emitting node's uid
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.94",
19
+ "version": "1.0.95",
20
20
  "repository": {
21
21
  "type": "git",
22
22
  "url": "git+https://github.com/simul/teleport-nodejs.git"
@@ -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 = [];