@livedesk/hub 0.1.69 → 0.1.71
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
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@livedesk/hub",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "VuvoDesk local Hub API and browser frame bridge",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/server.js",
|
|
7
|
-
"files": [
|
|
8
|
-
"src/",
|
|
9
|
-
"README.md"
|
|
10
|
-
],
|
|
11
|
-
"scripts": {
|
|
12
|
-
"dev": "node src/server.js",
|
|
13
|
-
"start": "node src/server.js",
|
|
14
|
-
"check": "node --check src/server.js && node --check src/remote-hub.js && node --check src/remote-clipboard-contract.mjs && node --check src/console-direct.js",
|
|
15
|
-
"prepublishOnly": "node ../../scripts/vuvodesk-release-git-gate.mjs"
|
|
16
|
-
},
|
|
17
|
-
"dependencies": {
|
|
18
|
-
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
1
|
+
{
|
|
2
|
+
"name": "@livedesk/hub",
|
|
3
|
+
"version": "0.1.71",
|
|
4
|
+
"description": "VuvoDesk local Hub API and browser frame bridge",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/server.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "node src/server.js",
|
|
13
|
+
"start": "node src/server.js",
|
|
14
|
+
"check": "node --check src/server.js && node --check src/remote-hub.js && node --check src/remote-clipboard-contract.mjs && node --check src/console-direct.js",
|
|
15
|
+
"prepublishOnly": "node ../../scripts/vuvodesk-release-git-gate.mjs"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
19
19
|
"@livedesk/runtime-core": "0.1.9",
|
|
20
|
-
"@openai/codex-sdk": "0.145.0",
|
|
21
|
-
"cors": "^2.8.5",
|
|
22
|
-
"express": "^4.21.2",
|
|
23
|
-
"node-datachannel": "0.33.0",
|
|
24
|
-
"path-to-regexp": "0.1.13",
|
|
25
|
-
"ws": "^8.18.3"
|
|
26
|
-
},
|
|
27
|
-
"engines": {
|
|
28
|
-
"node": ">=20"
|
|
29
|
-
},
|
|
30
|
-
"publishConfig": {
|
|
31
|
-
"access": "public"
|
|
32
|
-
}
|
|
33
|
-
}
|
|
20
|
+
"@openai/codex-sdk": "0.145.0",
|
|
21
|
+
"cors": "^2.8.5",
|
|
22
|
+
"express": "^4.21.2",
|
|
23
|
+
"node-datachannel": "0.33.0",
|
|
24
|
+
"path-to-regexp": "0.1.13",
|
|
25
|
+
"ws": "^8.18.3"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const CONSOLE_MEDIA_ADMISSION = Object.freeze({
|
|
2
|
+
laneBufferedBytes: 512 * 1024,
|
|
3
|
+
peerBufferedBytes: 1024 * 1024,
|
|
4
|
+
bufferedAgeMs: 120,
|
|
5
|
+
maxStreams: 64,
|
|
6
|
+
maxMetadataBytes: 64 * 1024
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function h264Identity(packet) {
|
|
10
|
+
if (!Buffer.isBuffer(packet) || packet.length < 5) return null;
|
|
11
|
+
const size = packet.readUInt32BE(0);
|
|
12
|
+
if (size < 2 || size > CONSOLE_MEDIA_ADMISSION.maxMetadataBytes || 4 + size >= packet.length) return null;
|
|
13
|
+
let frame;
|
|
14
|
+
try { frame = JSON.parse(packet.toString('utf8', 4, 4 + size)); } catch { return null; }
|
|
15
|
+
if (frame?.frameMode !== 'mode3-h264-hw'
|
|
16
|
+
|| !['wall', 'control'].includes(frame.streamPurpose)
|
|
17
|
+
|| !Number.isSafeInteger(frame.captureGeneration) || frame.captureGeneration <= 0
|
|
18
|
+
|| !Number.isSafeInteger(frame.monitorIndex) || frame.monitorIndex < 0
|
|
19
|
+
|| !['deviceId', 'sessionId', 'streamId', 'commandId'].every(key => (
|
|
20
|
+
typeof frame[key] === 'string' && frame[key].length > 0 && frame[key].length <= 256
|
|
21
|
+
))) return null;
|
|
22
|
+
return {
|
|
23
|
+
stream: JSON.stringify([frame.deviceId, frame.streamPurpose]),
|
|
24
|
+
owner: JSON.stringify([frame.sessionId, frame.streamId, frame.commandId, frame.captureGeneration, frame.monitorIndex]),
|
|
25
|
+
key: frame.isKeyFrame === true || frame.chunkType === 'key'
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// This is send admission, not authentication or presentation authority. Those
|
|
30
|
+
// exact binding gates still run at the Hub and browser. Retain no payload,
|
|
31
|
+
// timer, task, or outbound queue; every decision precedes wire fragmentation.
|
|
32
|
+
export function createConsoleFrameAdmission() {
|
|
33
|
+
const streams = new Map();
|
|
34
|
+
let bufferedSince = null;
|
|
35
|
+
let droppedFrames = 0;
|
|
36
|
+
let recoveredStreams = 0;
|
|
37
|
+
return {
|
|
38
|
+
admit(packet, { laneBufferedBytes, peerBufferedBytes, now }) {
|
|
39
|
+
const identity = h264Identity(packet);
|
|
40
|
+
if (!identity) return 'unmanaged';
|
|
41
|
+
let state = streams.get(identity.stream);
|
|
42
|
+
if (!state) {
|
|
43
|
+
if (streams.size >= CONSOLE_MEDIA_ADMISSION.maxStreams) return 'capacity';
|
|
44
|
+
state = { owner: identity.owner, awaitingKey: false };
|
|
45
|
+
streams.set(identity.stream, state);
|
|
46
|
+
} else if (state.owner !== identity.owner) {
|
|
47
|
+
state.owner = identity.owner;
|
|
48
|
+
state.awaitingKey = true;
|
|
49
|
+
}
|
|
50
|
+
if (laneBufferedBytes <= 0) bufferedSince = null;
|
|
51
|
+
else if (bufferedSince === null) bufferedSince = now;
|
|
52
|
+
const pressured = (laneBufferedBytes > 0 && (
|
|
53
|
+
laneBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.laneBufferedBytes
|
|
54
|
+
|| now - bufferedSince >= CONSOLE_MEDIA_ADMISSION.bufferedAgeMs
|
|
55
|
+
)) || (peerBufferedBytes > 0
|
|
56
|
+
&& peerBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.peerBufferedBytes);
|
|
57
|
+
if (pressured || (state.awaitingKey && !identity.key)) {
|
|
58
|
+
state.awaitingKey = true;
|
|
59
|
+
droppedFrames += 1;
|
|
60
|
+
return 'drop';
|
|
61
|
+
}
|
|
62
|
+
if (state.awaitingKey) recoveredStreams += 1;
|
|
63
|
+
state.awaitingKey = false;
|
|
64
|
+
return 'send';
|
|
65
|
+
},
|
|
66
|
+
inspect() {
|
|
67
|
+
return {
|
|
68
|
+
streamOwners: streams.size,
|
|
69
|
+
awaitingKeyStreams: [...streams.values()].filter(state => state.awaitingKey).length,
|
|
70
|
+
droppedFrames,
|
|
71
|
+
recoveredStreams,
|
|
72
|
+
retainedPayloadBytes: 0
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
clear() {
|
|
76
|
+
streams.clear();
|
|
77
|
+
bufferedSince = null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { CONSOLE_MEDIA_ADMISSION, createConsoleFrameAdmission } from './console-direct-frame-admission.mjs';
|
|
4
|
+
import { buildImmutableRemoteFramePacket } from './frame-packet-contract.mjs';
|
|
5
|
+
|
|
6
|
+
function packet({ key = false, device = 'a', generation = 1, monitor = 0, bytes = 1024 } = {}) {
|
|
7
|
+
return buildImmutableRemoteFramePacket({
|
|
8
|
+
deviceId: device, sessionId: 'session', streamId: 'stream', commandId: 'command',
|
|
9
|
+
captureGeneration: generation, monitorIndex: monitor, streamPurpose: 'wall',
|
|
10
|
+
frameMode: 'mode3-h264-hw', isKeyFrame: key
|
|
11
|
+
}, Buffer.alloc(bytes)).packet;
|
|
12
|
+
}
|
|
13
|
+
const empty = { laneBufferedBytes: 0, peerBufferedBytes: 0, now: 0 };
|
|
14
|
+
|
|
15
|
+
test('a small persistent backlog is bounded by age without a timer or retained frame', () => {
|
|
16
|
+
const gate = createConsoleFrameAdmission();
|
|
17
|
+
assert.equal(gate.admit(packet({ key: true }), empty), 'send');
|
|
18
|
+
const pending = { laneBufferedBytes: 1024, peerBufferedBytes: 1024, now: 1 };
|
|
19
|
+
assert.equal(gate.admit(packet(), pending), 'send');
|
|
20
|
+
assert.equal(gate.admit(packet(), { ...pending, now: 1 + CONSOLE_MEDIA_ADMISSION.bufferedAgeMs }), 'drop');
|
|
21
|
+
assert.equal(gate.admit(packet(), { ...empty, now: 200 }), 'drop');
|
|
22
|
+
assert.equal(gate.admit(packet({ key: true }), { ...empty, now: 201 }), 'send');
|
|
23
|
+
assert.equal(gate.inspect().retainedPayloadBytes, 0);
|
|
24
|
+
assert.equal(gate.inspect().recoveredStreams, 1);
|
|
25
|
+
gate.clear();
|
|
26
|
+
assert.equal(gate.inspect().streamOwners, 0);
|
|
27
|
+
assert.equal(gate.inspect().awaitingKeyStreams, 0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a pressured viewer cannot poison another viewer, device, or replacement binding', () => {
|
|
31
|
+
const phone = createConsoleFrameAdmission();
|
|
32
|
+
const desktop = createConsoleFrameAdmission();
|
|
33
|
+
const pressure = { ...empty, laneBufferedBytes: CONSOLE_MEDIA_ADMISSION.laneBufferedBytes, peerBufferedBytes: CONSOLE_MEDIA_ADMISSION.laneBufferedBytes };
|
|
34
|
+
assert.equal(phone.admit(packet(), pressure), 'drop');
|
|
35
|
+
assert.equal(desktop.admit(packet(), empty), 'send');
|
|
36
|
+
assert.equal(phone.admit(packet({ device: 'b', key: true }), empty), 'send');
|
|
37
|
+
assert.equal(phone.admit(packet(), empty), 'drop');
|
|
38
|
+
assert.equal(phone.admit(packet({ generation: 2, monitor: 1 }), empty), 'drop');
|
|
39
|
+
assert.equal(phone.admit(packet({ generation: 2, monitor: 1, key: true }), empty), 'send');
|
|
40
|
+
assert.equal(phone.inspect().awaitingKeyStreams, 0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('one complete key may exceed the soft watermark on an empty peer without admitting a second backlog', () => {
|
|
44
|
+
const gate = createConsoleFrameAdmission();
|
|
45
|
+
const key = packet({ key: true, bytes: 700 * 1024 });
|
|
46
|
+
assert.equal(gate.admit(key, empty), 'send');
|
|
47
|
+
assert.equal(gate.admit(key, { ...empty, laneBufferedBytes: key.length, peerBufferedBytes: key.length }), 'drop');
|
|
48
|
+
assert.equal(gate.admit(key, empty), 'send');
|
|
49
|
+
assert.equal(gate.admit(Buffer.from('legacy-binary'), empty), 'unmanaged');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a multiplexed lane has a fixed owner bound and releases it on close', () => {
|
|
53
|
+
const gate = createConsoleFrameAdmission();
|
|
54
|
+
for (let n = 0; n < CONSOLE_MEDIA_ADMISSION.maxStreams; n++) {
|
|
55
|
+
assert.equal(gate.admit(packet({ device: `device-${n}`, key: true }), empty), 'send');
|
|
56
|
+
}
|
|
57
|
+
assert.equal(gate.admit(packet({ device: 'overflow', key: true }), empty), 'capacity');
|
|
58
|
+
gate.clear();
|
|
59
|
+
assert.equal(gate.admit(packet({ device: 'replacement', key: true }), empty), 'send');
|
|
60
|
+
});
|
package/src/console-direct.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
encodeDirectConsoleWireMessage
|
|
10
10
|
} from '../../runtime-core/src/console-direct-wire.js';
|
|
11
11
|
import { workspaceRoleCanControl, workspaceRoleCanRequest } from './auth/workspace-access.js';
|
|
12
|
+
import { createConsoleFrameAdmission } from './console-direct-frame-admission.mjs';
|
|
12
13
|
|
|
13
14
|
const DEFAULT_SIGNAL_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
|
|
14
15
|
const DEFAULT_STUN_URLS = Object.freeze(['stun:stun.cloudflare.com:3478']);
|
|
@@ -87,6 +88,34 @@ function byteLength(value) {
|
|
|
87
88
|
return Buffer.byteLength(String(value || ''), 'utf8');
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function optionalPeerMetric(peer, method) {
|
|
92
|
+
try {
|
|
93
|
+
const value = peer[method]?.();
|
|
94
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
95
|
+
} catch { return null; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function candidateAddressScope(value) {
|
|
99
|
+
if (typeof value !== 'string' || !value || value === '?') return 'unknown';
|
|
100
|
+
if (/^(127\.|::1$)/i.test(value)) return 'loopback';
|
|
101
|
+
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|f[cd][0-9a-f]{2}:|fe80:)/i.test(value)) return 'private';
|
|
102
|
+
return 'public';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function inspectPeerPath(peer) {
|
|
106
|
+
try {
|
|
107
|
+
const pair = peer.getSelectedCandidatePair?.();
|
|
108
|
+
if (!pair?.local || !pair?.remote) return null;
|
|
109
|
+
return {
|
|
110
|
+
localType: String(pair.local.type || 'unknown').slice(0, 24),
|
|
111
|
+
remoteType: String(pair.remote.type || 'unknown').slice(0, 24),
|
|
112
|
+
localScope: candidateAddressScope(pair.local.address),
|
|
113
|
+
remoteScope: candidateAddressScope(pair.remote.address),
|
|
114
|
+
transport: String(pair.remote.transportType || 'unknown').slice(0, 24)
|
|
115
|
+
};
|
|
116
|
+
} catch { return null; }
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
function parseJson(value) {
|
|
91
120
|
try {
|
|
92
121
|
return JSON.parse(String(value || ''));
|
|
@@ -527,6 +556,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
527
556
|
disposePendingLogicalControl(owner, channelState.channelId);
|
|
528
557
|
disposeAssemblerTimer(channelState);
|
|
529
558
|
channelState.assembler.dispose();
|
|
559
|
+
channelState.frameAdmission?.clear();
|
|
530
560
|
closeLocalSocket(
|
|
531
561
|
owner,
|
|
532
562
|
channelState,
|
|
@@ -667,6 +697,18 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
667
697
|
|
|
668
698
|
function sendWire(owner, channelState, kind, value, maxMessageBytes, options = {}) {
|
|
669
699
|
if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
|
|
700
|
+
if (kind === 'binary' && channelState.frameAdmission && value.byteLength <= maxMessageBytes) {
|
|
701
|
+
const admission = channelState.frameAdmission.admit(value, {
|
|
702
|
+
laneBufferedBytes: channelBufferedAmount(channelState),
|
|
703
|
+
peerBufferedBytes: ownerBufferedAmount(owner),
|
|
704
|
+
now: performance.now()
|
|
705
|
+
});
|
|
706
|
+
if (admission === 'drop') return true;
|
|
707
|
+
if (admission === 'capacity') {
|
|
708
|
+
closeLogicalChannel(owner, channelState, { code: 1009, reason: 'console-direct-frame-owner-capacity' });
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
670
712
|
let chunks;
|
|
671
713
|
try {
|
|
672
714
|
const messageId = channelState.nextSendMessageId >>> 0;
|
|
@@ -739,7 +781,10 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
739
781
|
// Node Buffer shape. Share the exact encoded bytes without copying or
|
|
740
782
|
// retaining a second application queue.
|
|
741
783
|
const nativeChunk = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
742
|
-
|
|
784
|
+
// libdatachannel returns false when it accepted the chunk into its
|
|
785
|
+
// bounded send buffer. Only an exception means this send failed.
|
|
786
|
+
// Retrying duplicates a fragment; closing here truncates the frame.
|
|
787
|
+
channelState.channel.sendMessageBinary(nativeChunk);
|
|
743
788
|
} catch (error) {
|
|
744
789
|
dataChannelSendFailures += 1;
|
|
745
790
|
lastDataChannelSendFailure = Object.freeze({
|
|
@@ -970,7 +1015,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
970
1015
|
socket.on('message', (data, isBinary) => {
|
|
971
1016
|
if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState || channelState.localSocket !== socket) return;
|
|
972
1017
|
const kind = isBinary ? 'binary' : 'text';
|
|
973
|
-
const value = isBinary ?
|
|
1018
|
+
const value = isBinary ? (Buffer.isBuffer(data) ? data : Buffer.from(data)) : String(data);
|
|
974
1019
|
if (!sendWire(owner, channelState, kind, value, channelState.maxMessageBytes)) {
|
|
975
1020
|
closeLogicalChannel(owner, channelState, {
|
|
976
1021
|
code: 1013,
|
|
@@ -1179,6 +1224,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1179
1224
|
nextSendMessageId: 1,
|
|
1180
1225
|
openRequested: false,
|
|
1181
1226
|
localSocket: null,
|
|
1227
|
+
frameAdmission: identity.purpose === 'frame' ? createConsoleFrameAdmission() : null,
|
|
1182
1228
|
closed: false
|
|
1183
1229
|
};
|
|
1184
1230
|
owner.channels.set(identity.channelId, channelState);
|
|
@@ -1935,6 +1981,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1935
1981
|
};
|
|
1936
1982
|
|
|
1937
1983
|
const inspect = () => {
|
|
1984
|
+
const peerDiagnostics = [];
|
|
1938
1985
|
let logicalWebSocketChannels = 0;
|
|
1939
1986
|
let localWebSocketChannels = 0;
|
|
1940
1987
|
let pendingHttpRequests = 0;
|
|
@@ -1961,6 +2008,30 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1961
2008
|
}
|
|
1962
2009
|
retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
|
|
1963
2010
|
bufferedSendBytes += ownerBufferedAmount(owner);
|
|
2011
|
+
peerDiagnostics.push({
|
|
2012
|
+
consoleId: owner.consoleId,
|
|
2013
|
+
connectionId: owner.connectionId,
|
|
2014
|
+
generation: owner.generation,
|
|
2015
|
+
peerState: owner.peerState,
|
|
2016
|
+
iceState: owner.iceState,
|
|
2017
|
+
rttMs: optionalPeerMetric(owner.peer, 'rtt'),
|
|
2018
|
+
bytesSent: optionalPeerMetric(owner.peer, 'bytesSent'),
|
|
2019
|
+
bytesReceived: optionalPeerMetric(owner.peer, 'bytesReceived'),
|
|
2020
|
+
selectedPath: inspectPeerPath(owner.peer),
|
|
2021
|
+
bufferedSendBytes: (() => {
|
|
2022
|
+
const values = [owner.control, ...owner.channels.values()]
|
|
2023
|
+
.filter(lane => lane && !lane.closed)
|
|
2024
|
+
.map(lane => optionalPeerMetric(lane.channel, 'bufferedAmount'));
|
|
2025
|
+
return values.some(value => value === null) ? null : values.reduce((sum, value) => sum + value, 0);
|
|
2026
|
+
})(),
|
|
2027
|
+
pendingHttpRequests: owner.pendingHttp.size,
|
|
2028
|
+
lanes: [...owner.channels.values()].map(lane => ({
|
|
2029
|
+
channelId: lane.channelId,
|
|
2030
|
+
purpose: lane.purpose,
|
|
2031
|
+
bufferedBytes: optionalPeerMetric(lane.channel, 'bufferedAmount'),
|
|
2032
|
+
frameAdmission: lane.frameAdmission?.inspect() ?? null
|
|
2033
|
+
}))
|
|
2034
|
+
});
|
|
1964
2035
|
}
|
|
1965
2036
|
const reconnectTimerActive = Boolean(retryTimer);
|
|
1966
2037
|
const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
|
|
@@ -1979,6 +2050,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1979
2050
|
pendingLogicalControlRequests,
|
|
1980
2051
|
retainedAssemblyBytes,
|
|
1981
2052
|
bufferedSendBytes,
|
|
2053
|
+
peerDiagnostics,
|
|
1982
2054
|
iceConnectTimers,
|
|
1983
2055
|
peerDisconnectTimers,
|
|
1984
2056
|
assemblyDeadlineTimers,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from '../../runtime-core/src/console-direct-wire.js';
|
|
10
10
|
import { consoleDirectContract, createHubConsoleDirect } from './console-direct.js';
|
|
11
11
|
import { workspaceRoleCanRequest } from './auth/workspace-access.js';
|
|
12
|
+
import { buildImmutableRemoteFramePacket } from './frame-packet-contract.mjs';
|
|
12
13
|
|
|
13
14
|
assert.equal(consoleDirectContract.maxDefaultRetryDelayMs, 20_000);
|
|
14
15
|
assert.equal(consoleDirectContract.peerDisconnectedTimeoutMs, 5_000);
|
|
@@ -293,6 +294,64 @@ function offer(signal, connectionId = CONNECTION_ONE, sdp = 'v=0\r\na=fake-offer
|
|
|
293
294
|
return FakePeerConnection.instances.at(-1);
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
test('native buffered sends complete each media message without closing input or control', async () => {
|
|
298
|
+
const { direct, signal } = await connectedDirect();
|
|
299
|
+
try {
|
|
300
|
+
const peer = offer(signal);
|
|
301
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
302
|
+
const nativeSend = FakeDataChannel.prototype.sendMessageBinary;
|
|
303
|
+
const acceptBuffered = function (value) {
|
|
304
|
+
nativeSend.call(this, value);
|
|
305
|
+
this.buffered += value.byteLength;
|
|
306
|
+
return false; // libdatachannel accepted and buffered this exact chunk.
|
|
307
|
+
};
|
|
308
|
+
control.sendMessageBinary = acceptBuffered;
|
|
309
|
+
peer.emitDataChannel(control);
|
|
310
|
+
control.open();
|
|
311
|
+
assert.equal(peer.closed, false, 'buffered direct-ready is not a send failure');
|
|
312
|
+
assert.equal(JSON.parse(decodeWireMessages(control.sent)[0].data).type, 'direct-ready');
|
|
313
|
+
const input = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID_TWO}:input`);
|
|
314
|
+
peer.emitDataChannel(input);
|
|
315
|
+
input.open();
|
|
316
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
|
|
317
|
+
lane.sendMessageBinary = acceptBuffered;
|
|
318
|
+
peer.emitDataChannel(lane);
|
|
319
|
+
lane.open();
|
|
320
|
+
sendWire(control, {
|
|
321
|
+
type: 'ws-open', connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH,
|
|
322
|
+
channelId: CHANNEL_ID, purpose: 'frame', path: '/api/remote/frames/ws'
|
|
323
|
+
});
|
|
324
|
+
const local = FakeLocalSocket.instances[0];
|
|
325
|
+
local.open();
|
|
326
|
+
for (let frame = 0; frame < 30; frame += 1) {
|
|
327
|
+
lane.buffered = 0;
|
|
328
|
+
local.receive(Buffer.alloc(74 * 1024, frame), true);
|
|
329
|
+
assert.equal(lane.closed, false, `transient congestion must preserve frame ${frame}`);
|
|
330
|
+
}
|
|
331
|
+
const received = decodeWireMessages(lane.sent);
|
|
332
|
+
assert.equal(received.length, 30);
|
|
333
|
+
for (let frame = 0; frame < 30; frame += 1) {
|
|
334
|
+
assert.deepEqual(Buffer.from(received[frame].data), Buffer.alloc(74 * 1024, frame));
|
|
335
|
+
}
|
|
336
|
+
assert.equal(local.readyState, FakeSocket.OPEN);
|
|
337
|
+
assert.equal(input.closed, false);
|
|
338
|
+
assert.equal(control.closed, false);
|
|
339
|
+
assert.equal(peer.closed, false);
|
|
340
|
+
assert.equal(direct.inspect().dataChannelSendFailures, 0);
|
|
341
|
+
assert.equal(direct.inspect().dataChannelBackpressureCloses, 0);
|
|
342
|
+
lane.sendMessageBinary = () => { throw new Error('native-send-failed'); };
|
|
343
|
+
local.receive(Buffer.alloc(10), true);
|
|
344
|
+
assert.equal(lane.closed, true, 'an actual native exception still retires the exact lane');
|
|
345
|
+
assert.equal(control.closed, false);
|
|
346
|
+
assert.equal(input.closed, false);
|
|
347
|
+
assert.equal(direct.inspect().dataChannelSendFailures, 1);
|
|
348
|
+
} finally {
|
|
349
|
+
direct.close();
|
|
350
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
351
|
+
assert.equal(direct.inspect().retainedAssemblyBytes, 0);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
296
355
|
test('STUN-only peer answers and rejects stale owner callbacks after replacement', async () => {
|
|
297
356
|
const { direct, signal } = await connectedDirect();
|
|
298
357
|
const firstPeer = offer(signal, CONNECTION_ONE);
|
|
@@ -1278,6 +1337,64 @@ test('media pressure retires only its stale logical lane while preserving the co
|
|
|
1278
1337
|
direct.close();
|
|
1279
1338
|
});
|
|
1280
1339
|
|
|
1340
|
+
test('a pressured H.264 viewer drops whole dependent frames and resumes on a key without reconnecting', async () => {
|
|
1341
|
+
const { direct, signal } = await connectedDirect();
|
|
1342
|
+
const peer = offer(signal);
|
|
1343
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
1344
|
+
peer.emitDataChannel(control);
|
|
1345
|
+
control.open();
|
|
1346
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
|
|
1347
|
+
peer.emitDataChannel(lane);
|
|
1348
|
+
lane.open();
|
|
1349
|
+
sendWire(control, {
|
|
1350
|
+
type: 'ws-open', connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH,
|
|
1351
|
+
channelId: CHANNEL_ID, purpose: 'frame', path: '/api/remote/frames/ws'
|
|
1352
|
+
});
|
|
1353
|
+
const local = FakeLocalSocket.instances[0];
|
|
1354
|
+
local.open();
|
|
1355
|
+
const frame = (sequence, key, generation = 1) => buildImmutableRemoteFramePacket({
|
|
1356
|
+
deviceId: 'device-a', sessionId: 'session-a', streamId: 'wall-a', commandId: 'command-a',
|
|
1357
|
+
captureGeneration: generation, monitorIndex: 0, streamPurpose: 'wall',
|
|
1358
|
+
frameMode: 'mode3-h264-hw', chunkType: key ? 'key' : 'delta',
|
|
1359
|
+
isKeyFrame: key, streamFrameSeq: sequence
|
|
1360
|
+
}, Buffer.alloc(32 * 1024)).packet;
|
|
1361
|
+
local.receive(frame(1, true));
|
|
1362
|
+
const initialChunks = lane.sent.length;
|
|
1363
|
+
lane.buffered = consoleDirectContract.controlHeadroomBufferedBytes;
|
|
1364
|
+
local.receive(frame(2, false));
|
|
1365
|
+
assert.equal(lane.closed, false, 'congestion must not create another logical handshake or key-frame burst');
|
|
1366
|
+
assert.equal(lane.sent.length, initialChunks, 'no fragment of a dropped frame enters SCTP');
|
|
1367
|
+
lane.buffered = 0;
|
|
1368
|
+
local.receive(frame(3, false));
|
|
1369
|
+
assert.equal(lane.sent.length, initialChunks, 'a dependent delta cannot follow a discarded delta');
|
|
1370
|
+
local.receive(frame(4, true));
|
|
1371
|
+
assert.ok(lane.sent.length > initialChunks);
|
|
1372
|
+
const recoveredChunks = lane.sent.length;
|
|
1373
|
+
local.receive(frame(5, false));
|
|
1374
|
+
assert.ok(lane.sent.length > recoveredChunks);
|
|
1375
|
+
assert.equal(control.closed, false);
|
|
1376
|
+
assert.equal(peer.closed, false);
|
|
1377
|
+
assert.equal(direct.inspect().dataChannelBackpressureCloses, 0);
|
|
1378
|
+
const diagnostics = direct.inspect().peerDiagnostics[0];
|
|
1379
|
+
assert.equal(diagnostics.rttMs, null, 'missing native RTT is unknown');
|
|
1380
|
+
assert.equal(diagnostics.bytesSent, null, 'missing native byte counters are unknown');
|
|
1381
|
+
assert.equal(diagnostics.lanes[0].frameAdmission.droppedFrames, 2);
|
|
1382
|
+
assert.equal(diagnostics.lanes[0].frameAdmission.recoveredStreams, 1);
|
|
1383
|
+
peer.rtt = () => 12;
|
|
1384
|
+
peer.bytesSent = () => 1234;
|
|
1385
|
+
peer.selectedPair = {
|
|
1386
|
+
local: { type: 'host', address: '192.168.0.4', candidate: 'must-not-leak' },
|
|
1387
|
+
remote: { type: 'host', address: '192.168.0.8', transportType: 'Udp' }
|
|
1388
|
+
};
|
|
1389
|
+
const measured = direct.inspect().peerDiagnostics[0];
|
|
1390
|
+
assert.equal(measured.rttMs, 12);
|
|
1391
|
+
assert.equal(measured.bytesSent, 1234);
|
|
1392
|
+
assert.equal(measured.selectedPath.remoteScope, 'private');
|
|
1393
|
+
assert.doesNotMatch(JSON.stringify(measured), /192\.168\.|must-not-leak/);
|
|
1394
|
+
direct.close();
|
|
1395
|
+
assert.deepEqual(direct.inspect().peerDiagnostics, []);
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1281
1398
|
test('a reliable control response drops stale media backlog without retiring the peer', async () => {
|
|
1282
1399
|
const { direct, signal } = await connectedDirect({
|
|
1283
1400
|
fetchImpl: async () => new Response('{"online":true}', {
|
|
@@ -1449,7 +1566,7 @@ test('server session refresh preserves peers while logout uses the revoking clos
|
|
|
1449
1566
|
assert.doesNotMatch(logoutBlock, /hubConsoleDirect\?\.refresh\(\)/);
|
|
1450
1567
|
});
|
|
1451
1568
|
|
|
1452
|
-
test('native node-datachannel
|
|
1569
|
+
test('native node-datachannel buffered sends deliver the complete fragmented wire over loopback', { timeout: 8_000 }, async t => {
|
|
1453
1570
|
let offerer = null;
|
|
1454
1571
|
let answerer = null;
|
|
1455
1572
|
let offerChannel = null;
|
|
@@ -1458,6 +1575,7 @@ test('native node-datachannel peers carry the fragmented control wire over loopb
|
|
|
1458
1575
|
let openTimer = null;
|
|
1459
1576
|
let messageTimer = null;
|
|
1460
1577
|
try {
|
|
1578
|
+
nodeDataChannel.setSctpSettings({ sendBufferSize: 64 * 1024 });
|
|
1461
1579
|
offerer = new nodeDataChannel.PeerConnection('console-direct-native-offerer', { iceServers: [] });
|
|
1462
1580
|
answerer = new nodeDataChannel.PeerConnection('console-direct-native-answerer', { iceServers: [] });
|
|
1463
1581
|
offerer.onLocalDescription((sdp, type) => {
|
|
@@ -1494,17 +1612,23 @@ test('native node-datachannel peers carry the fragmented control wire over loopb
|
|
|
1494
1612
|
clearTimeout(openTimer);
|
|
1495
1613
|
openTimer = null;
|
|
1496
1614
|
const payload = JSON.stringify({ type: 'direct-ready', connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH });
|
|
1497
|
-
const
|
|
1615
|
+
const expected = payload.repeat(20_000);
|
|
1616
|
+
const chunks = encodeDirectConsoleWireMessage(expected, {
|
|
1498
1617
|
messageId: 77,
|
|
1499
1618
|
kind: 'control'
|
|
1500
1619
|
});
|
|
1501
1620
|
assert.ok(chunks.length > 1);
|
|
1502
|
-
|
|
1621
|
+
let bufferedSends = 0;
|
|
1622
|
+
for (const chunk of chunks) {
|
|
1623
|
+
if (offerChannel.sendMessageBinary(Buffer.from(chunk)) === false) bufferedSends += 1;
|
|
1624
|
+
}
|
|
1625
|
+
assert.ok(bufferedSends > 0, 'the native congestion path must actually be exercised');
|
|
1503
1626
|
const message = await received;
|
|
1504
1627
|
clearTimeout(messageTimer);
|
|
1505
1628
|
messageTimer = null;
|
|
1506
1629
|
assert.equal(message.kind, 'control');
|
|
1507
|
-
assert.equal(message.data,
|
|
1630
|
+
assert.equal(message.data, expected);
|
|
1631
|
+
t.diagnostic(`native buffered sends=${bufferedSends}; complete bytes=${message.byteLength}; chunks=${chunks.length}`);
|
|
1508
1632
|
} finally {
|
|
1509
1633
|
if (openTimer) clearTimeout(openTimer);
|
|
1510
1634
|
if (messageTimer) clearTimeout(messageTimer);
|
|
@@ -1515,5 +1639,6 @@ test('native node-datachannel peers carry the fragmented control wire over loopb
|
|
|
1515
1639
|
try { answerer?.close(); } catch {}
|
|
1516
1640
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1517
1641
|
nodeDataChannel.cleanup();
|
|
1642
|
+
nodeDataChannel.setSctpSettings({});
|
|
1518
1643
|
}
|
|
1519
1644
|
});
|
|
@@ -479,7 +479,7 @@ function parseBorrowLifecycleFramePacket(data) {
|
|
|
479
479
|
}
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
-
test('
|
|
482
|
+
test('Hub and phone Wall borrowers follow Control without stopping or retaining each other', {
|
|
483
483
|
timeout: 20_000
|
|
484
484
|
}, async () => {
|
|
485
485
|
const {
|
|
@@ -563,7 +563,7 @@ test('the browser Wall borrower follows exact Control key frames without owning
|
|
|
563
563
|
).toString('base64')
|
|
564
564
|
});
|
|
565
565
|
|
|
566
|
-
const connectBorrower = async label => {
|
|
566
|
+
const connectBorrower = async (label, options = {}) => {
|
|
567
567
|
const socket = new WebSocket(
|
|
568
568
|
`ws://127.0.0.1:${httpPort}/api/remote/frames/ws?devices=${encodeURIComponent(deviceId)}`,
|
|
569
569
|
{ headers: { Origin: baseUrl }, perMessageDeflate: false }
|
|
@@ -603,7 +603,8 @@ test('the browser Wall borrower follows exact Control key frames without owning
|
|
|
603
603
|
maxWidth: 960,
|
|
604
604
|
maxHeight: 540,
|
|
605
605
|
quality: 55,
|
|
606
|
-
monitorIndex: 0
|
|
606
|
+
monitorIndex: 0,
|
|
607
|
+
...options
|
|
607
608
|
}));
|
|
608
609
|
return { socket, observed, subscribeJsonIndex };
|
|
609
610
|
};
|
|
@@ -799,6 +800,20 @@ test('the browser Wall borrower follows exact Control key frames without owning
|
|
|
799
800
|
assert.equal(firstFrame.streamPurpose, 'control');
|
|
800
801
|
assert.equal(firstFrame.monitorIndex, 0);
|
|
801
802
|
|
|
803
|
+
const hubBorrower = await connectBorrower('desktop Hub', {
|
|
804
|
+
fps: 30, maxWidth: 1920, maxHeight: 1080
|
|
805
|
+
});
|
|
806
|
+
await waitForBorrowStart(hubBorrower.observed, initialControl.commandId,
|
|
807
|
+
hubBorrower.subscribeJsonIndex, 'desktop Hub borrows the same Control owner');
|
|
808
|
+
sendControlFrame(initialControl, 4, true);
|
|
809
|
+
await waitForExactReady(hubBorrower.observed, initialControl, 0, 'desktop Hub key readiness');
|
|
810
|
+
const otherMonitor = await connectBorrower('other monitor', { monitorIndex: 1 });
|
|
811
|
+
await waitForBorrowLifecycle(() => otherMonitor.observed.json.some(message =>
|
|
812
|
+
JSON.stringify(message).includes('CONTROL_CAPTURE_OWNS_DEVICE')),
|
|
813
|
+
'different monitor reports ownership conflict');
|
|
814
|
+
assert.equal(otherMonitor.observed.frames.length, 0, 'never substitute a different monitor');
|
|
815
|
+
otherMonitor.socket.close();
|
|
816
|
+
|
|
802
817
|
const firstStopCommandIndex = agentMessages.length;
|
|
803
818
|
const stopInitialPromise = postJson(
|
|
804
819
|
`/api/remote/devices/${encodeURIComponent(deviceId)}/live/stop`,
|
|
@@ -855,6 +870,8 @@ test('the browser Wall borrower follows exact Control key frames without owning
|
|
|
855
870
|
const replacementFrameIndex = firstBorrower.observed.frames.length;
|
|
856
871
|
sendOpen(replacementControl);
|
|
857
872
|
sendControlFrame(replacementControl, 1, true);
|
|
873
|
+
await waitForBorrowStart(hubBorrower.observed, replacementControl.commandId,
|
|
874
|
+
0, 'desktop Hub follows the replacement generation');
|
|
858
875
|
const replacementBorrowStart = await waitForBorrowStart(
|
|
859
876
|
firstBorrower.observed,
|
|
860
877
|
replacementControl.commandId,
|
|
@@ -878,6 +895,8 @@ test('the browser Wall borrower follows exact Control key frames without owning
|
|
|
878
895
|
await new Promise(resolveWait => setTimeout(resolveWait, 60));
|
|
879
896
|
assert.equal(firstBorrower.observed.frames.length, replacementFrameIndex);
|
|
880
897
|
sendControlFrame(replacementControl, 3, true);
|
|
898
|
+
await waitForExactReady(hubBorrower.observed, replacementControl, 0,
|
|
899
|
+
'desktop Hub replacement key readiness');
|
|
881
900
|
await waitForExactReady(
|
|
882
901
|
firstBorrower.observed,
|
|
883
902
|
replacementControl,
|
|
@@ -915,6 +934,10 @@ test('the browser Wall borrower follows exact Control key frames without owning
|
|
|
915
934
|
stopCountBeforeBorrowerClose,
|
|
916
935
|
'Closing a read-only borrower must never stop the native Control owner.'
|
|
917
936
|
);
|
|
937
|
+
sendControlFrame(replacementControl, 4, true);
|
|
938
|
+
await waitForBorrowLifecycle(() => hubBorrower.observed.frames.some(frame =>
|
|
939
|
+
frame.commandId === replacementControl.commandId && frame.frameSeq === 4),
|
|
940
|
+
'desktop Hub keeps receiving after phone borrower closes');
|
|
918
941
|
|
|
919
942
|
const fallbackBorrower = await connectBorrower('fallback borrower');
|
|
920
943
|
await waitForBorrowStart(
|
|
@@ -924,7 +947,7 @@ test('the browser Wall borrower follows exact Control key frames without owning
|
|
|
924
947
|
'fallback borrower Control binding'
|
|
925
948
|
);
|
|
926
949
|
const fallbackReadyIndex = fallbackBorrower.observed.json.length;
|
|
927
|
-
sendControlFrame(replacementControl,
|
|
950
|
+
sendControlFrame(replacementControl, 5, true);
|
|
928
951
|
await waitForExactReady(
|
|
929
952
|
fallbackBorrower.observed,
|
|
930
953
|
replacementControl,
|
|
@@ -984,6 +1007,11 @@ test('the browser Wall borrower follows exact Control key frames without owning
|
|
|
984
1007
|
'ordinary Wall browser binding after confirmed Control stop'
|
|
985
1008
|
);
|
|
986
1009
|
assert.equal(fallbackWallStart.started[0].presentationPurpose, '');
|
|
1010
|
+
await waitForBorrowLifecycle(() => hubBorrower.observed.json.some(message =>
|
|
1011
|
+
message.type === 'RemoteFrameLiveAutoStart'
|
|
1012
|
+
&& message.started?.some(item => item.commandId === fallbackWallCommand.message.commandId
|
|
1013
|
+
&& item.streamPurpose === 'wall' && item.readOnlyControlBorrow !== true)),
|
|
1014
|
+
'desktop Hub and phone both restore the ordinary Wall owner');
|
|
987
1015
|
} catch (error) {
|
|
988
1016
|
error.message += hubOutput.length > 0
|
|
989
1017
|
? `\nIsolated Hub output:\n${hubOutput.join('\n')}`
|