@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.
@@ -0,0 +1,297 @@
1
+ function finitePositive(value, fallback) {
2
+ const number = Number(value);
3
+ return Number.isFinite(number) && number > 0 ? number : fallback;
4
+ }
5
+
6
+ function clamp(value, minimum, maximum) {
7
+ return Math.max(minimum, Math.min(maximum, value));
8
+ }
9
+
10
+ /**
11
+ * Owns the Hub-to-browser retry that follows an exact native capture stop.
12
+ *
13
+ * Contract:
14
+ * - one owner per WebSocket/device intent generation;
15
+ * - the native stop promise is the event source (no polling loop);
16
+ * - retries and total elapsed time are both bounded;
17
+ * - supersede, close, success, and terminal failure synchronously release every timer.
18
+ */
19
+ export function createLiveCaptureTransitionRetryCoordinator(options = {}) {
20
+ const now = typeof options.now === 'function' ? options.now : Date.now;
21
+ const setTimer = typeof options.setTimer === 'function' ? options.setTimer : setTimeout;
22
+ const clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : clearTimeout;
23
+ const maxAttempts = Math.max(1, Math.floor(finitePositive(options.maxAttempts, 3)));
24
+ const minimumRetryAfterMs = Math.max(1, finitePositive(options.minimumRetryAfterMs, 25));
25
+ const maximumRetryAfterMs = Math.max(
26
+ minimumRetryAfterMs,
27
+ finitePositive(options.maximumRetryAfterMs, 250)
28
+ );
29
+ const minimumTotalMs = Math.max(1, finitePositive(options.minimumTotalMs, 2_000));
30
+ const maximumTotalMs = Math.max(minimumTotalMs, finitePositive(options.maximumTotalMs, 30_000));
31
+ const completionMarginMs = Math.max(0, Number(options.completionMarginMs ?? 2_000) || 0);
32
+ const clientStates = new WeakMap();
33
+ const activeOwners = new Set();
34
+
35
+ function stateFor(client) {
36
+ if (!client || (typeof client !== 'object' && typeof client !== 'function')) {
37
+ throw new TypeError('A capture transition retry requires an exact client owner.');
38
+ }
39
+ let state = clientStates.get(client);
40
+ if (!state) {
41
+ state = {
42
+ intentGenerations: new Map(),
43
+ owners: new Map()
44
+ };
45
+ clientStates.set(client, state);
46
+ }
47
+ return state;
48
+ }
49
+
50
+ function ownerIsCurrent(owner) {
51
+ if (!owner || owner.cancelled) {
52
+ return false;
53
+ }
54
+ const state = clientStates.get(owner.client);
55
+ return !!state
56
+ && state.owners.get(owner.deviceId) === owner
57
+ && state.intentGenerations.get(owner.deviceId) === owner.intentGeneration;
58
+ }
59
+
60
+ function releaseOwner(owner, reason = 'completed') {
61
+ if (!owner || owner.cancelled) {
62
+ return false;
63
+ }
64
+ owner.cancelled = true;
65
+ owner.cancelReason = reason;
66
+ owner.waitToken += 1;
67
+ if (owner.timer) {
68
+ clearTimer(owner.timer);
69
+ owner.timer = null;
70
+ }
71
+ const state = clientStates.get(owner.client);
72
+ if (state?.owners.get(owner.deviceId) === owner) {
73
+ state.owners.delete(owner.deviceId);
74
+ }
75
+ activeOwners.delete(owner);
76
+ return true;
77
+ }
78
+
79
+ function failOwner(owner, error, details = {}) {
80
+ if (!ownerIsCurrent(owner)) {
81
+ return false;
82
+ }
83
+ const onTerminal = owner.onTerminal;
84
+ const terminal = {
85
+ deviceId: owner.deviceId,
86
+ intentGeneration: owner.intentGeneration,
87
+ attemptCount: owner.attemptCount,
88
+ deadlineAtEpochMs: owner.deadlineAtEpochMs,
89
+ error,
90
+ ...details
91
+ };
92
+ releaseOwner(owner, error);
93
+ try {
94
+ onTerminal?.(terminal);
95
+ } catch {
96
+ // The retry owner is already terminal. UI notification failure cannot
97
+ // resurrect it or retain a timer.
98
+ }
99
+ return true;
100
+ }
101
+
102
+ function beginIntent(client, deviceId) {
103
+ const normalizedDeviceId = String(deviceId || '').trim();
104
+ if (!normalizedDeviceId) {
105
+ throw new TypeError('A capture transition retry requires a device id.');
106
+ }
107
+ const state = stateFor(client);
108
+ releaseOwner(state.owners.get(normalizedDeviceId), 'superseded');
109
+ const current = Number(state.intentGenerations.get(normalizedDeviceId) || 0);
110
+ const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
111
+ state.intentGenerations.set(normalizedDeviceId, next);
112
+ return next;
113
+ }
114
+
115
+ function complete(client, deviceId, intentGeneration, reason = 'completed') {
116
+ const normalizedDeviceId = String(deviceId || '').trim();
117
+ const state = clientStates.get(client);
118
+ const owner = state?.owners.get(normalizedDeviceId);
119
+ if (!owner
120
+ || (Number.isSafeInteger(Number(intentGeneration))
121
+ && Number(intentGeneration) > 0
122
+ && owner.intentGeneration !== Number(intentGeneration))) {
123
+ return false;
124
+ }
125
+ return releaseOwner(owner, reason);
126
+ }
127
+
128
+ function cancelClient(client, reason = 'client-closed') {
129
+ const state = clientStates.get(client);
130
+ if (!state) {
131
+ return 0;
132
+ }
133
+ let cancelled = 0;
134
+ for (const owner of [...state.owners.values()]) {
135
+ if (releaseOwner(owner, reason)) {
136
+ cancelled += 1;
137
+ }
138
+ }
139
+ state.intentGenerations.clear();
140
+ clientStates.delete(client);
141
+ return cancelled;
142
+ }
143
+
144
+ function schedule(client, request = {}) {
145
+ const deviceId = String(request.deviceId || '').trim();
146
+ const intentGeneration = Number(request.intentGeneration || 0);
147
+ const result = request.result && typeof request.result === 'object'
148
+ ? request.result
149
+ : {};
150
+ const state = stateFor(client);
151
+ if (!deviceId
152
+ || !Number.isSafeInteger(intentGeneration)
153
+ || intentGeneration <= 0
154
+ || state.intentGenerations.get(deviceId) !== intentGeneration
155
+ || result.error !== 'CAPTURE_TRANSITION_IN_PROGRESS') {
156
+ return false;
157
+ }
158
+
159
+ let owner = state.owners.get(deviceId);
160
+ if (owner && owner.intentGeneration !== intentGeneration) {
161
+ releaseOwner(owner, 'superseded');
162
+ owner = null;
163
+ }
164
+ const transitionTimeoutMs = clamp(
165
+ finitePositive(result.transitionTimeoutMs, 11_000),
166
+ minimumRetryAfterMs,
167
+ maximumTotalMs
168
+ );
169
+ if (!owner) {
170
+ const totalWindowMs = clamp(
171
+ transitionTimeoutMs + completionMarginMs,
172
+ minimumTotalMs,
173
+ maximumTotalMs
174
+ );
175
+ owner = {
176
+ client,
177
+ deviceId,
178
+ intentGeneration,
179
+ attemptCount: 0,
180
+ deadlineAtEpochMs: now() + totalWindowMs,
181
+ timer: null,
182
+ phase: 'created',
183
+ cancelled: false,
184
+ cancelReason: '',
185
+ waitToken: 0,
186
+ retry: request.retry,
187
+ onTerminal: request.onTerminal,
188
+ transitionTimeoutMs
189
+ };
190
+ state.owners.set(deviceId, owner);
191
+ activeOwners.add(owner);
192
+ } else {
193
+ owner.retry = request.retry;
194
+ owner.onTerminal = request.onTerminal;
195
+ owner.transitionTimeoutMs = transitionTimeoutMs;
196
+ if (owner.timer) {
197
+ clearTimer(owner.timer);
198
+ owner.timer = null;
199
+ }
200
+ }
201
+
202
+ if (owner.attemptCount >= maxAttempts) {
203
+ failOwner(owner, 'CAPTURE_TRANSITION_ATTEMPT_LIMIT');
204
+ return false;
205
+ }
206
+
207
+ const retryAfterMs = clamp(
208
+ finitePositive(result.retryAfterMs, minimumRetryAfterMs),
209
+ minimumRetryAfterMs,
210
+ maximumRetryAfterMs
211
+ );
212
+ const waitToken = ++owner.waitToken;
213
+ owner.phase = 'waiting-stop';
214
+ const transitionPromise = result.transitionPromise
215
+ && typeof result.transitionPromise.then === 'function'
216
+ ? result.transitionPromise
217
+ : Promise.resolve({
218
+ captureStopConfirmed: false,
219
+ evidenceUnavailable: true
220
+ });
221
+
222
+ void Promise.resolve(transitionPromise).then(
223
+ settlement => {
224
+ if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
225
+ return;
226
+ }
227
+ const remainingMs = owner.deadlineAtEpochMs - now();
228
+ const captureStopConfirmed = settlement?.captureStopConfirmed === true;
229
+ const requiredRetryBudgetMs = captureStopConfirmed
230
+ ? retryAfterMs
231
+ : owner.transitionTimeoutMs + retryAfterMs;
232
+ if (remainingMs < requiredRetryBudgetMs) {
233
+ failOwner(owner, 'CAPTURE_TRANSITION_TIMEOUT', {
234
+ captureStopConfirmed,
235
+ remainingMs: Math.max(0, remainingMs)
236
+ });
237
+ return;
238
+ }
239
+ owner.phase = 'retry-delay';
240
+ owner.timer = setTimer(() => {
241
+ if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
242
+ return;
243
+ }
244
+ owner.timer = null;
245
+ owner.phase = 'retrying';
246
+ owner.attemptCount += 1;
247
+ const retryContext = {
248
+ deviceId: owner.deviceId,
249
+ intentGeneration: owner.intentGeneration,
250
+ attemptCount: owner.attemptCount,
251
+ deadlineAtEpochMs: owner.deadlineAtEpochMs,
252
+ captureStopConfirmed
253
+ };
254
+ void Promise.resolve()
255
+ .then(() => owner.retry?.(retryContext))
256
+ .then(() => {
257
+ if (ownerIsCurrent(owner) && owner.phase === 'retrying') {
258
+ failOwner(owner, 'CAPTURE_TRANSITION_RETRY_NOT_SETTLED');
259
+ }
260
+ })
261
+ .catch(error => {
262
+ failOwner(owner, 'CAPTURE_TRANSITION_RETRY_FAILED', {
263
+ detail: error instanceof Error ? error.message : String(error || '')
264
+ });
265
+ });
266
+ }, Math.min(retryAfterMs, remainingMs));
267
+ owner.timer?.unref?.();
268
+ },
269
+ error => {
270
+ failOwner(owner, 'CAPTURE_TRANSITION_WAIT_FAILED', {
271
+ detail: error instanceof Error ? error.message : String(error || '')
272
+ });
273
+ }
274
+ );
275
+ return true;
276
+ }
277
+
278
+ function snapshot() {
279
+ const owners = [...activeOwners].filter(ownerIsCurrent);
280
+ return {
281
+ activeOwnerCount: owners.length,
282
+ timerCount: owners.filter(owner => !!owner.timer).length,
283
+ waitingStopCount: owners.filter(owner => owner.phase === 'waiting-stop').length,
284
+ retryingCount: owners.filter(owner => owner.phase === 'retrying').length,
285
+ attemptCount: owners.reduce((total, owner) => total + owner.attemptCount, 0),
286
+ terminal: owners.length === 0
287
+ };
288
+ }
289
+
290
+ return Object.freeze({
291
+ beginIntent,
292
+ schedule,
293
+ complete,
294
+ cancelClient,
295
+ snapshot
296
+ });
297
+ }
@@ -51,6 +51,7 @@ export class Mode4AtlasPool {
51
51
  : callbacks => new Mode4AtlasSession(callbacks);
52
52
  this.onStatus = typeof options.onStatus === 'function' ? options.onStatus : () => {};
53
53
  this.entries = new Map();
54
+ this.closingByKey = new Map();
54
55
  }
