@livedesk/hub 0.1.59 → 0.1.61
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 +1 -1
- package/src/control-presentation-borrow-contract.test.mjs +999 -0
- package/src/live-stream-monitor-contract.js +195 -3
- package/src/remote-hub.js +108 -56
- package/src/server.js +183 -68
- package/src/settings/settings-schema.js +25 -6
- package/src/settings/settings-store.js +9 -8
- package/src/wall-source-restart-contract.test.mjs +48 -0
- package/src/wall-source-restart-runtime.test.mjs +146 -0
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import WebSocket from 'ws';
|
|
8
|
+
|
|
9
|
+
import { createRemoteHub } from './remote-hub.js';
|
|
10
|
+
import {
|
|
11
|
+
READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS,
|
|
12
|
+
READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS,
|
|
13
|
+
createReadOnlyControlPresentationReconcileCoordinator,
|
|
14
|
+
resolveReadOnlyControlPresentationBorrow
|
|
15
|
+
} from './live-stream-monitor-contract.js';
|
|
16
|
+
|
|
17
|
+
const NOW_EPOCH_MS = Date.parse('2026-08-29T12:00:00.000Z');
|
|
18
|
+
|
|
19
|
+
function createHealthyDevice() {
|
|
20
|
+
return {
|
|
21
|
+
sessionId: 'session-current',
|
|
22
|
+
activeLiveStream: {
|
|
23
|
+
active: true,
|
|
24
|
+
open: true,
|
|
25
|
+
stopPending: false,
|
|
26
|
+
stopCommandId: '',
|
|
27
|
+
pendingDescriptor: null,
|
|
28
|
+
sessionId: 'session-current',
|
|
29
|
+
streamId: 'control-stream-current',
|
|
30
|
+
commandId: 'control-command-current',
|
|
31
|
+
captureGeneration: 7,
|
|
32
|
+
monitorIndex: 1,
|
|
33
|
+
streamPurpose: 'control',
|
|
34
|
+
mode: 'mode3-h264-hw',
|
|
35
|
+
frameMode: 'mode3-h264-hw',
|
|
36
|
+
readyFrameReceived: true,
|
|
37
|
+
framesReceived: 120,
|
|
38
|
+
lastFrameAt: new Date(NOW_EPOCH_MS - 250).toISOString(),
|
|
39
|
+
fps: 30,
|
|
40
|
+
maxWidth: 1920,
|
|
41
|
+
maxHeight: 1080,
|
|
42
|
+
quality: 72,
|
|
43
|
+
latestFrame: {
|
|
44
|
+
currentGenerationVerified: true,
|
|
45
|
+
sessionId: 'session-current',
|
|
46
|
+
streamId: 'control-stream-current',
|
|
47
|
+
commandId: 'control-command-current',
|
|
48
|
+
captureGeneration: 7,
|
|
49
|
+
monitorIndex: 1,
|
|
50
|
+
streamPurpose: 'control'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createWallOptions(overrides = {}) {
|
|
57
|
+
return {
|
|
58
|
+
allowReadOnlyControlBorrow: true,
|
|
59
|
+
streamPurpose: 'wall',
|
|
60
|
+
frameMode: 'mode3-h264-hw',
|
|
61
|
+
fps: 12,
|
|
62
|
+
maxWidth: 960,
|
|
63
|
+
maxHeight: 540,
|
|
64
|
+
quality: 55,
|
|
65
|
+
...overrides
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolve(device = createHealthyDevice(), liveOptions = createWallOptions(), monitorIndex = 1) {
|
|
70
|
+
return resolveReadOnlyControlPresentationBorrow({
|
|
71
|
+
device,
|
|
72
|
+
liveOptions,
|
|
73
|
+
monitorIndex,
|
|
74
|
+
nowEpochMs: NOW_EPOCH_MS
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test('a Wall observer borrows only the exact healthy Control H.264 binding', () => {
|
|
79
|
+
assert.deepEqual(resolve(), {
|
|
80
|
+
ok: true,
|
|
81
|
+
commandId: 'control-command-current',
|
|
82
|
+
sessionId: 'session-current',
|
|
83
|
+
streamId: 'control-stream-current',
|
|
84
|
+
streamPurpose: 'control',
|
|
85
|
+
fps: 30,
|
|
86
|
+
mode: 'mode3-h264-hw',
|
|
87
|
+
frameMode: 'mode3-h264-hw',
|
|
88
|
+
monitorIndex: 1,
|
|
89
|
+
captureGeneration: 7,
|
|
90
|
+
ready: true,
|
|
91
|
+
reused: true,
|
|
92
|
+
readOnlyControlBorrow: true,
|
|
93
|
+
presentationPurpose: 'wall',
|
|
94
|
+
requestedProfile: {
|
|
95
|
+
fps: 12,
|
|
96
|
+
maxWidth: 960,
|
|
97
|
+
maxHeight: 540,
|
|
98
|
+
quality: 55
|
|
99
|
+
},
|
|
100
|
+
effectiveProfile: {
|
|
101
|
+
fps: 30,
|
|
102
|
+
maxWidth: 1920,
|
|
103
|
+
maxHeight: 1080,
|
|
104
|
+
quality: 72
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('borrowing requires an explicit Wall observer request', () => {
|
|
110
|
+
assert.equal(resolve(createHealthyDevice(), createWallOptions({
|
|
111
|
+
allowReadOnlyControlBorrow: false
|
|
112
|
+
})), null);
|
|
113
|
+
assert.equal(resolve(createHealthyDevice(), createWallOptions({
|
|
114
|
+
streamPurpose: 'control'
|
|
115
|
+
})), null);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('borrowing rejects a different monitor and an idle Control frame', () => {
|
|
119
|
+
assert.equal(resolve(createHealthyDevice(), createWallOptions(), 0), null);
|
|
120
|
+
|
|
121
|
+
const staleDevice = createHealthyDevice();
|
|
122
|
+
staleDevice.activeLiveStream.lastFrameAt = new Date(
|
|
123
|
+
NOW_EPOCH_MS - READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS - 1
|
|
124
|
+
).toISOString();
|
|
125
|
+
assert.equal(resolve(staleDevice), null);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('borrowing rejects a stopping or replacement-pending Control stream', () => {
|
|
129
|
+
for (const activePatch of [
|
|
130
|
+
{ stopPending: true },
|
|
131
|
+
{ stopCommandId: 'stop-command-current' },
|
|
132
|
+
{ pendingDescriptor: { commandId: 'replacement-command' } }
|
|
133
|
+
]) {
|
|
134
|
+
const device = createHealthyDevice();
|
|
135
|
+
Object.assign(device.activeLiveStream, activePatch);
|
|
136
|
+
assert.equal(resolve(device), null);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('borrowing rejects non-Control purpose and non-hardware-H.264 modes', () => {
|
|
141
|
+
const wrongPurpose = createHealthyDevice();
|
|
142
|
+
wrongPurpose.activeLiveStream.streamPurpose = 'wall';
|
|
143
|
+
assert.equal(resolve(wrongPurpose), null);
|
|
144
|
+
|
|
145
|
+
const wrongActiveMode = createHealthyDevice();
|
|
146
|
+
wrongActiveMode.activeLiveStream.frameMode = 'mode2-jpeg';
|
|
147
|
+
wrongActiveMode.activeLiveStream.mode = 'mode2-jpeg';
|
|
148
|
+
assert.equal(resolve(wrongActiveMode), null);
|
|
149
|
+
|
|
150
|
+
assert.equal(resolve(createHealthyDevice(), createWallOptions({
|
|
151
|
+
frameMode: 'mode2-jpeg'
|
|
152
|
+
})), null);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('borrowing rejects a latest frame owned by another exact binding', () => {
|
|
156
|
+
for (const [field, value] of [
|
|
157
|
+
['sessionId', 'session-old'],
|
|
158
|
+
['streamId', 'control-stream-old'],
|
|
159
|
+
['commandId', 'control-command-old'],
|
|
160
|
+
['captureGeneration', 6],
|
|
161
|
+
['monitorIndex', 0],
|
|
162
|
+
['streamPurpose', 'wall']
|
|
163
|
+
]) {
|
|
164
|
+
const device = createHealthyDevice();
|
|
165
|
+
device.activeLiveStream.latestFrame[field] = value;
|
|
166
|
+
assert.equal(resolve(device), null, `latest frame ${field} mismatch must be rejected`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
function createFakeReconcileTimers() {
|
|
171
|
+
const owners = [];
|
|
172
|
+
return {
|
|
173
|
+
owners,
|
|
174
|
+
setTimeoutFn(callback, delayMs) {
|
|
175
|
+
const owner = {
|
|
176
|
+
callback,
|
|
177
|
+
delayMs,
|
|
178
|
+
cleared: false,
|
|
179
|
+
unrefCalls: 0,
|
|
180
|
+
unref() {
|
|
181
|
+
owner.unrefCalls += 1;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
owners.push(owner);
|
|
185
|
+
return owner;
|
|
186
|
+
},
|
|
187
|
+
clearTimeoutFn(owner) {
|
|
188
|
+
owner.cleared = true;
|
|
189
|
+
},
|
|
190
|
+
fire(owner) {
|
|
191
|
+
if (!owner.cleared) owner.callback();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
test('a replacement Control cancels the one deferred stop reconcile and Ready rebinds immediately', () => {
|
|
197
|
+
const timers = createFakeReconcileTimers();
|
|
198
|
+
const reconciledDeviceIds = [];
|
|
199
|
+
const coordinator = createReadOnlyControlPresentationReconcileCoordinator({
|
|
200
|
+
onReconcile: deviceId => reconciledDeviceIds.push(deviceId),
|
|
201
|
+
setTimeoutFn: timers.setTimeoutFn,
|
|
202
|
+
clearTimeoutFn: timers.clearTimeoutFn
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.equal(coordinator.scheduleAfterConfirmedStop(' mac-current '), true);
|
|
206
|
+
assert.equal(coordinator.scheduleAfterConfirmedStop('mac-current'), false);
|
|
207
|
+
assert.equal(coordinator.getPendingCount(), 1);
|
|
208
|
+
assert.equal(timers.owners.length, 1);
|
|
209
|
+
assert.equal(timers.owners[0].delayMs, READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS);
|
|
210
|
+
assert.equal(timers.owners[0].unrefCalls, 1);
|
|
211
|
+
|
|
212
|
+
assert.equal(coordinator.cancelForControlTransition('mac-current'), true);
|
|
213
|
+
assert.equal(coordinator.getPendingCount(), 0);
|
|
214
|
+
assert.equal(timers.owners[0].cleared, true);
|
|
215
|
+
timers.fire(timers.owners[0]);
|
|
216
|
+
assert.deepEqual(reconciledDeviceIds, []);
|
|
217
|
+
|
|
218
|
+
assert.equal(coordinator.scheduleAfterConfirmedStop('mac-current'), true);
|
|
219
|
+
assert.equal(coordinator.reconcileReadyControl('mac-current'), true);
|
|
220
|
+
assert.equal(timers.owners[1].cleared, true);
|
|
221
|
+
assert.equal(coordinator.getPendingCount(), 0);
|
|
222
|
+
assert.deepEqual(reconciledDeviceIds, ['mac-current']);
|
|
223
|
+
timers.fire(timers.owners[1]);
|
|
224
|
+
assert.deepEqual(reconciledDeviceIds, ['mac-current']);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('closing the reconcile coordinator releases every timer and rejects later work', () => {
|
|
228
|
+
const timers = createFakeReconcileTimers();
|
|
229
|
+
const reconciledDeviceIds = [];
|
|
230
|
+
const coordinator = createReadOnlyControlPresentationReconcileCoordinator({
|
|
231
|
+
onReconcile: deviceId => reconciledDeviceIds.push(deviceId),
|
|
232
|
+
setTimeoutFn: timers.setTimeoutFn,
|
|
233
|
+
clearTimeoutFn: timers.clearTimeoutFn
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
coordinator.scheduleAfterConfirmedStop('mac-a');
|
|
237
|
+
coordinator.scheduleAfterConfirmedStop('mac-b');
|
|
238
|
+
assert.equal(coordinator.getPendingCount(), 2);
|
|
239
|
+
coordinator.close();
|
|
240
|
+
assert.equal(coordinator.getPendingCount(), 0);
|
|
241
|
+
assert.deepEqual(timers.owners.map(owner => owner.cleared), [true, true]);
|
|
242
|
+
assert.equal(coordinator.scheduleAfterConfirmedStop('mac-c'), false);
|
|
243
|
+
assert.equal(coordinator.reconcileReadyControl('mac-c'), false);
|
|
244
|
+
for (const owner of timers.owners) timers.fire(owner);
|
|
245
|
+
assert.deepEqual(reconciledDeviceIds, []);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('the authoritative Hub borrows without stream.start and still enforces current policy', async () => {
|
|
249
|
+
let remoteAccessBlocked = false;
|
|
250
|
+
const hub = createRemoteHub({
|
|
251
|
+
env: {
|
|
252
|
+
...process.env,
|
|
253
|
+
LIVEDESK_REMOTE_HUB: '1',
|
|
254
|
+
REMOTE_HUB_HOST: '127.0.0.1',
|
|
255
|
+
REMOTE_HUB_PORT: '0'
|
|
256
|
+
},
|
|
257
|
+
pairToken: 'control-presentation-borrow-token',
|
|
258
|
+
getEffectiveDevicePolicy: () => remoteAccessBlocked
|
|
259
|
+
? { accessMode: 'block-remote-access' }
|
|
260
|
+
: {}
|
|
261
|
+
});
|
|
262
|
+
let socket;
|
|
263
|
+
try {
|
|
264
|
+
const status = await hub.start();
|
|
265
|
+
socket = net.createConnection({ host: '127.0.0.1', port: status.port });
|
|
266
|
+
await once(socket, 'connect');
|
|
267
|
+
|
|
268
|
+
const messages = [];
|
|
269
|
+
let incoming = '';
|
|
270
|
+
socket.on('data', chunk => {
|
|
271
|
+
incoming += chunk.toString('utf8');
|
|
272
|
+
let newline = incoming.indexOf('\n');
|
|
273
|
+
while (newline >= 0) {
|
|
274
|
+
const line = incoming.slice(0, newline).trim();
|
|
275
|
+
incoming = incoming.slice(newline + 1);
|
|
276
|
+
if (line) messages.push(JSON.parse(line));
|
|
277
|
+
newline = incoming.indexOf('\n');
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
const waitUntil = async predicate => {
|
|
281
|
+
const deadline = Date.now() + 2_000;
|
|
282
|
+
while (Date.now() < deadline) {
|
|
283
|
+
if (predicate()) return;
|
|
284
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 10));
|
|
285
|
+
}
|
|
286
|
+
throw new Error('timed out waiting for authoritative Control borrow fixture');
|
|
287
|
+
};
|
|
288
|
+
const send = message => socket.write(`${JSON.stringify(message)}\n`);
|
|
289
|
+
|
|
290
|
+
send({
|
|
291
|
+
type: 'hello',
|
|
292
|
+
pairToken: 'control-presentation-borrow-token',
|
|
293
|
+
deviceId: 'control-presentation-device',
|
|
294
|
+
deviceName: 'Control presentation fixture',
|
|
295
|
+
hostname: 'control-presentation-fixture',
|
|
296
|
+
platform: 'darwin',
|
|
297
|
+
arch: 'arm64',
|
|
298
|
+
protocol: 'mindexec.remote.agent',
|
|
299
|
+
protocolVersion: 2,
|
|
300
|
+
capabilities: { liveStream: true, frameProtocol: {}, frameModes: [] }
|
|
301
|
+
});
|
|
302
|
+
await waitUntil(() => messages.some(message => message.type === 'welcome'));
|
|
303
|
+
|
|
304
|
+
const controlOptions = {
|
|
305
|
+
streamId: 'control-control-presentation-device',
|
|
306
|
+
streamPurpose: 'control',
|
|
307
|
+
mode: 'mode3-h264-hw',
|
|
308
|
+
frameMode: 'mode3-h264-hw',
|
|
309
|
+
fps: 30,
|
|
310
|
+
maxWidth: 1920,
|
|
311
|
+
maxHeight: 1080,
|
|
312
|
+
quality: 72,
|
|
313
|
+
monitorIndex: 0
|
|
314
|
+
};
|
|
315
|
+
const control = hub.startLiveStream('control-presentation-device', {
|
|
316
|
+
...controlOptions,
|
|
317
|
+
commandId: 'control-presentation-command'
|
|
318
|
+
});
|
|
319
|
+
assert.equal(control.ok, true);
|
|
320
|
+
await waitUntil(() => messages.some(message => (
|
|
321
|
+
message.type === 'command'
|
|
322
|
+
&& message.command === 'stream.start'
|
|
323
|
+
&& message.commandId === control.commandId
|
|
324
|
+
)));
|
|
325
|
+
|
|
326
|
+
send({
|
|
327
|
+
type: 'stream.open',
|
|
328
|
+
streamId: control.streamId,
|
|
329
|
+
commandId: control.commandId,
|
|
330
|
+
captureGeneration: control.captureGeneration,
|
|
331
|
+
monitorIndex: 0,
|
|
332
|
+
streamPurpose: 'control',
|
|
333
|
+
mode: 'mode3-h264-hw',
|
|
334
|
+
frameMode: 'mode3-h264-hw',
|
|
335
|
+
codec: 'h264',
|
|
336
|
+
width: 1920,
|
|
337
|
+
height: 1080
|
|
338
|
+
});
|
|
339
|
+
send({
|
|
340
|
+
type: 'stream.frame',
|
|
341
|
+
streamId: control.streamId,
|
|
342
|
+
commandId: control.commandId,
|
|
343
|
+
captureGeneration: control.captureGeneration,
|
|
344
|
+
monitorIndex: 0,
|
|
345
|
+
streamPurpose: 'control',
|
|
346
|
+
frameSeq: 1,
|
|
347
|
+
frameMode: 'mode3-h264-hw',
|
|
348
|
+
codec: 'h264',
|
|
349
|
+
mimeType: 'video/h264',
|
|
350
|
+
isKeyFrame: true,
|
|
351
|
+
chunkType: 'key',
|
|
352
|
+
width: 1920,
|
|
353
|
+
height: 1080,
|
|
354
|
+
data: Buffer.from([
|
|
355
|
+
0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1f,
|
|
356
|
+
0x00, 0x00, 0x00, 0x01, 0x68, 0xce, 0x06, 0xe2,
|
|
357
|
+
0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84
|
|
358
|
+
]).toString('base64')
|
|
359
|
+
});
|
|
360
|
+
await waitUntil(() => hub.getDeviceLiveFrame('control-presentation-device')?.currentGenerationVerified === true);
|
|
361
|
+
|
|
362
|
+
const streamStartCount = messages.filter(message => message.command === 'stream.start').length;
|
|
363
|
+
const wallRequest = {
|
|
364
|
+
streamPurpose: 'wall',
|
|
365
|
+
mode: 'mode3-h264-hw',
|
|
366
|
+
frameMode: 'mode3-h264-hw',
|
|
367
|
+
fps: 5,
|
|
368
|
+
maxWidth: 960,
|
|
369
|
+
maxHeight: 540,
|
|
370
|
+
quality: 55,
|
|
371
|
+
monitorIndex: 0
|
|
372
|
+
};
|
|
373
|
+
const borrowed = hub.startLiveStream('control-presentation-device', {
|
|
374
|
+
...wallRequest,
|
|
375
|
+
allowReadOnlyControlBorrow: true,
|
|
376
|
+
reuseExisting: true
|
|
377
|
+
});
|
|
378
|
+
assert.equal(borrowed.ok, true);
|
|
379
|
+
assert.equal(borrowed.readOnlyControlBorrow, true);
|
|
380
|
+
assert.equal(borrowed.presentationPurpose, 'wall');
|
|
381
|
+
assert.equal(borrowed.streamPurpose, 'control');
|
|
382
|
+
assert.equal(borrowed.streamId, control.streamId);
|
|
383
|
+
assert.equal(borrowed.commandId, control.commandId);
|
|
384
|
+
assert.equal(borrowed.captureGeneration, control.captureGeneration);
|
|
385
|
+
assert.deepEqual(borrowed.effectiveProfile, {
|
|
386
|
+
fps: 30,
|
|
387
|
+
maxWidth: 1920,
|
|
388
|
+
maxHeight: 1080,
|
|
389
|
+
quality: 72
|
|
390
|
+
});
|
|
391
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 20));
|
|
392
|
+
assert.equal(messages.filter(message => message.command === 'stream.start').length, streamStartCount);
|
|
393
|
+
|
|
394
|
+
const unflagged = hub.startLiveStream('control-presentation-device', wallRequest);
|
|
395
|
+
assert.equal(unflagged.ok, false);
|
|
396
|
+
assert.equal(unflagged.error, 'CONTROL_CAPTURE_OWNS_DEVICE');
|
|
397
|
+
|
|
398
|
+
remoteAccessBlocked = true;
|
|
399
|
+
const blocked = hub.startLiveStream('control-presentation-device', {
|
|
400
|
+
...wallRequest,
|
|
401
|
+
allowReadOnlyControlBorrow: true
|
|
402
|
+
});
|
|
403
|
+
assert.equal(blocked.ok, false);
|
|
404
|
+
assert.equal(blocked.error, 'remote-access-blocked-by-settings');
|
|
405
|
+
assert.equal(messages.filter(message => message.command === 'stream.start').length, streamStartCount);
|
|
406
|
+
} finally {
|
|
407
|
+
socket?.destroy();
|
|
408
|
+
await hub.close();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
async function reserveBorrowLifecyclePort(excluded = new Set()) {
|
|
413
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
414
|
+
const server = net.createServer();
|
|
415
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
416
|
+
server.once('error', rejectListen);
|
|
417
|
+
server.listen(0, '127.0.0.1', resolveListen);
|
|
418
|
+
});
|
|
419
|
+
const port = Number(server.address()?.port || 0);
|
|
420
|
+
await new Promise(resolveClose => server.close(resolveClose));
|
|
421
|
+
if (port > 0 && !excluded.has(port)) return port;
|
|
422
|
+
}
|
|
423
|
+
throw new Error('Could not reserve an isolated Control borrow lifecycle port.');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function waitForBorrowLifecycle(read, label, timeoutMs = 4_000, intervalMs = 10) {
|
|
427
|
+
const deadline = Date.now() + timeoutMs;
|
|
428
|
+
let lastError = null;
|
|
429
|
+
while (Date.now() < deadline) {
|
|
430
|
+
try {
|
|
431
|
+
const value = await read();
|
|
432
|
+
if (value) return value;
|
|
433
|
+
} catch (error) {
|
|
434
|
+
lastError = error;
|
|
435
|
+
}
|
|
436
|
+
await new Promise(resolveWait => setTimeout(resolveWait, intervalMs));
|
|
437
|
+
}
|
|
438
|
+
throw new Error(
|
|
439
|
+
`Timed out waiting for ${label}.`
|
|
440
|
+
+ (lastError ? ` Last error: ${lastError.message}` : '')
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function collectBorrowLifecycleProcessOutput(child, limit = 100) {
|
|
445
|
+
const lines = [];
|
|
446
|
+
const collect = chunk => {
|
|
447
|
+
lines.push(...String(chunk || '').split(/\r?\n/).filter(Boolean));
|
|
448
|
+
while (lines.length > limit) lines.shift();
|
|
449
|
+
};
|
|
450
|
+
child.stdout?.on('data', collect);
|
|
451
|
+
child.stderr?.on('data', collect);
|
|
452
|
+
return lines;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function terminateBorrowLifecycleProcess(child) {
|
|
456
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
457
|
+
child.kill();
|
|
458
|
+
const exited = await Promise.race([
|
|
459
|
+
once(child, 'exit').then(() => true),
|
|
460
|
+
new Promise(resolveWait => setTimeout(() => resolveWait(false), 3_000))
|
|
461
|
+
]);
|
|
462
|
+
if (!exited && child.exitCode === null && child.signalCode === null) {
|
|
463
|
+
child.kill('SIGKILL');
|
|
464
|
+
await Promise.race([
|
|
465
|
+
once(child, 'exit'),
|
|
466
|
+
new Promise(resolveWait => setTimeout(resolveWait, 1_000))
|
|
467
|
+
]);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function parseBorrowLifecycleFramePacket(data) {
|
|
472
|
+
if (!Buffer.isBuffer(data) || data.length < 5) return null;
|
|
473
|
+
const metadataLength = data.readUInt32BE(0);
|
|
474
|
+
if (metadataLength <= 0 || metadataLength > data.length - 4) return null;
|
|
475
|
+
try {
|
|
476
|
+
return JSON.parse(data.subarray(4, 4 + metadataLength).toString('utf8'));
|
|
477
|
+
} catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
test('the browser Wall borrower follows exact Control key frames without owning native capture', {
|
|
483
|
+
timeout: 20_000
|
|
484
|
+
}, async () => {
|
|
485
|
+
const {
|
|
486
|
+
READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS
|
|
487
|
+
} = await import('./live-stream-monitor-contract.js');
|
|
488
|
+
const usedPorts = new Set();
|
|
489
|
+
const httpPort = await reserveBorrowLifecyclePort(usedPorts);
|
|
490
|
+
usedPorts.add(httpPort);
|
|
491
|
+
const remotePort = await reserveBorrowLifecyclePort(usedPorts);
|
|
492
|
+
const pairToken = `control-borrow-ws-${process.pid}-${Date.now().toString(36)}`;
|
|
493
|
+
const deviceId = `control-borrow-ws-device-${process.pid}`;
|
|
494
|
+
const baseUrl = `http://127.0.0.1:${httpPort}`;
|
|
495
|
+
const hubServerPath = fileURLToPath(new URL('./server.js', import.meta.url));
|
|
496
|
+
const rootDir = fileURLToPath(new URL('../../../', import.meta.url));
|
|
497
|
+
let hubProcess = null;
|
|
498
|
+
let agentSocket = null;
|
|
499
|
+
const browserSockets = [];
|
|
500
|
+
const agentMessages = [];
|
|
501
|
+
const stopAcknowledgedAtByCommandId = new Map();
|
|
502
|
+
let hubOutput = [];
|
|
503
|
+
|
|
504
|
+
const postJson = async (path, body) => {
|
|
505
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
506
|
+
method: 'POST',
|
|
507
|
+
headers: { 'Content-Type': 'application/json' },
|
|
508
|
+
body: JSON.stringify(body)
|
|
509
|
+
});
|
|
510
|
+
const payload = await response.json();
|
|
511
|
+
assert.equal(response.ok, true, `${path} HTTP request failed: ${JSON.stringify(payload)}`);
|
|
512
|
+
return payload;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const sendAgent = payload => {
|
|
516
|
+
assert.equal(agentSocket?.destroyed, false, 'The fake macOS Agent socket must still be open.');
|
|
517
|
+
agentSocket.write(`${JSON.stringify(payload)}\n`);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const waitForAgentCommand = (predicate, label, fromIndex = 0) => waitForBorrowLifecycle(
|
|
521
|
+
() => agentMessages.slice(fromIndex).find(record => (
|
|
522
|
+
record.message?.type === 'command' && predicate(record.message)
|
|
523
|
+
)),
|
|
524
|
+
label
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const sendOpen = binding => sendAgent({
|
|
528
|
+
type: 'stream.open',
|
|
529
|
+
streamId: binding.streamId,
|
|
530
|
+
commandId: binding.commandId,
|
|
531
|
+
captureGeneration: binding.captureGeneration,
|
|
532
|
+
monitorIndex: 0,
|
|
533
|
+
streamPurpose: 'control',
|
|
534
|
+
mode: 'mode3-h264-hw',
|
|
535
|
+
frameMode: 'mode3-h264-hw',
|
|
536
|
+
codec: 'h264',
|
|
537
|
+
width: 1920,
|
|
538
|
+
height: 1080
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const sendControlFrame = (binding, frameSeq, isKeyFrame) => sendAgent({
|
|
542
|
+
type: 'stream.frame',
|
|
543
|
+
streamId: binding.streamId,
|
|
544
|
+
commandId: binding.commandId,
|
|
545
|
+
captureGeneration: binding.captureGeneration,
|
|
546
|
+
monitorIndex: 0,
|
|
547
|
+
streamPurpose: 'control',
|
|
548
|
+
frameSeq,
|
|
549
|
+
frameMode: 'mode3-h264-hw',
|
|
550
|
+
codec: 'h264',
|
|
551
|
+
mimeType: 'video/h264',
|
|
552
|
+
isKeyFrame,
|
|
553
|
+
chunkType: isKeyFrame ? 'key' : 'delta',
|
|
554
|
+
width: 1920,
|
|
555
|
+
height: 1080,
|
|
556
|
+
data: Buffer.from(isKeyFrame
|
|
557
|
+
? [
|
|
558
|
+
0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1f,
|
|
559
|
+
0x00, 0x00, 0x00, 0x01, 0x68, 0xce, 0x06, 0xe2,
|
|
560
|
+
0x00, 0x00, 0x00, 0x01, 0x65, frameSeq & 0xff, 0x84
|
|
561
|
+
]
|
|
562
|
+
: [0x00, 0x00, 0x00, 0x01, 0x41, frameSeq & 0xff, 0x11]
|
|
563
|
+
).toString('base64')
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const connectBorrower = async label => {
|
|
567
|
+
const socket = new WebSocket(
|
|
568
|
+
`ws://127.0.0.1:${httpPort}/api/remote/frames/ws?devices=${encodeURIComponent(deviceId)}`,
|
|
569
|
+
{ headers: { Origin: baseUrl }, perMessageDeflate: false }
|
|
570
|
+
);
|
|
571
|
+
const observed = { json: [], frames: [] };
|
|
572
|
+
socket.on('message', (data, isBinary) => {
|
|
573
|
+
if (isBinary) {
|
|
574
|
+
const metadata = parseBorrowLifecycleFramePacket(data);
|
|
575
|
+
if (metadata) observed.frames.push(metadata);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
observed.json.push(JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data)));
|
|
580
|
+
} catch {
|
|
581
|
+
// This isolated contract records only valid Hub JSON control messages.
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
browserSockets.push(socket);
|
|
585
|
+
await Promise.race([
|
|
586
|
+
once(socket, 'open'),
|
|
587
|
+
once(socket, 'error').then(([error]) => Promise.reject(error))
|
|
588
|
+
]);
|
|
589
|
+
await waitForBorrowLifecycle(
|
|
590
|
+
() => observed.json.find(message => message.type === 'RemoteFrameSocketReady'),
|
|
591
|
+
`${label} frame socket ready`
|
|
592
|
+
);
|
|
593
|
+
const subscribeJsonIndex = observed.json.length;
|
|
594
|
+
socket.send(JSON.stringify({
|
|
595
|
+
type: 'subscribe',
|
|
596
|
+
deviceIds: [deviceId],
|
|
597
|
+
autoStartLive: true,
|
|
598
|
+
allowReadOnlyControlBorrow: true,
|
|
599
|
+
streamPurpose: 'wall',
|
|
600
|
+
mode: 'mode3-h264-hw',
|
|
601
|
+
frameMode: 'mode3-h264-hw',
|
|
602
|
+
fps: 5,
|
|
603
|
+
maxWidth: 960,
|
|
604
|
+
maxHeight: 540,
|
|
605
|
+
quality: 55,
|
|
606
|
+
monitorIndex: 0
|
|
607
|
+
}));
|
|
608
|
+
return { socket, observed, subscribeJsonIndex };
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const waitForBorrowStart = (observed, commandId, fromIndex, label) => waitForBorrowLifecycle(
|
|
612
|
+
() => observed.json.slice(fromIndex).find(message => (
|
|
613
|
+
message.type === 'RemoteFrameLiveAutoStart'
|
|
614
|
+
&& message.started?.some(item => (
|
|
615
|
+
item.deviceId === deviceId
|
|
616
|
+
&& item.commandId === commandId
|
|
617
|
+
&& item.streamPurpose === 'control'
|
|
618
|
+
&& item.readOnlyControlBorrow === true
|
|
619
|
+
&& item.presentationPurpose === 'wall'
|
|
620
|
+
))
|
|
621
|
+
)),
|
|
622
|
+
label
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
const waitForExactReady = (observed, binding, fromIndex, label) => waitForBorrowLifecycle(
|
|
626
|
+
() => observed.json.slice(fromIndex).find(message => (
|
|
627
|
+
message.type === 'RemoteFrameStreamReady'
|
|
628
|
+
&& message.deviceId === deviceId
|
|
629
|
+
&& message.sessionId === binding.sessionId
|
|
630
|
+
&& message.streamId === binding.streamId
|
|
631
|
+
&& message.commandId === binding.commandId
|
|
632
|
+
&& message.captureGeneration === binding.captureGeneration
|
|
633
|
+
&& message.streamPurpose === 'control'
|
|
634
|
+
&& message.monitorIndex === 0
|
|
635
|
+
&& message.readOnlyControlBorrow === true
|
|
636
|
+
&& message.presentationPurpose === 'wall'
|
|
637
|
+
)),
|
|
638
|
+
label
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
hubProcess = spawn(process.execPath, [hubServerPath], {
|
|
643
|
+
cwd: rootDir,
|
|
644
|
+
env: {
|
|
645
|
+
...process.env,
|
|
646
|
+
LIVEDESK_HUB_HTTP_HOST: '127.0.0.1',
|
|
647
|
+
LIVEDESK_HUB_HTTP_PORT: String(httpPort),
|
|
648
|
+
REMOTE_HUB_HOST: '127.0.0.1',
|
|
649
|
+
REMOTE_HUB_PORT: String(remotePort),
|
|
650
|
+
REMOTE_HUB_PAIR_TOKEN: pairToken,
|
|
651
|
+
REMOTE_HUB_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS: '750',
|
|
652
|
+
LIVEDESK_FRAME_STREAM_STOP_GRACE_MS: '25',
|
|
653
|
+
LIVEDESK_PUBLIC_IP_DISCOVERY_DISABLED: '1',
|
|
654
|
+
REMOTE_HUB_PUBLIC_IP_DISCOVERY_DISABLED: '1',
|
|
655
|
+
LIVEDESK_TEST_MODE: '1',
|
|
656
|
+
LIVEDESK_TEST_LICENSE_PLAN: 'pro',
|
|
657
|
+
LIVEDESK_UDP_ENABLED: '0'
|
|
658
|
+
},
|
|
659
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
660
|
+
windowsHide: true
|
|
661
|
+
});
|
|
662
|
+
hubOutput = collectBorrowLifecycleProcessOutput(hubProcess);
|
|
663
|
+
await waitForBorrowLifecycle(async () => {
|
|
664
|
+
if (hubProcess.exitCode !== null) {
|
|
665
|
+
throw new Error(`isolated Hub exited ${hubProcess.exitCode}: ${hubOutput.join('\n')}`);
|
|
666
|
+
}
|
|
667
|
+
const response = await fetch(`${baseUrl}/api/health`, { cache: 'no-store' });
|
|
668
|
+
return response.ok;
|
|
669
|
+
}, 'isolated Hub health', 10_000, 50);
|
|
670
|
+
|
|
671
|
+
agentSocket = net.createConnection({ host: '127.0.0.1', port: remotePort });
|
|
672
|
+
await once(agentSocket, 'connect');
|
|
673
|
+
let incoming = '';
|
|
674
|
+
agentSocket.on('data', chunk => {
|
|
675
|
+
incoming += chunk.toString('utf8');
|
|
676
|
+
let newlineIndex = incoming.indexOf('\n');
|
|
677
|
+
while (newlineIndex >= 0) {
|
|
678
|
+
const line = incoming.slice(0, newlineIndex).trim();
|
|
679
|
+
incoming = incoming.slice(newlineIndex + 1);
|
|
680
|
+
newlineIndex = incoming.indexOf('\n');
|
|
681
|
+
if (!line) continue;
|
|
682
|
+
const message = JSON.parse(line);
|
|
683
|
+
const record = { message, at: Date.now() };
|
|
684
|
+
agentMessages.push(record);
|
|
685
|
+
if (message.type === 'command' && message.command === 'stream.stop') {
|
|
686
|
+
setTimeout(() => {
|
|
687
|
+
if (agentSocket?.destroyed) return;
|
|
688
|
+
stopAcknowledgedAtByCommandId.set(message.commandId, Date.now());
|
|
689
|
+
sendAgent({
|
|
690
|
+
type: 'command.result',
|
|
691
|
+
commandId: message.commandId,
|
|
692
|
+
result: {
|
|
693
|
+
stream: true,
|
|
694
|
+
stopped: true,
|
|
695
|
+
streamId: String(message.payload?.streamId || '')
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}, 5);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
sendAgent({
|
|
703
|
+
type: 'hello',
|
|
704
|
+
pairToken,
|
|
705
|
+
deviceId,
|
|
706
|
+
deviceName: 'Control borrow browser fixture',
|
|
707
|
+
hostname: 'control-borrow-browser-fixture',
|
|
708
|
+
platform: 'darwin',
|
|
709
|
+
arch: 'arm64',
|
|
710
|
+
protocol: 'mindexec.remote.agent',
|
|
711
|
+
protocolVersion: 2,
|
|
712
|
+
capabilities: {
|
|
713
|
+
liveStream: true,
|
|
714
|
+
liveCaptureConcurrency: 'exclusive',
|
|
715
|
+
frameProtocol: {},
|
|
716
|
+
frameModes: []
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
await waitForBorrowLifecycle(
|
|
720
|
+
() => agentMessages.find(record => record.message?.type === 'welcome'),
|
|
721
|
+
'fake macOS Agent welcome'
|
|
722
|
+
);
|
|
723
|
+
await waitForBorrowLifecycle(async () => {
|
|
724
|
+
const response = await fetch(`${baseUrl}/api/remote/devices`, { cache: 'no-store' });
|
|
725
|
+
const payload = await response.json();
|
|
726
|
+
return payload.devices?.find(device => device.deviceId === deviceId && device.connected);
|
|
727
|
+
}, 'fake macOS Agent registration');
|
|
728
|
+
|
|
729
|
+
const controlOptions = {
|
|
730
|
+
streamPurpose: 'control',
|
|
731
|
+
mode: 'mode3-h264-hw',
|
|
732
|
+
frameMode: 'mode3-h264-hw',
|
|
733
|
+
fps: 30,
|
|
734
|
+
maxWidth: 1920,
|
|
735
|
+
maxHeight: 1080,
|
|
736
|
+
quality: 72,
|
|
737
|
+
monitorIndex: 0
|
|
738
|
+
};
|
|
739
|
+
const initialControl = await postJson(
|
|
740
|
+
`/api/remote/devices/${encodeURIComponent(deviceId)}/live/start`,
|
|
741
|
+
controlOptions
|
|
742
|
+
);
|
|
743
|
+
assert.equal(initialControl.ok, true);
|
|
744
|
+
const initialStartCommand = await waitForAgentCommand(
|
|
745
|
+
message => message.command === 'stream.start' && message.commandId === initialControl.commandId,
|
|
746
|
+
'initial Control start command'
|
|
747
|
+
);
|
|
748
|
+
assert.equal(initialStartCommand.message.payload.streamPurpose, 'control');
|
|
749
|
+
sendOpen(initialControl);
|
|
750
|
+
sendControlFrame(initialControl, 1, true);
|
|
751
|
+
await waitForBorrowLifecycle(async () => {
|
|
752
|
+
const response = await fetch(`${baseUrl}/api/remote/devices`, { cache: 'no-store' });
|
|
753
|
+
const payload = await response.json();
|
|
754
|
+
const device = payload.devices?.find(item => item.deviceId === deviceId);
|
|
755
|
+
return device?.activeLiveStream?.commandId === initialControl.commandId
|
|
756
|
+
&& device.activeLiveStream.readyFrameReceived === true;
|
|
757
|
+
}, 'initial exact Control readiness');
|
|
758
|
+
|
|
759
|
+
const firstBorrower = await connectBorrower('first borrower');
|
|
760
|
+
const firstBorrowStart = await waitForBorrowStart(
|
|
761
|
+
firstBorrower.observed,
|
|
762
|
+
initialControl.commandId,
|
|
763
|
+
firstBorrower.subscribeJsonIndex,
|
|
764
|
+
'first read-only Control binding'
|
|
765
|
+
);
|
|
766
|
+
const firstBorrowBinding = firstBorrowStart.started.find(item => item.commandId === initialControl.commandId);
|
|
767
|
+
assert.equal(firstBorrowBinding.ready, false, 'A new borrower must wait for its own exact key frame.');
|
|
768
|
+
assert.equal(firstBorrowBinding.captureGeneration, initialControl.captureGeneration);
|
|
769
|
+
assert.equal(firstBorrowBinding.effectiveProfile?.fps, 30);
|
|
770
|
+
const firstReadyIndex = firstBorrower.observed.json.length;
|
|
771
|
+
const firstFrameIndex = firstBorrower.observed.frames.length;
|
|
772
|
+
sendControlFrame(initialControl, 2, false);
|
|
773
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 60));
|
|
774
|
+
assert.equal(
|
|
775
|
+
firstBorrower.observed.json.slice(firstReadyIndex).some(message => message.type === 'RemoteFrameStreamReady'),
|
|
776
|
+
false,
|
|
777
|
+
'A dependent delta must not declare a new borrower ready.'
|
|
778
|
+
);
|
|
779
|
+
assert.equal(
|
|
780
|
+
firstBorrower.observed.frames.length,
|
|
781
|
+
firstFrameIndex,
|
|
782
|
+
'A dependent delta must not cross the new borrower frame lane before a key frame.'
|
|
783
|
+
);
|
|
784
|
+
sendControlFrame(initialControl, 3, true);
|
|
785
|
+
await waitForExactReady(
|
|
786
|
+
firstBorrower.observed,
|
|
787
|
+
initialControl,
|
|
788
|
+
firstReadyIndex,
|
|
789
|
+
'first borrower exact Control key readiness'
|
|
790
|
+
);
|
|
791
|
+
const firstFrame = await waitForBorrowLifecycle(
|
|
792
|
+
() => firstBorrower.observed.frames.slice(firstFrameIndex).find(frame => (
|
|
793
|
+
frame.commandId === initialControl.commandId
|
|
794
|
+
&& frame.captureGeneration === initialControl.captureGeneration
|
|
795
|
+
&& frame.frameSeq === 3
|
|
796
|
+
)),
|
|
797
|
+
'first borrower exact Control key packet'
|
|
798
|
+
);
|
|
799
|
+
assert.equal(firstFrame.streamPurpose, 'control');
|
|
800
|
+
assert.equal(firstFrame.monitorIndex, 0);
|
|
801
|
+
|
|
802
|
+
const firstStopCommandIndex = agentMessages.length;
|
|
803
|
+
const stopInitialPromise = postJson(
|
|
804
|
+
`/api/remote/devices/${encodeURIComponent(deviceId)}/live/stop`,
|
|
805
|
+
{
|
|
806
|
+
streamId: initialControl.streamId,
|
|
807
|
+
streamPurpose: 'control',
|
|
808
|
+
reason: 'replace-control-generation'
|
|
809
|
+
}
|
|
810
|
+
);
|
|
811
|
+
const firstStopCommand = await waitForAgentCommand(
|
|
812
|
+
message => message.command === 'stream.stop' && message.payload?.streamId === initialControl.streamId,
|
|
813
|
+
'initial Control confirmed stop',
|
|
814
|
+
firstStopCommandIndex
|
|
815
|
+
);
|
|
816
|
+
const initialStop = await stopInitialPromise;
|
|
817
|
+
assert.equal(initialStop.captureStopConfirmed, true);
|
|
818
|
+
assert.ok(stopAcknowledgedAtByCommandId.has(firstStopCommand.message.commandId));
|
|
819
|
+
|
|
820
|
+
const replacementControl = await postJson(
|
|
821
|
+
`/api/remote/devices/${encodeURIComponent(deviceId)}/live/start`,
|
|
822
|
+
controlOptions
|
|
823
|
+
);
|
|
824
|
+
assert.equal(replacementControl.ok, true);
|
|
825
|
+
assert.ok(replacementControl.captureGeneration > initialControl.captureGeneration);
|
|
826
|
+
const replacementStartCommand = await waitForAgentCommand(
|
|
827
|
+
message => message.command === 'stream.start' && message.commandId === replacementControl.commandId,
|
|
828
|
+
'replacement Control start command',
|
|
829
|
+
firstStopCommandIndex
|
|
830
|
+
);
|
|
831
|
+
assert.equal(replacementStartCommand.message.payload.streamPurpose, 'control');
|
|
832
|
+
const replacementJsonIndex = firstBorrower.observed.json.length;
|
|
833
|
+
await new Promise(resolveWait => setTimeout(
|
|
834
|
+
resolveWait,
|
|
835
|
+
READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS + 40
|
|
836
|
+
));
|
|
837
|
+
assert.equal(
|
|
838
|
+
agentMessages.slice(firstStopCommandIndex).some(record => (
|
|
839
|
+
record.message?.type === 'command'
|
|
840
|
+
&& record.message.command === 'stream.start'
|
|
841
|
+
&& record.message.payload?.streamPurpose === 'wall'
|
|
842
|
+
)),
|
|
843
|
+
false,
|
|
844
|
+
'The stopped-Control grace must not let Wall steal a prompt replacement Control claim.'
|
|
845
|
+
);
|
|
846
|
+
assert.equal(
|
|
847
|
+
firstBorrower.observed.json.slice(replacementJsonIndex).some(message => (
|
|
848
|
+
message.type === 'RemoteFrameLiveAutoStart'
|
|
849
|
+
&& message.started?.some(item => item.commandId === replacementControl.commandId)
|
|
850
|
+
)),
|
|
851
|
+
false,
|
|
852
|
+
'A borrower must not rebind to a replacement Control before that generation is ready.'
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
const replacementFrameIndex = firstBorrower.observed.frames.length;
|
|
856
|
+
sendOpen(replacementControl);
|
|
857
|
+
sendControlFrame(replacementControl, 1, true);
|
|
858
|
+
const replacementBorrowStart = await waitForBorrowStart(
|
|
859
|
+
firstBorrower.observed,
|
|
860
|
+
replacementControl.commandId,
|
|
861
|
+
replacementJsonIndex,
|
|
862
|
+
'replacement Control borrower reconciliation'
|
|
863
|
+
);
|
|
864
|
+
assert.equal(replacementBorrowStart.reason, 'control-borrow-reconcile');
|
|
865
|
+
assert.equal(
|
|
866
|
+
replacementBorrowStart.started.find(item => item.commandId === replacementControl.commandId)?.ready,
|
|
867
|
+
false
|
|
868
|
+
);
|
|
869
|
+
assert.equal(
|
|
870
|
+
firstBorrower.observed.frames.slice(replacementFrameIndex).some(frame => (
|
|
871
|
+
frame.commandId === replacementControl.commandId
|
|
872
|
+
)),
|
|
873
|
+
false,
|
|
874
|
+
'The key that precedes replacement binding installation must not leak into the new lane.'
|
|
875
|
+
);
|
|
876
|
+
const replacementReadyIndex = firstBorrower.observed.json.length;
|
|
877
|
+
sendControlFrame(replacementControl, 2, false);
|
|
878
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 60));
|
|
879
|
+
assert.equal(firstBorrower.observed.frames.length, replacementFrameIndex);
|
|
880
|
+
sendControlFrame(replacementControl, 3, true);
|
|
881
|
+
await waitForExactReady(
|
|
882
|
+
firstBorrower.observed,
|
|
883
|
+
replacementControl,
|
|
884
|
+
replacementReadyIndex,
|
|
885
|
+
'replacement borrower exact Control key readiness'
|
|
886
|
+
);
|
|
887
|
+
await waitForBorrowLifecycle(
|
|
888
|
+
() => firstBorrower.observed.frames.slice(replacementFrameIndex).find(frame => (
|
|
889
|
+
frame.commandId === replacementControl.commandId
|
|
890
|
+
&& frame.captureGeneration === replacementControl.captureGeneration
|
|
891
|
+
&& frame.frameSeq === 3
|
|
892
|
+
)),
|
|
893
|
+
'replacement borrower exact Control key packet'
|
|
894
|
+
);
|
|
895
|
+
assert.equal(
|
|
896
|
+
firstBorrower.observed.frames.slice(replacementFrameIndex).some(frame => (
|
|
897
|
+
frame.commandId === initialControl.commandId
|
|
898
|
+
|| frame.captureGeneration === initialControl.captureGeneration
|
|
899
|
+
)),
|
|
900
|
+
false,
|
|
901
|
+
'A replacement binding must reject every stale Control generation.'
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
const stopCountBeforeBorrowerClose = agentMessages.filter(record => (
|
|
905
|
+
record.message?.type === 'command' && record.message.command === 'stream.stop'
|
|
906
|
+
)).length;
|
|
907
|
+
const firstBorrowerClosed = once(firstBorrower.socket, 'close');
|
|
908
|
+
firstBorrower.socket.close(1000, 'borrower-release-regression');
|
|
909
|
+
await firstBorrowerClosed;
|
|
910
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 90));
|
|
911
|
+
assert.equal(
|
|
912
|
+
agentMessages.filter(record => (
|
|
913
|
+
record.message?.type === 'command' && record.message.command === 'stream.stop'
|
|
914
|
+
)).length,
|
|
915
|
+
stopCountBeforeBorrowerClose,
|
|
916
|
+
'Closing a read-only borrower must never stop the native Control owner.'
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
const fallbackBorrower = await connectBorrower('fallback borrower');
|
|
920
|
+
await waitForBorrowStart(
|
|
921
|
+
fallbackBorrower.observed,
|
|
922
|
+
replacementControl.commandId,
|
|
923
|
+
fallbackBorrower.subscribeJsonIndex,
|
|
924
|
+
'fallback borrower Control binding'
|
|
925
|
+
);
|
|
926
|
+
const fallbackReadyIndex = fallbackBorrower.observed.json.length;
|
|
927
|
+
sendControlFrame(replacementControl, 4, true);
|
|
928
|
+
await waitForExactReady(
|
|
929
|
+
fallbackBorrower.observed,
|
|
930
|
+
replacementControl,
|
|
931
|
+
fallbackReadyIndex,
|
|
932
|
+
'fallback borrower exact Control key readiness'
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
const finalStopCommandIndex = agentMessages.length;
|
|
936
|
+
const stopReplacementPromise = postJson(
|
|
937
|
+
`/api/remote/devices/${encodeURIComponent(deviceId)}/live/stop`,
|
|
938
|
+
{
|
|
939
|
+
streamId: replacementControl.streamId,
|
|
940
|
+
streamPurpose: 'control',
|
|
941
|
+
reason: 'confirmed-control-stop-wall-fallback'
|
|
942
|
+
}
|
|
943
|
+
);
|
|
944
|
+
const finalStopCommand = await waitForAgentCommand(
|
|
945
|
+
message => message.command === 'stream.stop' && message.payload?.streamId === replacementControl.streamId,
|
|
946
|
+
'replacement Control confirmed stop',
|
|
947
|
+
finalStopCommandIndex
|
|
948
|
+
);
|
|
949
|
+
const replacementStop = await stopReplacementPromise;
|
|
950
|
+
assert.equal(replacementStop.captureStopConfirmed, true);
|
|
951
|
+
const finalStopAcknowledgedAt = stopAcknowledgedAtByCommandId.get(finalStopCommand.message.commandId);
|
|
952
|
+
assert.ok(finalStopAcknowledgedAt > 0);
|
|
953
|
+
assert.equal(
|
|
954
|
+
agentMessages.slice(finalStopCommandIndex).some(record => (
|
|
955
|
+
record.message?.type === 'command'
|
|
956
|
+
&& record.message.command === 'stream.start'
|
|
957
|
+
&& record.message.payload?.streamPurpose === 'wall'
|
|
958
|
+
)),
|
|
959
|
+
false,
|
|
960
|
+
'Confirmed Control stop must not reacquire Wall synchronously.'
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
const fallbackWallCommand = await waitForAgentCommand(
|
|
964
|
+
message => message.command === 'stream.start' && message.payload?.streamPurpose === 'wall',
|
|
965
|
+
'deferred ordinary Wall fallback',
|
|
966
|
+
finalStopCommandIndex
|
|
967
|
+
);
|
|
968
|
+
assert.ok(
|
|
969
|
+
fallbackWallCommand.at - finalStopAcknowledgedAt
|
|
970
|
+
>= READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS - 40,
|
|
971
|
+
`Wall fallback started before the ${READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS}ms Control replacement grace.`
|
|
972
|
+
);
|
|
973
|
+
const fallbackWallStart = await waitForBorrowLifecycle(
|
|
974
|
+
() => fallbackBorrower.observed.json.find(message => (
|
|
975
|
+
message.type === 'RemoteFrameLiveAutoStart'
|
|
976
|
+
&& message.reason === 'control-borrow-reconcile'
|
|
977
|
+
&& message.started?.some(item => (
|
|
978
|
+
item.deviceId === deviceId
|
|
979
|
+
&& item.commandId === fallbackWallCommand.message.commandId
|
|
980
|
+
&& item.streamPurpose === 'wall'
|
|
981
|
+
&& item.readOnlyControlBorrow !== true
|
|
982
|
+
))
|
|
983
|
+
)),
|
|
984
|
+
'ordinary Wall browser binding after confirmed Control stop'
|
|
985
|
+
);
|
|
986
|
+
assert.equal(fallbackWallStart.started[0].presentationPurpose, '');
|
|
987
|
+
} catch (error) {
|
|
988
|
+
error.message += hubOutput.length > 0
|
|
989
|
+
? `\nIsolated Hub output:\n${hubOutput.join('\n')}`
|
|
990
|
+
: '';
|
|
991
|
+
throw error;
|
|
992
|
+
} finally {
|
|
993
|
+
for (const socket of browserSockets) {
|
|
994
|
+
try { socket.terminate(); } catch { /* already closed */ }
|
|
995
|
+
}
|
|
996
|
+
agentSocket?.destroy();
|
|
997
|
+
await terminateBorrowLifecycleProcess(hubProcess);
|
|
998
|
+
}
|
|
999
|
+
});
|