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 +25 -0
- package/client/geometry_service.js +10 -2
- package/connections/webrtcconnection.js +106 -1
- package/core/core.js +39 -1
- package/package.json +1 -1
- package/protocol/command.js +45 -13
- package/scene/node.js +2 -1
- package/test/test_geometry_service_stream_idempotent.js +94 -0
- package/test/test_webrtc_connection_timeout.js +19 -0
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
package/protocol/command.js
CHANGED
|
@@ -21,7 +21,9 @@ const CommandPayloadType =
|
|
|
21
21
|
AssignNodePosePath:14,
|
|
22
22
|
SetupInputs:15,
|
|
23
23
|
PingForLatency:16,
|
|
24
|
-
|
|
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.
|
|
48
|
-
this.
|
|
49
|
-
this.
|
|
50
|
-
this.
|
|
51
|
-
this.
|
|
52
|
-
this.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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 = [];
|