55
56
 
56
57
  acquire(config, callbacks = {}) {
@@ -65,6 +66,7 @@ export class Mode4AtlasPool {
65
66
  streamId: `mode4-atlas-shared-${randomUUID()}`
66
67
  };
67
68
  entry.session = this.createSession({
69
+ startAfter: this.closingByKey.get(key) || null,
68
70
  onFrame: output => {
69
71
  for (const listener of entry.clients.values()) listener.onFrame?.(output);
70
72
  },
@@ -100,7 +102,14 @@ export class Mode4AtlasPool {
100
102
  entry.clients.delete(clientId);
101
103
  if (entry.clients.size === 0) {
102
104
  this.entries.delete(key);
103
- entry.session.close();
105
+ const closePromise = Promise.resolve(entry.session.close());
106
+ this.closingByKey.set(key, closePromise);
107
+ const retireCloseOwner = () => {
108
+ if (this.closingByKey.get(key) === closePromise) {
109
+ this.closingByKey.delete(key);
110
+ }
111
+ };
112
+ void closePromise.then(retireCloseOwner, retireCloseOwner);
104
113
  }
105
114
  }
106
115
  };
@@ -116,8 +125,13 @@ export class Mode4AtlasPool {
116
125
  return this.entries.size;
117
126
  }
118
127
 
119
- close() {
120
- for (const entry of this.entries.values()) entry.session.close();
128
+ async close() {
129
+ const closePromises = [...this.closingByKey.values()];
130
+ for (const entry of this.entries.values()) {
131
+ closePromises.push(Promise.resolve(entry.session.close()));
132
+ }
121
133
  this.entries.clear();
134
+ this.closingByKey.clear();
135
+ await Promise.allSettled(closePromises);
122
136
  }
123
137
  }
@@ -36,6 +36,10 @@ let encoderOutputSeen = false;
36
36
  let encoderGeneration = 0;
37
37
  let encoderRestartTimer = null;
38
38
  let encoderStartupTimer = null;
39
+ let encoderStopPromise = null;
40
+ let configureRevision = 0;
41
+ let configureChain = Promise.resolve();
42
+ let shutdownPromise = null;
39
43
  let inputWaitTimer = null;
40
44
  let outputBuffer = Buffer.alloc(0);
41
45
  let frameSeq = 0;
@@ -48,6 +52,9 @@ let inputRevision = 0;
48
52
  let lastEncodedInputRevision = -1;
49
53
  let lastAtlasWriteAtEpochMs = 0;
50
54
  const ENCODER_STARTUP_OUTPUT_TIMEOUT_MS = 8_000;
55
+ const ENCODER_STDIN_DRAIN_MS = 250;
56
+ const ENCODER_TERM_EXIT_MS = 500;
57
+ const ENCODER_KILL_EXIT_MS = 750;
51
58
  const ATLAS_KEEPALIVE_MS = 1_000;
52
59
 
53
60
  function clamp(value, min, max, fallback) {
@@ -128,7 +135,7 @@ function buildEncoderCandidates() {
128
135
  }
129
136
 
130
137
  function startEncoder() {
131
- if (closed || encoder || encoderCandidates.length === 0) return;
138
+ if (closed || encoder || encoderStopPromise || encoderCandidates.length === 0) return;
132
139
  if (encoderCandidateIndex >= encoderCandidates.length) {
133
140
  send({ type: 'status', state: 'error', error: 'No Mode 4 H.264 encoder is available.' });
134
141
  return;
@@ -144,9 +151,23 @@ function startEncoder() {
144
151
  '-bsf:v', 'h264_metadata=aud=insert', '-f', 'h264', 'pipe:1'
145
152
  ];
146
153
  const child = spawn(candidate.path, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
147
- encoder = { child, candidate, inputSeen: false };
154
+ const encoderEntry = {
155
+ child,
156
+ candidate,
157
+ inputSeen: false,
158
+ stopping: false,
159
+ intentionalStop: false
160
+ };
161
+ encoder = encoderEntry;
148
162
  encoderOutputSeen = false;
149
163
  outputBuffer = Buffer.alloc(0);
164
+ send({
165
+ type: 'encoder-owner',
166
+ state: 'active',
167
+ pid: Number(child.pid || 0),
168
+ encoderGeneration,
169
+ encoder: candidate.name
170
+ });
150
171
  child.stdout.on('data', appendEncodedBytes);
151
172
  child.stdin.on('error', error => {
152
173
  if (encoder?.child === child && !isClosedPipeError(error)) {
@@ -163,8 +184,20 @@ function startEncoder() {
163
184
  encoderStartupTimer = null;
164
185
  const wasCurrent = encoder?.child === child;
165
186
  if (wasCurrent) encoder = null;
166
- flushEncodedUnit();
167
- if (closed) return;
187
+ send({
188
+ type: 'encoder-owner',
189
+ state: 'exited',
190
+ pid: Number(child.pid || 0),
191
+ encoderGeneration,
192
+ encoder: candidate.name,
193
+ code
194
+ });
195
+ if (!closed && !encoderEntry.intentionalStop) {
196
+ flushEncodedUnit();
197
+ } else {
198
+ outputBuffer = Buffer.alloc(0);
199
+ }
200
+ if (closed || encoderEntry.intentionalStop) return;
168
201
  send({ type: 'status', state: 'encoder-restart', encoder: candidate.name, code, emitted: encoderOutputSeen });
169
202
  clearTimeout(encoderRestartTimer);
170
203
  encoderRestartTimer = setTimeout(startEncoder, encoderOutputSeen ? 800 : 120);
@@ -189,16 +222,71 @@ function armEncoderStartupTimer(child, candidate) {
189
222
  }, ENCODER_STARTUP_OUTPUT_TIMEOUT_MS);
190
223
  }
191
224
 
192
- function stopEncoder() {
225
+ function waitForChildExit(child, timeoutMs) {
226
+ if (!child || child.exitCode !== null || child.signalCode !== null) {
227
+ return Promise.resolve(true);
228
+ }
229
+ return new Promise(resolve => {
230
+ let settled = false;
231
+ let timer = null;
232
+ const finish = exited => {
233
+ if (settled) return;
234
+ settled = true;
235
+ if (timer) clearTimeout(timer);
236
+ child.off('exit', handleExit);
237
+ child.off('close', handleExit);
238
+ resolve(exited);
239
+ };
240
+ const handleExit = () => finish(true);
241
+ child.once('exit', handleExit);
242
+ child.once('close', handleExit);
243
+ timer = setTimeout(() => finish(
244
+ child.exitCode !== null || child.signalCode !== null
245
+ ), Math.max(1, Number(timeoutMs || 0)));
246
+ timer.unref?.();
247
+ });
248
+ }
249
+
250
+ async function stopEncoder(reason = 'stop') {
193
251
  clearTimeout(encoderRestartTimer);
194
252
  encoderRestartTimer = null;
195
253
  clearTimeout(encoderStartupTimer);
196
254
  encoderStartupTimer = null;
197
- const child = encoder?.child;
198
- encoder = null;
199
- if (!child) return;
200
- try { child.stdin.end(); } catch {}
201
- setTimeout(() => { try { child.kill(); } catch {} }, 250).unref?.();
255
+ if (encoderStopPromise) return encoderStopPromise;
256
+ const encoderEntry = encoder;
257
+ const child = encoderEntry?.child;
258
+ if (!child) return true;
259
+ encoderEntry.stopping = true;
260
+ encoderEntry.intentionalStop = true;
261
+
262
+ const stopPromise = (async () => {
263
+ try { child.stdin.end(); } catch {}
264
+ let exited = await waitForChildExit(child, ENCODER_STDIN_DRAIN_MS);
265
+ if (!exited) {
266
+ try { child.kill('SIGTERM'); } catch {}
267
+ exited = await waitForChildExit(child, ENCODER_TERM_EXIT_MS);
268
+ }
269
+ if (!exited) {
270
+ try { child.kill('SIGKILL'); } catch {}
271
+ exited = await waitForChildExit(child, ENCODER_KILL_EXIT_MS);
272
+ }
273
+ if (!exited) {
274
+ send({
275
+ type: 'status',
276
+ state: 'encoder-stop-failed',
277
+ error: `Mode 4 encoder pid ${Number(child.pid || 0)} did not exit during ${reason}.`,
278
+ pid: Number(child.pid || 0),
279
+ encoderGeneration
280
+ });
281
+ return false;
282
+ }
283
+ if (encoder?.child === child) encoder = null;
284
+ return true;
285
+ })().finally(() => {
286
+ if (encoderStopPromise === stopPromise) encoderStopPromise = null;
287
+ });
288
+ encoderStopPromise = stopPromise;
289
+ return stopPromise;
202
290
  }
203
291
 
204
292
  function findStartCodes(buffer) {
@@ -374,7 +462,8 @@ function ingestFrame(message) {
374
462
 
375
463
  function writeAtlasFrame() {
376
464
  if (latestTiles.size === 0) return;
377
- if (!encoder) {
465
+ if (!encoder || encoder.stopping) {
466
+ if (encoder?.stopping) return;
378
467
  startEncoder();
379
468
  return;
380
469
  }
@@ -438,7 +527,8 @@ function startComposeClock() {
438
527
  tickTimer = setTimeout(tick, intervalMs);
439
528
  }
440
529
 
441
- function configure(value) {
530
+ async function configure(value, revision) {
531
+ if (closed) return;
442
532
  const next = normalizeConfig(value);
443
533
  const encoderChanged = next.width !== config.width || next.height !== config.height || next.fps !== config.fps;
444
534
  config = next;
@@ -457,7 +547,8 @@ function configure(value) {
457
547
  }, 3000) : null;
458
548
  rebuildLayout();
459
549
  if (encoderChanged || !encoder) {
460
- stopEncoder();
550
+ const stopped = await stopEncoder('reconfigure');
551
+ if (!stopped || closed || revision !== configureRevision) return;
461
552
  encoderCandidates = buildEncoderCandidates();
462
553
  encoderCandidateIndex = 0;
463
554
  if (latestTiles.size > 0) startEncoder();
@@ -465,22 +556,44 @@ function configure(value) {
465
556
  startComposeClock();
466
557
  }
467
558
 
559
+ function queueConfigure(value) {
560
+ const revision = ++configureRevision;
561
+ configureChain = configureChain
562
+ .then(() => configure(value, revision))
563
+ .catch(error => {
564
+ if (!closed) {
565
+ send({
566
+ type: 'status',
567
+ state: 'configure-error',
568
+ error: error instanceof Error ? error.message : String(error)
569
+ });
570
+ }
571
+ });
572
+ return configureChain;
573
+ }
574
+
468
575
  process.on('message', message => {
469
- if (message?.type === 'configure') configure(message);
576
+ if (message?.type === 'configure') void queueConfigure(message);
470
577
  else if (message?.type === 'frame') ingestFrame(message);
471
- else if (message?.type === 'close') shutdown();
578
+ else if (message?.type === 'close') void shutdown();
472
579
  });
473
580
 
474
581
  function shutdown() {
475
- if (closed) return;
582
+ if (shutdownPromise) return shutdownPromise;
476
583
  closed = true;
477
584
  clearTimeout(tickTimer);
478
585
  clearTimeout(inputWaitTimer);
479
- stopEncoder();
480
- setTimeout(() => process.exit(0), 50).unref?.();
586
+ clearTimeout(encoderRestartTimer);
587
+ clearTimeout(encoderStartupTimer);
588
+ shutdownPromise = (async () => {
589
+ await configureChain.catch(() => {});
590
+ const encoderExited = await stopEncoder('worker-shutdown');
591
+ process.exit(encoderExited ? 0 : 1);
592
+ })();
593
+ return shutdownPromise;
481
594
  }
482
595
 
483
- process.on('disconnect', shutdown);
484
- process.on('SIGTERM', shutdown);
485
- process.on('SIGINT', shutdown);
596
+ process.on('disconnect', () => { void shutdown(); });
597
+ process.on('SIGTERM', () => { void shutdown(); });
598
+ process.on('SIGINT', () => { void shutdown(); });
486
599
  send({ type: 'status', state: 'ready' });