@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.
@@ -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
+ }