@livedesk/hub 0.1.41 → 0.1.43
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 +3 -3
- package/src/agents/agent-device-scope.js +29 -29
- package/src/agents/agent-manager.js +103 -103
- package/src/agents/agent-permission-store.js +78 -78
- package/src/agents/agent-runtime-error.js +9 -9
- package/src/agents/agent-settings.js +71 -71
- package/src/agents/codex-agent-runtime.js +594 -594
- package/src/agents/codex-mcp-server.js +100 -100
- package/src/console-relay.js +415 -0
- package/src/filesystem/directory-reader.js +187 -187
- package/src/filesystem/hub-filesystem.js +78 -78
- package/src/filesystem/path-registry.js +78 -78
- package/src/filesystem/roots.js +115 -115
- package/src/frame-packet-contract.mjs +427 -427
- package/src/http/hub-ui-session.js +119 -119
- package/src/live-capture-transition-retry.mjs +297 -297
- package/src/live-desk-update.js +167 -108
- package/src/live-stream-monitor-contract.js +38 -38
- package/src/lzo1x.js +109 -109
- package/src/mode4-atlas-pool.js +137 -137
- package/src/mode4-atlas-sizing.js +32 -32
- package/src/mode4-atlas-worker.js +599 -599
- package/src/mode4-atlas.js +306 -306
- package/src/remote-audio-subscription-contract.mjs +109 -109
- package/src/remote-audio-subscription-contract.test.mjs +72 -72
- package/src/remote-hub.js +144 -144
- package/src/server.js +244 -222
- package/src/settings/effective-device-policy.js +16 -16
- package/src/transport/udp-protocol.js +453 -453
package/src/mode4-atlas.js
CHANGED
|
@@ -1,306 +1,306 @@
|
|
|
1
|
-
import { fork } from 'node:child_process';
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
-
|
|
4
|
-
const workerPath = fileURLToPath(new URL('./mode4-atlas-worker.js', import.meta.url));
|
|
5
|
-
const WORKER_CLOSE_GRACE_MS = 2_500;
|
|
6
|
-
const WORKER_FORCE_EXIT_MS = 750;
|
|
7
|
-
const ENCODER_PARENT_TERM_MS = 250;
|
|
8
|
-
const ENCODER_PARENT_KILL_MS = 750;
|
|
9
|
-
|
|
10
|
-
function waitForChildExit(child, timeoutMs) {
|
|
11
|
-
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
12
|
-
return Promise.resolve(true);
|
|
13
|
-
}
|
|
14
|
-
return new Promise(resolve => {
|
|
15
|
-
let settled = false;
|
|
16
|
-
let timer = null;
|
|
17
|
-
const finish = exited => {
|
|
18
|
-
if (settled) return;
|
|
19
|
-
settled = true;
|
|
20
|
-
if (timer) clearTimeout(timer);
|
|
21
|
-
child.off('exit', handleExit);
|
|
22
|
-
child.off('close', handleExit);
|
|
23
|
-
resolve(exited);
|
|
24
|
-
};
|
|
25
|
-
const handleExit = () => finish(true);
|
|
26
|
-
child.once('exit', handleExit);
|
|
27
|
-
child.once('close', handleExit);
|
|
28
|
-
timer = setTimeout(() => finish(
|
|
29
|
-
child.exitCode !== null || child.signalCode !== null
|
|
30
|
-
), Math.max(1, Number(timeoutMs || 0)));
|
|
31
|
-
timer.unref?.();
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function processIsAlive(pid) {
|
|
36
|
-
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
37
|
-
try {
|
|
38
|
-
process.kill(pid, 0);
|
|
39
|
-
return true;
|
|
40
|
-
} catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function terminateExactProcess(pid) {
|
|
46
|
-
if (!processIsAlive(pid)) return true;
|
|
47
|
-
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
48
|
-
const termDeadline = Date.now() + ENCODER_PARENT_TERM_MS;
|
|
49
|
-
while (processIsAlive(pid) && Date.now() < termDeadline) {
|
|
50
|
-
await new Promise(resolve => setTimeout(resolve, 25));
|
|
51
|
-
}
|
|
52
|
-
if (!processIsAlive(pid)) return true;
|
|
53
|
-
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
54
|
-
const killDeadline = Date.now() + ENCODER_PARENT_KILL_MS;
|
|
55
|
-
while (processIsAlive(pid) && Date.now() < killDeadline) {
|
|
56
|
-
await new Promise(resolve => setTimeout(resolve, 25));
|
|
57
|
-
}
|
|
58
|
-
return !processIsAlive(pid);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export class Mode4AtlasSession {
|
|
62
|
-
constructor(options = {}) {
|
|
63
|
-
this.onFrame = typeof options.onFrame === 'function' ? options.onFrame : () => {};
|
|
64
|
-
this.onLayout = typeof options.onLayout === 'function' ? options.onLayout : () => {};
|
|
65
|
-
this.onStatus = typeof options.onStatus === 'function' ? options.onStatus : () => {};
|
|
66
|
-
this.deviceIds = [];
|
|
67
|
-
this.deviceSet = new Set();
|
|
68
|
-
this.inputDeviceIds = [];
|
|
69
|
-
this.inputDeviceSet = new Set();
|
|
70
|
-
this.config = null;
|
|
71
|
-
this.closed = false;
|
|
72
|
-
this.worker = null;
|
|
73
|
-
this.restartTimer = null;
|
|
74
|
-
this.pendingFrames = new Map();
|
|
75
|
-
this.frameSendsInFlight = new Set();
|
|
76
|
-
this.workerGeneration = 0;
|
|
77
|
-
this.encoderOwner = null;
|
|
78
|
-
this.workerExitCleanupPromises = new Map();
|
|
79
|
-
this.closePromise = null;
|
|
80
|
-
this.startBarrier = null;
|
|
81
|
-
if (options.startAfter && typeof options.startAfter.then === 'function') {
|
|
82
|
-
this.startBarrier = Promise.resolve(options.startAfter)
|
|
83
|
-
.catch(error => {
|
|
84
|
-
this.onStatus({
|
|
85
|
-
type: 'status',
|
|
86
|
-
state: 'prior-worker-close-error',
|
|
87
|
-
error: error instanceof Error ? error.message : String(error)
|
|
88
|
-
});
|
|
89
|
-
})
|
|
90
|
-
.then(() => {
|
|
91
|
-
this.startBarrier = null;
|
|
92
|
-
this.startWorker();
|
|
93
|
-
});
|
|
94
|
-
} else {
|
|
95
|
-
this.startWorker();
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
startWorker() {
|
|
100
|
-
if (this.closed || this.worker) return;
|
|
101
|
-
const workerGeneration = ++this.workerGeneration;
|
|
102
|
-
const worker = fork(workerPath, [], {
|
|
103
|
-
env: { ...process.env },
|
|
104
|
-
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
105
|
-
serialization: 'advanced',
|
|
106
|
-
windowsHide: true
|
|
107
|
-
});
|
|
108
|
-
this.worker = worker;
|
|
109
|
-
worker.on('message', message => {
|
|
110
|
-
if (message?.type === 'encoder-owner') {
|
|
111
|
-
const pid = Number(message.pid || 0);
|
|
112
|
-
const encoderGeneration = Number(message.encoderGeneration || 0);
|
|
113
|
-
if (message.state === 'active' && Number.isSafeInteger(pid) && pid > 0) {
|
|
114
|
-
this.encoderOwner = {
|
|
115
|
-
pid,
|
|
116
|
-
encoderGeneration,
|
|
117
|
-
workerGeneration,
|
|
118
|
-
stopPromise: null
|
|
119
|
-
};
|
|
120
|
-
} else if (message.state === 'exited'
|
|
121
|
-
&& this.encoderOwner?.workerGeneration === workerGeneration
|
|
122
|
-
&& this.encoderOwner?.pid === pid
|
|
123
|
-
&& this.encoderOwner?.encoderGeneration === encoderGeneration) {
|
|
124
|
-
this.encoderOwner = null;
|
|
125
|
-
}
|
|
126
|
-
this.onStatus(message);
|
|
127
|
-
} else if (this.closed) {
|
|
128
|
-
return;
|
|
129
|
-
} else if (message?.type === 'frame' && message.metadata && message.payload) {
|
|
130
|
-
this.onFrame({
|
|
131
|
-
metadata: { ...message.metadata, workerGeneration },
|
|
132
|
-
payload: Buffer.from(message.payload)
|
|
133
|
-
});
|
|
134
|
-
} else if (message?.type === 'layout') {
|
|
135
|
-
this.onLayout(message);
|
|
136
|
-
} else if (message?.type === 'status' || message?.type === 'log') {
|
|
137
|
-
this.onStatus(message);
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
worker.on('exit', code => {
|
|
141
|
-
const cleanupPromise = this.handleWorkerExit(worker, workerGeneration, code);
|
|
142
|
-
this.workerExitCleanupPromises.set(workerGeneration, cleanupPromise);
|
|
143
|
-
const retireCleanupOwner = () => {
|
|
144
|
-
if (this.workerExitCleanupPromises.get(workerGeneration) === cleanupPromise) {
|
|
145
|
-
this.workerExitCleanupPromises.delete(workerGeneration);
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
void cleanupPromise.then(retireCleanupOwner, retireCleanupOwner);
|
|
149
|
-
});
|
|
150
|
-
worker.on('error', error => this.onStatus({ type: 'status', state: 'worker-error', error: error.message }));
|
|
151
|
-
if (this.config) {
|
|
152
|
-
worker.send({ type: 'configure', ...this.config }, () => {
|
|
153
|
-
for (const deviceId of this.pendingFrames.keys()) this.pumpDeviceFrame(deviceId);
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async stopEncoderOwnerForWorker(workerGeneration, reason) {
|
|
159
|
-
const owner = this.encoderOwner;
|
|
160
|
-
if (!owner || owner.workerGeneration !== workerGeneration) return true;
|
|
161
|
-
if (owner.stopPromise) return owner.stopPromise;
|
|
162
|
-
owner.stopPromise = terminateExactProcess(owner.pid).then(exited => {
|
|
163
|
-
if (!exited) {
|
|
164
|
-
this.onStatus({
|
|
165
|
-
type: 'status',
|
|
166
|
-
state: 'encoder-parent-stop-failed',
|
|
167
|
-
error: `Mode 4 encoder pid ${owner.pid} survived ${reason}.`,
|
|
168
|
-
pid: owner.pid,
|
|
169
|
-
encoderGeneration: owner.encoderGeneration,
|
|
170
|
-
workerGeneration
|
|
171
|
-
});
|
|
172
|
-
} else {
|
|
173
|
-
this.onStatus({
|
|
174
|
-
type: 'encoder-owner',
|
|
175
|
-
state: 'exited',
|
|
176
|
-
source: 'parent-reaper',
|
|
177
|
-
pid: owner.pid,
|
|
178
|
-
encoderGeneration: owner.encoderGeneration,
|
|
179
|
-
workerGeneration
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
if (exited && this.encoderOwner === owner) this.encoderOwner = null;
|
|
183
|
-
return exited;
|
|
184
|
-
});
|
|
185
|
-
return owner.stopPromise;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async handleWorkerExit(worker, workerGeneration, code) {
|
|
189
|
-
if (this.worker === worker) this.worker = null;
|
|
190
|
-
this.frameSendsInFlight.clear();
|
|
191
|
-
await this.stopEncoderOwnerForWorker(workerGeneration, 'worker-exit');
|
|
192
|
-
if (this.closed) return;
|
|
193
|
-
this.onStatus({ type: 'status', state: 'worker-restart', code });
|
|
194
|
-
clearTimeout(this.restartTimer);
|
|
195
|
-
this.restartTimer = setTimeout(() => {
|
|
196
|
-
this.startWorker();
|
|
197
|
-
if (this.config) this.worker?.send({ type: 'configure', ...this.config });
|
|
198
|
-
}, 750);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
configure(options = {}) {
|
|
202
|
-
this.deviceIds = [...new Set((Array.isArray(options.deviceIds) ? options.deviceIds : [])
|
|
203
|
-
.map(value => String(value || '').trim()).filter(Boolean))].slice(0, 100);
|
|
204
|
-
this.deviceSet = new Set(this.deviceIds);
|
|
205
|
-
this.inputDeviceIds = [...new Set((Array.isArray(options.inputDeviceIds) ? options.inputDeviceIds : this.deviceIds)
|
|
206
|
-
.map(value => String(value || '').trim()).filter(value => this.deviceSet.has(value)))].slice(0, 100);
|
|
207
|
-
this.inputDeviceSet = new Set(this.inputDeviceIds);
|
|
208
|
-
this.pendingFrames = new Map([...this.pendingFrames].filter(([deviceId]) => this.inputDeviceSet.has(deviceId)));
|
|
209
|
-
this.config = {
|
|
210
|
-
deviceIds: this.deviceIds,
|
|
211
|
-
width: Number(options.width || 1920),
|
|
212
|
-
height: Number(options.height || 1080),
|
|
213
|
-
tileWidth: Number(options.tileWidth || 320),
|
|
214
|
-
tileHeight: Number(options.tileHeight || 180),
|
|
215
|
-
fps: Number(options.fps || 20)
|
|
216
|
-
};
|
|
217
|
-
this.worker?.send({ type: 'configure', ...this.config });
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
pumpDeviceFrame(deviceId) {
|
|
221
|
-
if (this.closed || !this.worker || this.frameSendsInFlight.has(deviceId)) return;
|
|
222
|
-
const message = this.pendingFrames.get(deviceId);
|
|
223
|
-
if (!message) return;
|
|
224
|
-
this.pendingFrames.delete(deviceId);
|
|
225
|
-
this.frameSendsInFlight.add(deviceId);
|
|
226
|
-
try {
|
|
227
|
-
this.worker.send(message, error => {
|
|
228
|
-
this.frameSendsInFlight.delete(deviceId);
|
|
229
|
-
if (error && !this.closed) {
|
|
230
|
-
this.onStatus({ type: 'log', level: 'warn', message: `Mode 4 input ${deviceId}: ${error.message}` });
|
|
231
|
-
}
|
|
232
|
-
this.pumpDeviceFrame(deviceId);
|
|
233
|
-
});
|
|
234
|
-
} catch (error) {
|
|
235
|
-
this.frameSendsInFlight.delete(deviceId);
|
|
236
|
-
this.onStatus({ type: 'log', level: 'warn', message: `Mode 4 input ${deviceId}: ${error.message}` });
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
ingest(frameEvent) {
|
|
241
|
-
if (this.closed) return;
|
|
242
|
-
const frame = frameEvent?.frame || {};
|
|
243
|
-
const deviceId = String(frameEvent?.deviceId || frame.deviceId || '').trim();
|
|
244
|
-
const mode = String(frame.frameMode || frame.mode || '').toLowerCase();
|
|
245
|
-
const streamPurpose = String(frame.streamPurpose || '').trim().toLowerCase();
|
|
246
|
-
if (!this.inputDeviceSet.has(deviceId)
|
|
247
|
-
|| mode !== 'mode2-lzo'
|
|
248
|
-
|| streamPurpose !== 'atlas'
|
|
249
|
-
|| !Buffer.isBuffer(frameEvent?.payload)) return;
|
|
250
|
-
this.pendingFrames.set(deviceId, {
|
|
251
|
-
type: 'frame',
|
|
252
|
-
deviceId,
|
|
253
|
-
frameSeq: Number(frame.frameSeq || 0),
|
|
254
|
-
width: Number(frame.width || 0),
|
|
255
|
-
height: Number(frame.height || 0),
|
|
256
|
-
uncompressedByteLength: Number(frame.uncompressedByteLength || 0),
|
|
257
|
-
monitorIndex: Number(frame.monitorIndex || 0),
|
|
258
|
-
capturedAtEpochMs: Date.parse(frame.capturedAt || '') || 0,
|
|
259
|
-
hubReceivedAtEpochMs: Date.parse(frame.receivedAt || '') || Date.now(),
|
|
260
|
-
contentHash: String(frame.contentHash || ''),
|
|
261
|
-
payload: frameEvent.payload
|
|
262
|
-
});
|
|
263
|
-
this.pumpDeviceFrame(deviceId);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
close() {
|
|
267
|
-
if (this.closePromise) return this.closePromise;
|
|
268
|
-
this.closed = true;
|
|
269
|
-
clearTimeout(this.restartTimer);
|
|
270
|
-
this.pendingFrames.clear();
|
|
271
|
-
this.frameSendsInFlight.clear();
|
|
272
|
-
const worker = this.worker;
|
|
273
|
-
const workerGeneration = this.workerGeneration;
|
|
274
|
-
this.closePromise = (async () => {
|
|
275
|
-
if (!worker) {
|
|
276
|
-
await this.startBarrier?.catch?.(() => {});
|
|
277
|
-
await this.workerExitCleanupPromises.get(workerGeneration)?.catch?.(() => {});
|
|
278
|
-
const encoderExited = await this.stopEncoderOwnerForWorker(
|
|
279
|
-
workerGeneration,
|
|
280
|
-
'parent-close-without-worker'
|
|
281
|
-
);
|
|
282
|
-
return { workerExited: true, encoderExited };
|
|
283
|
-
}
|
|
284
|
-
try { worker.send({ type: 'close' }); } catch {}
|
|
285
|
-
let workerExited = await waitForChildExit(worker, WORKER_CLOSE_GRACE_MS);
|
|
286
|
-
if (!workerExited) {
|
|
287
|
-
await this.stopEncoderOwnerForWorker(workerGeneration, 'parent-close-timeout');
|
|
288
|
-
try { worker.kill('SIGKILL'); } catch {}
|
|
289
|
-
workerExited = await waitForChildExit(worker, WORKER_FORCE_EXIT_MS);
|
|
290
|
-
}
|
|
291
|
-
await Promise.resolve();
|
|
292
|
-
await this.workerExitCleanupPromises.get(workerGeneration)?.catch?.(() => {});
|
|
293
|
-
const encoderExited = await this.stopEncoderOwnerForWorker(workerGeneration, 'parent-close-final');
|
|
294
|
-
if (!workerExited) {
|
|
295
|
-
this.onStatus({
|
|
296
|
-
type: 'status',
|
|
297
|
-
state: 'worker-stop-failed',
|
|
298
|
-
error: `Mode 4 worker pid ${Number(worker.pid || 0)} did not exit during close.`,
|
|
299
|
-
workerGeneration
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
return { workerExited, encoderExited };
|
|
303
|
-
})();
|
|
304
|
-
return this.closePromise;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const workerPath = fileURLToPath(new URL('./mode4-atlas-worker.js', import.meta.url));
|
|
5
|
+
const WORKER_CLOSE_GRACE_MS = 2_500;
|
|
6
|
+
const WORKER_FORCE_EXIT_MS = 750;
|
|
7
|
+
const ENCODER_PARENT_TERM_MS = 250;
|
|
8
|
+
const ENCODER_PARENT_KILL_MS = 750;
|
|
9
|
+
|
|
10
|
+
function waitForChildExit(child, timeoutMs) {
|
|
11
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
12
|
+
return Promise.resolve(true);
|
|
13
|
+
}
|
|
14
|
+
return new Promise(resolve => {
|
|
15
|
+
let settled = false;
|
|
16
|
+
let timer = null;
|
|
17
|
+
const finish = exited => {
|
|
18
|
+
if (settled) return;
|
|
19
|
+
settled = true;
|
|
20
|
+
if (timer) clearTimeout(timer);
|
|
21
|
+
child.off('exit', handleExit);
|
|
22
|
+
child.off('close', handleExit);
|
|
23
|
+
resolve(exited);
|
|
24
|
+
};
|
|
25
|
+
const handleExit = () => finish(true);
|
|
26
|
+
child.once('exit', handleExit);
|
|
27
|
+
child.once('close', handleExit);
|
|
28
|
+
timer = setTimeout(() => finish(
|
|
29
|
+
child.exitCode !== null || child.signalCode !== null
|
|
30
|
+
), Math.max(1, Number(timeoutMs || 0)));
|
|
31
|
+
timer.unref?.();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function processIsAlive(pid) {
|
|
36
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function terminateExactProcess(pid) {
|
|
46
|
+
if (!processIsAlive(pid)) return true;
|
|
47
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
48
|
+
const termDeadline = Date.now() + ENCODER_PARENT_TERM_MS;
|
|
49
|
+
while (processIsAlive(pid) && Date.now() < termDeadline) {
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
51
|
+
}
|
|
52
|
+
if (!processIsAlive(pid)) return true;
|
|
53
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
54
|
+
const killDeadline = Date.now() + ENCODER_PARENT_KILL_MS;
|
|
55
|
+
while (processIsAlive(pid) && Date.now() < killDeadline) {
|
|
56
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
57
|
+
}
|
|
58
|
+
return !processIsAlive(pid);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class Mode4AtlasSession {
|
|
62
|
+
constructor(options = {}) {
|
|
63
|
+
this.onFrame = typeof options.onFrame === 'function' ? options.onFrame : () => {};
|
|
64
|
+
this.onLayout = typeof options.onLayout === 'function' ? options.onLayout : () => {};
|
|
65
|
+
this.onStatus = typeof options.onStatus === 'function' ? options.onStatus : () => {};
|
|
66
|
+
this.deviceIds = [];
|
|
67
|
+
this.deviceSet = new Set();
|
|
68
|
+
this.inputDeviceIds = [];
|
|
69
|
+
this.inputDeviceSet = new Set();
|
|
70
|
+
this.config = null;
|
|
71
|
+
this.closed = false;
|
|
72
|
+
this.worker = null;
|
|
73
|
+
this.restartTimer = null;
|
|
74
|
+
this.pendingFrames = new Map();
|
|
75
|
+
this.frameSendsInFlight = new Set();
|
|
76
|
+
this.workerGeneration = 0;
|
|
77
|
+
this.encoderOwner = null;
|
|
78
|
+
this.workerExitCleanupPromises = new Map();
|
|
79
|
+
this.closePromise = null;
|
|
80
|
+
this.startBarrier = null;
|
|
81
|
+
if (options.startAfter && typeof options.startAfter.then === 'function') {
|
|
82
|
+
this.startBarrier = Promise.resolve(options.startAfter)
|
|
83
|
+
.catch(error => {
|
|
84
|
+
this.onStatus({
|
|
85
|
+
type: 'status',
|
|
86
|
+
state: 'prior-worker-close-error',
|
|
87
|
+
error: error instanceof Error ? error.message : String(error)
|
|
88
|
+
});
|
|
89
|
+
})
|
|
90
|
+
.then(() => {
|
|
91
|
+
this.startBarrier = null;
|
|
92
|
+
this.startWorker();
|
|
93
|
+
});
|
|
94
|
+
} else {
|
|
95
|
+
this.startWorker();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
startWorker() {
|
|
100
|
+
if (this.closed || this.worker) return;
|
|
101
|
+
const workerGeneration = ++this.workerGeneration;
|
|
102
|
+
const worker = fork(workerPath, [], {
|
|
103
|
+
env: { ...process.env },
|
|
104
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
105
|
+
serialization: 'advanced',
|
|
106
|
+
windowsHide: true
|
|
107
|
+
});
|
|
108
|
+
this.worker = worker;
|
|
109
|
+
worker.on('message', message => {
|
|
110
|
+
if (message?.type === 'encoder-owner') {
|
|
111
|
+
const pid = Number(message.pid || 0);
|
|
112
|
+
const encoderGeneration = Number(message.encoderGeneration || 0);
|
|
113
|
+
if (message.state === 'active' && Number.isSafeInteger(pid) && pid > 0) {
|
|
114
|
+
this.encoderOwner = {
|
|
115
|
+
pid,
|
|
116
|
+
encoderGeneration,
|
|
117
|
+
workerGeneration,
|
|
118
|
+
stopPromise: null
|
|
119
|
+
};
|
|
120
|
+
} else if (message.state === 'exited'
|
|
121
|
+
&& this.encoderOwner?.workerGeneration === workerGeneration
|
|
122
|
+
&& this.encoderOwner?.pid === pid
|
|
123
|
+
&& this.encoderOwner?.encoderGeneration === encoderGeneration) {
|
|
124
|
+
this.encoderOwner = null;
|
|
125
|
+
}
|
|
126
|
+
this.onStatus(message);
|
|
127
|
+
} else if (this.closed) {
|
|
128
|
+
return;
|
|
129
|
+
} else if (message?.type === 'frame' && message.metadata && message.payload) {
|
|
130
|
+
this.onFrame({
|
|
131
|
+
metadata: { ...message.metadata, workerGeneration },
|
|
132
|
+
payload: Buffer.from(message.payload)
|
|
133
|
+
});
|
|
134
|
+
} else if (message?.type === 'layout') {
|
|
135
|
+
this.onLayout(message);
|
|
136
|
+
} else if (message?.type === 'status' || message?.type === 'log') {
|
|
137
|
+
this.onStatus(message);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
worker.on('exit', code => {
|
|
141
|
+
const cleanupPromise = this.handleWorkerExit(worker, workerGeneration, code);
|
|
142
|
+
this.workerExitCleanupPromises.set(workerGeneration, cleanupPromise);
|
|
143
|
+
const retireCleanupOwner = () => {
|
|
144
|
+
if (this.workerExitCleanupPromises.get(workerGeneration) === cleanupPromise) {
|
|
145
|
+
this.workerExitCleanupPromises.delete(workerGeneration);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
void cleanupPromise.then(retireCleanupOwner, retireCleanupOwner);
|
|
149
|
+
});
|
|
150
|
+
worker.on('error', error => this.onStatus({ type: 'status', state: 'worker-error', error: error.message }));
|
|
151
|
+
if (this.config) {
|
|
152
|
+
worker.send({ type: 'configure', ...this.config }, () => {
|
|
153
|
+
for (const deviceId of this.pendingFrames.keys()) this.pumpDeviceFrame(deviceId);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async stopEncoderOwnerForWorker(workerGeneration, reason) {
|
|
159
|
+
const owner = this.encoderOwner;
|
|
160
|
+
if (!owner || owner.workerGeneration !== workerGeneration) return true;
|
|
161
|
+
if (owner.stopPromise) return owner.stopPromise;
|
|
162
|
+
owner.stopPromise = terminateExactProcess(owner.pid).then(exited => {
|
|
163
|
+
if (!exited) {
|
|
164
|
+
this.onStatus({
|
|
165
|
+
type: 'status',
|
|
166
|
+
state: 'encoder-parent-stop-failed',
|
|
167
|
+
error: `Mode 4 encoder pid ${owner.pid} survived ${reason}.`,
|
|
168
|
+
pid: owner.pid,
|
|
169
|
+
encoderGeneration: owner.encoderGeneration,
|
|
170
|
+
workerGeneration
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
this.onStatus({
|
|
174
|
+
type: 'encoder-owner',
|
|
175
|
+
state: 'exited',
|
|
176
|
+
source: 'parent-reaper',
|
|
177
|
+
pid: owner.pid,
|
|
178
|
+
encoderGeneration: owner.encoderGeneration,
|
|
179
|
+
workerGeneration
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (exited && this.encoderOwner === owner) this.encoderOwner = null;
|
|
183
|
+
return exited;
|
|
184
|
+
});
|
|
185
|
+
return owner.stopPromise;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async handleWorkerExit(worker, workerGeneration, code) {
|
|
189
|
+
if (this.worker === worker) this.worker = null;
|
|
190
|
+
this.frameSendsInFlight.clear();
|
|
191
|
+
await this.stopEncoderOwnerForWorker(workerGeneration, 'worker-exit');
|
|
192
|
+
if (this.closed) return;
|
|
193
|
+
this.onStatus({ type: 'status', state: 'worker-restart', code });
|
|
194
|
+
clearTimeout(this.restartTimer);
|
|
195
|
+
this.restartTimer = setTimeout(() => {
|
|
196
|
+
this.startWorker();
|
|
197
|
+
if (this.config) this.worker?.send({ type: 'configure', ...this.config });
|
|
198
|
+
}, 750);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
configure(options = {}) {
|
|
202
|
+
this.deviceIds = [...new Set((Array.isArray(options.deviceIds) ? options.deviceIds : [])
|
|
203
|
+
.map(value => String(value || '').trim()).filter(Boolean))].slice(0, 100);
|
|
204
|
+
this.deviceSet = new Set(this.deviceIds);
|
|
205
|
+
this.inputDeviceIds = [...new Set((Array.isArray(options.inputDeviceIds) ? options.inputDeviceIds : this.deviceIds)
|
|
206
|
+
.map(value => String(value || '').trim()).filter(value => this.deviceSet.has(value)))].slice(0, 100);
|
|
207
|
+
this.inputDeviceSet = new Set(this.inputDeviceIds);
|
|
208
|
+
this.pendingFrames = new Map([...this.pendingFrames].filter(([deviceId]) => this.inputDeviceSet.has(deviceId)));
|
|
209
|
+
this.config = {
|
|
210
|
+
deviceIds: this.deviceIds,
|
|
211
|
+
width: Number(options.width || 1920),
|
|
212
|
+
height: Number(options.height || 1080),
|
|
213
|
+
tileWidth: Number(options.tileWidth || 320),
|
|
214
|
+
tileHeight: Number(options.tileHeight || 180),
|
|
215
|
+
fps: Number(options.fps || 20)
|
|
216
|
+
};
|
|
217
|
+
this.worker?.send({ type: 'configure', ...this.config });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
pumpDeviceFrame(deviceId) {
|
|
221
|
+
if (this.closed || !this.worker || this.frameSendsInFlight.has(deviceId)) return;
|
|
222
|
+
const message = this.pendingFrames.get(deviceId);
|
|
223
|
+
if (!message) return;
|
|
224
|
+
this.pendingFrames.delete(deviceId);
|
|
225
|
+
this.frameSendsInFlight.add(deviceId);
|
|
226
|
+
try {
|
|
227
|
+
this.worker.send(message, error => {
|
|
228
|
+
this.frameSendsInFlight.delete(deviceId);
|
|
229
|
+
if (error && !this.closed) {
|
|
230
|
+
this.onStatus({ type: 'log', level: 'warn', message: `Mode 4 input ${deviceId}: ${error.message}` });
|
|
231
|
+
}
|
|
232
|
+
this.pumpDeviceFrame(deviceId);
|
|
233
|
+
});
|
|
234
|
+
} catch (error) {
|
|
235
|
+
this.frameSendsInFlight.delete(deviceId);
|
|
236
|
+
this.onStatus({ type: 'log', level: 'warn', message: `Mode 4 input ${deviceId}: ${error.message}` });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
ingest(frameEvent) {
|
|
241
|
+
if (this.closed) return;
|
|
242
|
+
const frame = frameEvent?.frame || {};
|
|
243
|
+
const deviceId = String(frameEvent?.deviceId || frame.deviceId || '').trim();
|
|
244
|
+
const mode = String(frame.frameMode || frame.mode || '').toLowerCase();
|
|
245
|
+
const streamPurpose = String(frame.streamPurpose || '').trim().toLowerCase();
|
|
246
|
+
if (!this.inputDeviceSet.has(deviceId)
|
|
247
|
+
|| mode !== 'mode2-lzo'
|
|
248
|
+
|| streamPurpose !== 'atlas'
|
|
249
|
+
|| !Buffer.isBuffer(frameEvent?.payload)) return;
|
|
250
|
+
this.pendingFrames.set(deviceId, {
|
|
251
|
+
type: 'frame',
|
|
252
|
+
deviceId,
|
|
253
|
+
frameSeq: Number(frame.frameSeq || 0),
|
|
254
|
+
width: Number(frame.width || 0),
|
|
255
|
+
height: Number(frame.height || 0),
|
|
256
|
+
uncompressedByteLength: Number(frame.uncompressedByteLength || 0),
|
|
257
|
+
monitorIndex: Number(frame.monitorIndex || 0),
|
|
258
|
+
capturedAtEpochMs: Date.parse(frame.capturedAt || '') || 0,
|
|
259
|
+
hubReceivedAtEpochMs: Date.parse(frame.receivedAt || '') || Date.now(),
|
|
260
|
+
contentHash: String(frame.contentHash || ''),
|
|
261
|
+
payload: frameEvent.payload
|
|
262
|
+
});
|
|
263
|
+
this.pumpDeviceFrame(deviceId);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
close() {
|
|
267
|
+
if (this.closePromise) return this.closePromise;
|
|
268
|
+
this.closed = true;
|
|
269
|
+
clearTimeout(this.restartTimer);
|
|
270
|
+
this.pendingFrames.clear();
|
|
271
|
+
this.frameSendsInFlight.clear();
|
|
272
|
+
const worker = this.worker;
|
|
273
|
+
const workerGeneration = this.workerGeneration;
|
|
274
|
+
this.closePromise = (async () => {
|
|
275
|
+
if (!worker) {
|
|
276
|
+
await this.startBarrier?.catch?.(() => {});
|
|
277
|
+
await this.workerExitCleanupPromises.get(workerGeneration)?.catch?.(() => {});
|
|
278
|
+
const encoderExited = await this.stopEncoderOwnerForWorker(
|
|
279
|
+
workerGeneration,
|
|
280
|
+
'parent-close-without-worker'
|
|
281
|
+
);
|
|
282
|
+
return { workerExited: true, encoderExited };
|
|
283
|
+
}
|
|
284
|
+
try { worker.send({ type: 'close' }); } catch {}
|
|
285
|
+
let workerExited = await waitForChildExit(worker, WORKER_CLOSE_GRACE_MS);
|
|
286
|
+
if (!workerExited) {
|
|
287
|
+
await this.stopEncoderOwnerForWorker(workerGeneration, 'parent-close-timeout');
|
|
288
|
+
try { worker.kill('SIGKILL'); } catch {}
|
|
289
|
+
workerExited = await waitForChildExit(worker, WORKER_FORCE_EXIT_MS);
|
|
290
|
+
}
|
|
291
|
+
await Promise.resolve();
|
|
292
|
+
await this.workerExitCleanupPromises.get(workerGeneration)?.catch?.(() => {});
|
|
293
|
+
const encoderExited = await this.stopEncoderOwnerForWorker(workerGeneration, 'parent-close-final');
|
|
294
|
+
if (!workerExited) {
|
|
295
|
+
this.onStatus({
|
|
296
|
+
type: 'status',
|
|
297
|
+
state: 'worker-stop-failed',
|
|
298
|
+
error: `Mode 4 worker pid ${Number(worker.pid || 0)} did not exit during close.`,
|
|
299
|
+
workerGeneration
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return { workerExited, encoderExited };
|
|
303
|
+
})();
|
|
304
|
+
return this.closePromise;
|
|
305
|
+
}
|
|
306
|
+
}
|