@livedesk/hub 0.1.26 → 0.1.28

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.
@@ -2,6 +2,61 @@ import { fork } from 'node:child_process';
2
2
  import { fileURLToPath } from 'node:url';
3
3
 
4
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
+ }
5
60
 
6
61
  export class Mode4AtlasSession {
7
62
  constructor(options = {}) {
@@ -19,7 +74,26 @@ export class Mode4AtlasSession {
19
74
  this.pendingFrames = new Map();
20
75
  this.frameSendsInFlight = new Set();
21
76
  this.workerGeneration = 0;
22
- this.startWorker();
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
+ }
23
97
  }
24
98
 
25
99
  startWorker() {
@@ -33,7 +107,26 @@ export class Mode4AtlasSession {
33
107
  });
34
108
  this.worker = worker;
35
109
  worker.on('message', message => {
36
- if (message?.type === 'frame' && message.metadata && message.payload) {
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) {
37
130
  this.onFrame({
38
131
  metadata: { ...message.metadata, workerGeneration },
39
132
  payload: Buffer.from(message.payload)
@@ -45,15 +138,14 @@ export class Mode4AtlasSession {
45
138
  }
46
139
  });
47
140
  worker.on('exit', code => {
48
- if (this.worker === worker) this.worker = null;
49
- this.frameSendsInFlight.clear();
50
- if (this.closed) return;
51
- this.onStatus({ type: 'status', state: 'worker-restart', code });
52
- clearTimeout(this.restartTimer);
53
- this.restartTimer = setTimeout(() => {
54
- this.startWorker();
55
- if (this.config) this.worker?.send({ type: 'configure', ...this.config });
56
- }, 750);
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);
57
149
  });
58
150
  worker.on('error', error => this.onStatus({ type: 'status', state: 'worker-error', error: error.message }));
59
151
  if (this.config) {
@@ -63,6 +155,49 @@ export class Mode4AtlasSession {
63
155
  }
64
156
  }
65
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
+
66
201
  configure(options = {}) {
67
202
  this.deviceIds = [...new Set((Array.isArray(options.deviceIds) ? options.deviceIds : [])
68
203
  .map(value => String(value || '').trim()).filter(Boolean))].slice(0, 100);
@@ -103,7 +238,7 @@ export class Mode4AtlasSession {
103
238
  }
104
239
 
105
240
  ingest(frameEvent) {
106
- if (this.closed || !this.worker) return;
241
+ if (this.closed) return;
107
242
  const frame = frameEvent?.frame || {};
108
243
  const deviceId = String(frameEvent?.deviceId || frame.deviceId || '').trim();
109
244
  const mode = String(frame.frameMode || frame.mode || '').toLowerCase();
@@ -129,14 +264,43 @@ export class Mode4AtlasSession {
129
264
  }
130
265
 
131
266
  close() {
132
- if (this.closed) return;
267
+ if (this.closePromise) return this.closePromise;
133
268
  this.closed = true;
134
269
  clearTimeout(this.restartTimer);
135
270
  this.pendingFrames.clear();
136
271
  this.frameSendsInFlight.clear();
137
272
  const worker = this.worker;
138
- this.worker = null;
139
- try { worker?.send({ type: 'close' }); } catch {}
140
- setTimeout(() => { try { worker?.kill(); } catch {} }, 400).unref?.();
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;
141
305
  }
142
306
  }
@@ -0,0 +1,109 @@
1
+ function normalizedText(value, maxLength = 160) {
2
+ return String(value || '').trim().slice(0, maxLength);
3
+ }
4
+
5
+ export function normalizeRemoteAudioStreamBindings(payload = {}, deviceIds = []) {
6
+ const bindings = new Map();
7
+ const byDevice = payload?.streamIdsByDeviceId;
8
+ if (byDevice && typeof byDevice === 'object' && !Array.isArray(byDevice)) {
9
+ for (const deviceId of deviceIds) {
10
+ const streamId = normalizedText(byDevice[deviceId], 128);
11
+ if (streamId) {
12
+ bindings.set(deviceId, streamId);
13
+ }
14
+ }
15
+ }
16
+ if (Array.isArray(payload?.streamBindings)) {
17
+ for (const binding of payload.streamBindings) {
18
+ const deviceId = normalizedText(binding?.deviceId);
19
+ const streamId = normalizedText(binding?.streamId, 128);
20
+ if (deviceIds.includes(deviceId) && streamId) {
21
+ bindings.set(deviceId, streamId);
22
+ }
23
+ }
24
+ }
25
+ if (deviceIds.length === 1 && !bindings.has(deviceIds[0])) {
26
+ const streamId = normalizedText(payload?.streamId, 128);
27
+ if (streamId) {
28
+ bindings.set(deviceIds[0], streamId);
29
+ }
30
+ }
31
+ return bindings;
32
+ }
33
+
34
+ export function remoteAudioSubscriberOwns(client, deviceId, streamId = '') {
35
+ const subscribedDeviceIds = client?.liveDeskAudioDeviceIds;
36
+ if (!(subscribedDeviceIds instanceof Set)
37
+ || subscribedDeviceIds.size === 0
38
+ || !subscribedDeviceIds.has(deviceId)) {
39
+ return !(subscribedDeviceIds instanceof Set) || subscribedDeviceIds.size === 0;
40
+ }
41
+ const expectedStreamId = normalizedText(streamId, 128);
42
+ const boundStreamId = client?.liveDeskAudioStreamIdsByDeviceId instanceof Map
43
+ ? normalizedText(client.liveDeskAudioStreamIdsByDeviceId.get(deviceId), 128)
44
+ : '';
45
+ // A legacy subscriber without an exact binding remains a wildcard owner.
46
+ return !expectedStreamId || !boundStreamId || boundStreamId === expectedStreamId;
47
+ }
48
+
49
+ export function collectReleasedRemoteAudioOwners({
50
+ previousDeviceIds,
51
+ previousStreamIdsByDeviceId,
52
+ nextDeviceIds,
53
+ nextStreamIdsByDeviceId,
54
+ fallbackOwners = []
55
+ }) {
56
+ const previousIds = previousDeviceIds instanceof Set ? previousDeviceIds : new Set();
57
+ const nextIds = nextDeviceIds instanceof Set ? nextDeviceIds : new Set();
58
+ if (previousIds.size === 0) {
59
+ return fallbackOwners.map(owner => ({
60
+ deviceId: normalizedText(owner?.deviceId),
61
+ streamId: normalizedText(owner?.streamId, 128)
62
+ })).filter(owner => owner.deviceId);
63
+ }
64
+ const owners = [];
65
+ for (const deviceId of previousIds) {
66
+ const previousStreamId = previousStreamIdsByDeviceId instanceof Map
67
+ ? normalizedText(previousStreamIdsByDeviceId.get(deviceId), 128)
68
+ : '';
69
+ const nextStreamId = nextStreamIdsByDeviceId instanceof Map
70
+ ? normalizedText(nextStreamIdsByDeviceId.get(deviceId), 128)
71
+ : '';
72
+ if (!nextIds.has(deviceId) || previousStreamId !== nextStreamId) {
73
+ owners.push({ deviceId, streamId: previousStreamId });
74
+ }
75
+ }
76
+ return owners;
77
+ }
78
+
79
+ export function observeRemoteAudioStopConfirmation({
80
+ result,
81
+ deviceId,
82
+ streamId,
83
+ reason,
84
+ hasSubscriber,
85
+ disconnect,
86
+ warn = () => {}
87
+ }) {
88
+ if (!result?.stopPromise) {
89
+ return false;
90
+ }
91
+ void Promise.resolve(result.stopPromise).then(confirmation => {
92
+ if (confirmation?.captureStopConfirmed === true) {
93
+ return;
94
+ }
95
+ if (!hasSubscriber(deviceId, streamId)) {
96
+ warn(
97
+ `audio capture stop was not confirmed device=${deviceId} reason=${reason}: `
98
+ + `${confirmation?.error || 'audio-stop-unconfirmed'}`
99
+ );
100
+ disconnect(deviceId, 'audio-capture-stop-unconfirmed');
101
+ }
102
+ }).catch(error => {
103
+ if (!hasSubscriber(deviceId, streamId)) {
104
+ warn(`audio capture stop confirmation failed device=${deviceId}: ${error instanceof Error ? error.message : String(error)}`);
105
+ disconnect(deviceId, 'audio-capture-stop-confirmation-failed');
106
+ }
107
+ });
108
+ return true;
109
+ }
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ collectReleasedRemoteAudioOwners,
6
+ normalizeRemoteAudioStreamBindings,
7
+ observeRemoteAudioStopConfirmation,
8
+ remoteAudioSubscriberOwns
9
+ } from './remote-audio-subscription-contract.mjs';
10
+
11
+ function deferred() {
12
+ let resolve;
13
+ const promise = new Promise(resolvePromise => {
14
+ resolve = resolvePromise;
15
+ });
16
+ return { promise, resolve };
17
+ }
18
+
19
+ test('late old WebSocket cleanup keeps a differently bound replacement owner', () => {
20
+ const previousDeviceIds = new Set(['device-a']);
21
+ const previousBindings = new Map([['device-a', 'audio-old']]);
22
+ const replacement = {
23
+ liveDeskAudioDeviceIds: new Set(['device-a']),
24
+ liveDeskAudioStreamIdsByDeviceId: new Map([['device-a', 'audio-new']])
25
+ };
26
+ const released = collectReleasedRemoteAudioOwners({
27
+ previousDeviceIds,
28
+ previousStreamIdsByDeviceId: previousBindings,
29
+ nextDeviceIds: new Set(),
30
+ nextStreamIdsByDeviceId: new Map()
31
+ });
32
+
33
+ assert.deepEqual(released, [{ deviceId: 'device-a', streamId: 'audio-old' }]);
34
+ assert.equal(remoteAudioSubscriberOwns(replacement, 'device-a', 'audio-old'), false);
35
+ assert.equal(remoteAudioSubscriberOwns(replacement, 'device-a', 'audio-new'), true);
36
+ });
37
+
38
+ test('subscription binding change releases only the exact old stream', () => {
39
+ const nextBindings = normalizeRemoteAudioStreamBindings({
40
+ streamIdsByDeviceId: { 'device-a': 'audio-new' }
41
+ }, ['device-a']);
42
+ assert.deepEqual(
43
+ collectReleasedRemoteAudioOwners({
44
+ previousDeviceIds: new Set(['device-a']),
45
+ previousStreamIdsByDeviceId: new Map([['device-a', 'audio-old']]),
46
+ nextDeviceIds: new Set(['device-a']),
47
+ nextStreamIdsByDeviceId: nextBindings
48
+ }),
49
+ [{ deviceId: 'device-a', streamId: 'audio-old' }]
50
+ );
51
+ });
52
+
53
+ test('deduplicated API-first stop failure still forces final Agent drain', async () => {
54
+ const stop = deferred();
55
+ const disconnects = [];
56
+ const result = { stopPromise: stop.promise };
57
+ assert.equal(observeRemoteAudioStopConfirmation({
58
+ result,
59
+ deviceId: 'device-a',
60
+ streamId: 'audio-current',
61
+ reason: 'audio-subscriber-closed',
62
+ hasSubscriber: () => false,
63
+ disconnect: (...args) => disconnects.push(args)
64
+ }), true);
65
+
66
+ stop.resolve({ captureStopConfirmed: false, error: 'native-audio-stop-failed' });
67
+ await stop.promise;
68
+ await Promise.resolve();
69
+ assert.deepEqual(disconnects, [
70
+ ['device-a', 'audio-capture-stop-unconfirmed']
71
+ ]);
72
+ });