@livedesk/hub 0.1.4 → 0.1.6

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/src/server.js CHANGED
@@ -3,13 +3,14 @@
3
3
  import express from 'express';
4
4
  import crypto from 'node:crypto';
5
5
  import { createServer } from 'node:http';
6
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import os from 'node:os';
10
10
  import { WebSocketServer } from 'ws';
11
- import { createRemoteHub } from './remote-hub.js';
12
- import { Mode4AtlasSession } from './mode4-atlas.js';
11
+ import { createRemoteHub } from './remote-hub.js';
12
+ import { buildMode4AtlasSessionKey, Mode4AtlasPool, planMode4AtlasInputTransitions } from './mode4-atlas-pool.js';
13
+ import { resolveMode4AtlasTileSize } from './mode4-atlas-sizing.js';
13
14
  import { createHubFilesystem } from './filesystem/hub-filesystem.js';
14
15
  import { createHubTransferJobs } from './filesystem/transfer-jobs.js';
15
16
  import { createHubSharedFolders } from './filesystem/shared-folders.js';
@@ -59,19 +60,31 @@ const runtimeManager = createHubRuntime({
59
60
  runtimePid: process.pid
60
61
  });
61
62
  runtimeManager.emit('runtime.started', { role: runtimeRole, pid: process.pid });
62
- const frameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_BACKPRESSURE_BYTES', 64 * 1024 * 1024);
63
- const frameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_QUEUE_PACKETS', 256);
64
- const controlFrameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_CONTROL_FRAME_WS_QUEUE_PACKETS', 4);
65
- const frameClientDrainBudgetPackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_DRAIN_BUDGET_PACKETS', 64);
66
- const frameStreamStopGraceMs = readPositiveIntegerEnv('LIVEDESK_FRAME_STREAM_STOP_GRACE_MS', 1500);
67
- const mode5FrameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_BACKPRESSURE_BYTES', 2 * 1024 * 1024);
68
- const mode5FrameQueuePackets = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_QUEUE_PACKETS', 2);
69
- const audioBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_AUDIO_WS_BACKPRESSURE_BYTES', 96 * 1024);
70
- const frameClients = new Set();
63
+ const frameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_BACKPRESSURE_BYTES', 8 * 1024 * 1024);
64
+ const frameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_FRAME_WS_QUEUE_PACKETS', 4);
65
+ const controlFrameClientQueuePackets = readPositiveIntegerEnv('LIVEDESK_CONTROL_FRAME_WS_QUEUE_PACKETS', 2);
66
+ const frameStreamStopGraceMs = readPositiveIntegerEnv('LIVEDESK_FRAME_STREAM_STOP_GRACE_MS', 1500);
67
+ const mode5FrameBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_BACKPRESSURE_BYTES', 2 * 1024 * 1024);
68
+ const mode5FrameQueuePackets = readPositiveIntegerEnv('LIVEDESK_MODE5_WS_QUEUE_PACKETS', 2);
69
+ const audioBackpressureBytes = readPositiveIntegerEnv('LIVEDESK_AUDIO_WS_BACKPRESSURE_BYTES', 96 * 1024);
70
+ const webSocketHeartbeatMs = readPositiveIntegerEnv('LIVEDESK_BROWSER_WS_HEARTBEAT_MS', 15_000);
71
+ const hubShutdownGraceMs = readPositiveIntegerEnv('LIVEDESK_HUB_SHUTDOWN_GRACE_MS', 1_500);
72
+ const hubShutdownTimeoutMs = Math.max(
73
+ hubShutdownGraceMs + 1_000,
74
+ readPositiveIntegerEnv('LIVEDESK_HUB_SHUTDOWN_TIMEOUT_MS', 8_000)
75
+ );
76
+ const frameClients = new Set();
71
77
  const frameClientsByDeviceId = new Map();
72
78
  const frameWildcardClients = new Set();
73
79
  const pendingFrameStreamStops = new Map();
74
- const atlasClients = new Map();
80
+ const atlasClients = new Map();
81
+ const atlasPool = new Mode4AtlasPool({
82
+ onStatus: status => {
83
+ if (status?.type !== 'log') return;
84
+ const logger = status.level === 'warn' ? console.warn : console.log;
85
+ logger(`[LiveDesk Hub] ${status.message}`);
86
+ }
87
+ });
75
88
  const inputClients = new Set();
76
89
  const audioClients = new Set();
77
90
  const FREE_DEVICE_LIMIT = 5;
@@ -94,6 +107,10 @@ const supabasePublishableKey = String(configuredSupabaseKey || PRODUCTION_SUPABA
94
107
  const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
95
108
  ? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
96
109
  : AUTH_REQUEST_TIMEOUT_MS;
110
+ const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
111
+ const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
112
+ ? ''
113
+ : String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
97
114
  const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
98
115
  && ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
99
116
  ? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
@@ -111,9 +128,10 @@ let verifiedLicense = {
111
128
  verifiedAt: 0
112
129
  };
113
130
  let frameClientSeq = 0;
114
- let inputClientSeq = 0;
115
- let audioClientSeq = 0;
131
+ let inputClientSeq = 0;
132
+ let audioClientSeq = 0;
116
133
  let liveDeskUpdateManager = null;
134
+ let hubTransferJobs = null;
117
135
  const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
118
136
  const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
119
137
  readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
@@ -124,6 +142,7 @@ const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk
124
142
  const HUB_WAKE_NOTIFY_RETRY_MS = 60_000;
125
143
  let hubHostTargetRenewTimer = null;
126
144
  let hubHostTargetRenewInFlight = false;
145
+ let roleTransitionTakeoverPending = process.env.LIVEDESK_ROLE_TAKEOVER === '1';
127
146
  let hubHostTargetLeaseState = {
128
147
  state: 'idle',
129
148
  lastAttemptAt: '',
@@ -148,8 +167,9 @@ function readPositiveIntegerEnv(name, fallback) {
148
167
  return Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
149
168
  }
150
169
 
151
- function handleRemoteHubEvent(type, event) {
152
- liveDeskUpdateManager?.handleRemoteEvent(type, event);
170
+ function handleRemoteHubEvent(type, event) {
171
+ liveDeskUpdateManager?.handleRemoteEvent(type, event);
172
+ hubTransferJobs?.handleRemoteEvent(type, event);
153
173
  if (type === 'RemoteInputApplied') {
154
174
  for (const ws of inputClients) {
155
175
  sendJson(ws, {
@@ -159,16 +179,16 @@ function handleRemoteHubEvent(type, event) {
159
179
  }
160
180
  return;
161
181
  }
162
- if (type === 'RemoteInputError') {
182
+ if (type === 'RemoteInputError') {
163
183
  for (const ws of inputClients) {
164
184
  sendJson(ws, {
165
185
  type: 'RemoteInputError',
166
186
  ...event
167
187
  });
168
- }
169
- return;
170
- }
171
- if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
188
+ }
189
+ return;
190
+ }
191
+ if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
172
192
  connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
173
193
  }
174
194
  if (type !== 'RemoteDeviceConnected') {
@@ -293,11 +313,82 @@ function normalizeSessionExpiryMs(value) {
293
313
  return numeric < 100_000_000_000 ? Math.round(numeric * 1000) : Math.round(numeric);
294
314
  }
295
315
 
316
+ function readRuntimeAuthState() {
317
+ if (!runtimeAuthStatePath) return {};
318
+ try {
319
+ const state = JSON.parse(readFileSync(runtimeAuthStatePath, 'utf8'));
320
+ return state && typeof state === 'object' ? state : {};
321
+ } catch {
322
+ return {};
323
+ }
324
+ }
325
+
326
+ function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
327
+ try {
328
+ const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
329
+ const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
330
+ return session && typeof session === 'object' ? session : null;
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ function writePrivateRuntimeAuthState(state) {
337
+ if (!runtimeAuthStatePath) return false;
338
+ mkdirSync(dirname(runtimeAuthStatePath), { recursive: true });
339
+ const temporaryPath = `${runtimeAuthStatePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
340
+ try {
341
+ writeFileSync(temporaryPath, JSON.stringify(state, null, 2), 'utf8');
342
+ try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
343
+ renameSync(temporaryPath, runtimeAuthStatePath);
344
+ try { chmodSync(runtimeAuthStatePath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
345
+ return true;
346
+ } catch (error) {
347
+ try { rmSync(temporaryPath, { force: true }); } catch { /* best effort */ }
348
+ throw error;
349
+ }
350
+ }
351
+
352
+ function persistRuntimeSession(session) {
353
+ if (!runtimeAuthStatePath) return false;
354
+ const state = readRuntimeAuthState();
355
+ const previous = readPersistedRuntimeSession(state) || {};
356
+ const incomingUserId = String(session?.user?.id || '').trim();
357
+ const previousUserId = String(previous?.user?.id || '').trim();
358
+ const sameUser = !incomingUserId || !previousUserId || incomingUserId === previousUserId;
359
+ const refreshToken = String(
360
+ session?.refresh_token
361
+ || (sameUser ? previous?.refresh_token : '')
362
+ || ''
363
+ ).trim();
364
+ const normalized = normalizeRuntimeAuthSession({
365
+ ...previous,
366
+ ...session,
367
+ refresh_token: refreshToken,
368
+ user: {
369
+ ...(sameUser && previous?.user && typeof previous.user === 'object' ? previous.user : {}),
370
+ ...(session?.user && typeof session.user === 'object' ? session.user : {})
371
+ }
372
+ }, { requireRefreshToken: true });
373
+ if (!normalized.ok) return false;
374
+ state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
375
+ writePrivateRuntimeAuthState(state);
376
+ return true;
377
+ }
378
+
379
+ function clearPersistedRuntimeSession() {
380
+ if (!runtimeAuthStatePath) return false;
381
+ const state = readRuntimeAuthState();
382
+ delete state[CLIENT_AUTH_STORAGE_KEY];
383
+ return writePrivateRuntimeAuthState(state);
384
+ }
385
+
296
386
  function clearRuntimeSession() {
297
387
  runtimeAccessToken = '';
298
388
  runtimeRefreshToken = '';
299
389
  runtimeAccessTokenExpiresAt = 0;
300
390
  runtimeManager.setAuthenticated(false);
391
+ try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
301
392
  }
302
393
 
303
394
  async function getRuntimeAccessToken() {
@@ -343,6 +434,15 @@ async function getRuntimeAccessToken() {
343
434
  runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
344
435
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
345
436
  || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
437
+ try {
438
+ persistRuntimeSession({
439
+ access_token: runtimeAccessToken,
440
+ refresh_token: runtimeRefreshToken,
441
+ expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
442
+ });
443
+ } catch (error) {
444
+ console.warn(`[LiveDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
445
+ }
346
446
  return runtimeAccessToken;
347
447
  }
348
448
 
@@ -500,7 +600,7 @@ const remoteHub = createRemoteHub({
500
600
  }
501
601
  });
502
602
 
503
- function requestHubRestart(request = {}) {
603
+ function requestHubRestart(request = {}) {
504
604
  const requestPath = String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim();
505
605
  if (!requestPath) {
506
606
  return { ok: false, error: 'hub-launcher-restart-unavailable' };
@@ -517,10 +617,76 @@ function requestHubRestart(request = {}) {
517
617
  return { ok: true };
518
618
  } catch (error) {
519
619
  return { ok: false, error: `hub-restart-request-failed: ${error instanceof Error ? error.message : String(error)}` };
520
- }
521
- }
522
-
523
- if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
620
+ }
621
+ }
622
+
623
+ const HUB_RESTART_RESULT_STAGES = new Set([
624
+ 'supervisor-starting',
625
+ 'preflight-failed',
626
+ 'waiting-for-old-launcher',
627
+ 'old-launcher-exited',
628
+ 'target-attempt',
629
+ 'target-failed',
630
+ 'fallback-attempt',
631
+ 'fallback-failed',
632
+ 'updated',
633
+ 'restored',
634
+ 'failed'
635
+ ]);
636
+ const HUB_RESTART_RESULT_OUTCOMES = new Set(['in-progress', 'updated', 'restored', 'failed']);
637
+
638
+ function readHubRestartResult() {
639
+ const resultPath = String(process.env.LIVEDESK_HUB_UPDATE_RESULT_PATH || '').trim();
640
+ if (!resultPath || !existsSync(resultPath)) return null;
641
+ try {
642
+ const raw = JSON.parse(readFileSync(resultPath, 'utf8'));
643
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
644
+ const operationId = String(raw.operationId || '').trim().slice(0, 160);
645
+ const stage = String(raw.stage || '').trim();
646
+ const outcome = String(raw.outcome || '').trim();
647
+ if (!operationId || !HUB_RESTART_RESULT_STAGES.has(stage) || !HUB_RESTART_RESULT_OUTCOMES.has(outcome)) {
648
+ return null;
649
+ }
650
+ const text = (value, maximum = 200) => String(value || '').trim().slice(0, maximum);
651
+ const positiveInteger = value => {
652
+ const numeric = Number(value);
653
+ return Number.isInteger(numeric) && numeric > 0 ? numeric : undefined;
654
+ };
655
+ return {
656
+ operationId,
657
+ stage,
658
+ outcome,
659
+ targetVersion: text(raw.targetVersion),
660
+ fallbackVersion: text(raw.fallbackVersion),
661
+ appVersion: text(raw.appVersion),
662
+ attempt: positiveInteger(raw.attempt),
663
+ previousRuntimePid: positiveInteger(raw.previousRuntimePid),
664
+ supervisorPid: positiveInteger(raw.supervisorPid),
665
+ launcherPid: positiveInteger(raw.launcherPid),
666
+ runtimePid: positiveInteger(raw.runtimePid),
667
+ startedAt: text(raw.startedAt, 64),
668
+ updatedAt: text(raw.updatedAt, 64),
669
+ completedAt: text(raw.completedAt, 64),
670
+ error: text(raw.error, 4_000)
671
+ };
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+
677
+ function getLiveDeskUpdateStatus() {
678
+ const restartResult = readHubRestartResult();
679
+ liveDeskUpdateManager?.reconcileHubRestartResult(restartResult);
680
+ return {
681
+ ...(liveDeskUpdateManager?.getStatus() || {
682
+ updateAvailable: false,
683
+ state: 'unavailable'
684
+ }),
685
+ restartResult
686
+ };
687
+ }
688
+
689
+ if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
524
690
  liveDeskUpdateManager = createLiveDeskUpdateManager({
525
691
  remoteHub,
526
692
  currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
@@ -1044,23 +1210,58 @@ async function synchronizeAgentEnablement() {
1044
1210
  }
1045
1211
 
1046
1212
  const hubFilesystem = createHubFilesystem();
1047
- const hubTransferJobs = createHubTransferJobs({
1048
- filesystem: hubFilesystem,
1049
- remoteHub,
1050
- maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2)
1051
- });
1213
+ hubTransferJobs = createHubTransferJobs({
1214
+ filesystem: hubFilesystem,
1215
+ remoteHub,
1216
+ maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
1217
+ commandResultTimeoutMs: readPositiveIntegerEnv('LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS', 30_000)
1218
+ });
1052
1219
  const hubSharedFolders = createHubSharedFolders({
1053
1220
  filesystem: hubFilesystem,
1054
1221
  transferJobs: hubTransferJobs,
1055
1222
  remoteHub
1056
1223
  });
1057
1224
 
1058
- const app = express();
1059
- const httpServer = createServer(app);
1060
- const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1061
- const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1062
- const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1063
- const audioWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1225
+ const app = express();
1226
+ const httpServer = createServer(app);
1227
+ const httpConnections = new Set();
1228
+ const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1229
+ const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1230
+ const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1231
+ const audioWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
1232
+ const browserWebSocketServers = [frameWss, atlasWss, inputWss, audioWss];
1233
+
1234
+ httpServer.on('connection', socket => {
1235
+ httpConnections.add(socket);
1236
+ socket.once('close', () => httpConnections.delete(socket));
1237
+ });
1238
+
1239
+ for (const wss of browserWebSocketServers) {
1240
+ wss.on('connection', ws => {
1241
+ ws.liveDeskHeartbeatAlive = true;
1242
+ ws.on('pong', () => {
1243
+ ws.liveDeskHeartbeatAlive = true;
1244
+ });
1245
+ });
1246
+ }
1247
+
1248
+ const browserWebSocketHeartbeatTimer = setInterval(() => {
1249
+ for (const wss of browserWebSocketServers) {
1250
+ for (const ws of wss.clients) {
1251
+ if (ws.liveDeskHeartbeatAlive === false) {
1252
+ ws.terminate();
1253
+ continue;
1254
+ }
1255
+ ws.liveDeskHeartbeatAlive = false;
1256
+ try {
1257
+ ws.ping();
1258
+ } catch {
1259
+ ws.terminate();
1260
+ }
1261
+ }
1262
+ }
1263
+ }, webSocketHeartbeatMs);
1264
+ browserWebSocketHeartbeatTimer.unref?.();
1064
1265
 
1065
1266
  function normalizeHostname(value) {
1066
1267
  return String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
@@ -1340,25 +1541,27 @@ function forgetWebSocketClient(ws) {
1340
1541
  }
1341
1542
  }
1342
1543
 
1343
- function safeWebSocketSend(ws, payload, options = undefined) {
1344
- if (!ws || ws.readyState !== 1) {
1345
- return false;
1346
- }
1347
-
1348
- try {
1349
- ws.send(payload, options, err => {
1350
- if (err) {
1351
- forgetWebSocketClient(ws);
1352
- }
1353
- });
1354
- return true;
1355
- } catch {
1356
- forgetWebSocketClient(ws);
1357
- return false;
1358
- }
1359
- }
1544
+ function safeWebSocketSend(ws, payload, options = undefined, onComplete = undefined) {
1545
+ if (!ws || ws.readyState !== 1) {
1546
+ return false;
1547
+ }
1548
+
1549
+ try {
1550
+ ws.send(payload, options, err => {
1551
+ if (err) {
1552
+ forgetWebSocketClient(ws);
1553
+ }
1554
+ onComplete?.(err || null);
1555
+ });
1556
+ return true;
1557
+ } catch (error) {
1558
+ forgetWebSocketClient(ws);
1559
+ onComplete?.(error);
1560
+ return false;
1561
+ }
1562
+ }
1360
1563
 
1361
- function unregisterFrameClient(ws) {
1564
+ function unregisterFrameClient(ws) {
1362
1565
  if (!ws) {
1363
1566
  return;
1364
1567
  }
@@ -1373,10 +1576,16 @@ function unregisterFrameClient(ws) {
1373
1576
  if (clients.size === 0) {
1374
1577
  frameClientsByDeviceId.delete(deviceId);
1375
1578
  }
1376
- }
1377
- frameWildcardClients.delete(ws);
1378
- ws.liveDeskFrameSendLane?.queue?.splice?.(0);
1379
- }
1579
+ }
1580
+ frameWildcardClients.delete(ws);
1581
+ ws.liveDeskExpectedStreamBindingsByDeviceId?.clear?.();
1582
+ const lane = ws.liveDeskFrameSendLane;
1583
+ if (lane) {
1584
+ clearTimeout(lane.retryTimer);
1585
+ lane.retryTimer = null;
1586
+ lane.queue.splice(0);
1587
+ }
1588
+ }
1380
1589
 
1381
1590
  function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
1382
1591
  if (!streamId) {
@@ -1476,11 +1685,12 @@ function registerFrameClient(ws) {
1476
1685
  function ensureFrameClientSendLane(ws) {
1477
1686
  if (!ws.liveDeskFrameSendLane) {
1478
1687
  ws.liveDeskFrameSendLane = {
1479
- queue: [],
1480
- draining: false,
1481
- dropped: 0,
1482
- awaitingKeyFrames: new Set()
1483
- };
1688
+ queue: [],
1689
+ sending: false,
1690
+ retryTimer: null,
1691
+ dropped: 0,
1692
+ awaitingKeyFrames: new Set()
1693
+ };
1484
1694
  }
1485
1695
  return ws.liveDeskFrameSendLane;
1486
1696
  }
@@ -1528,39 +1738,48 @@ function dropQueuedMode5FrameForLane(lane, deviceId) {
1528
1738
  return true;
1529
1739
  }
1530
1740
 
1531
- function drainFrameClientSendLane(ws) {
1532
- const lane = ws.liveDeskFrameSendLane;
1533
- if (!lane || lane.draining) {
1534
- return;
1535
- }
1536
- lane.draining = true;
1537
- setImmediate(() => {
1538
- lane.draining = false;
1539
- if (!ws || ws.readyState !== ws.OPEN) {
1540
- lane.queue.length = 0;
1541
- return;
1542
- }
1543
-
1544
- let sent = 0;
1545
- while (lane.queue.length > 0 && sent < frameClientDrainBudgetPackets) {
1546
- if (ws.bufferedAmount > frameBackpressureBytes) {
1547
- ws.liveDeskFrameBackpressured = true;
1548
- break;
1549
- }
1550
- const item = lane.queue.shift();
1551
- if (!item) {
1552
- continue;
1553
- }
1554
- ws.liveDeskFrameBackpressured = false;
1555
- safeWebSocketSend(ws, item.packet, { binary: true });
1556
- sent += 1;
1557
- }
1558
-
1559
- if (lane.queue.length > 0 && ws.readyState === ws.OPEN) {
1560
- drainFrameClientSendLane(ws);
1561
- }
1562
- });
1563
- }
1741
+ function drainFrameClientSendLane(ws) {
1742
+ const lane = ws.liveDeskFrameSendLane;
1743
+ if (!lane || lane.sending) {
1744
+ return;
1745
+ }
1746
+ clearTimeout(lane.retryTimer);
1747
+ lane.retryTimer = null;
1748
+ if (!ws || ws.readyState !== ws.OPEN) {
1749
+ lane.queue.length = 0;
1750
+ return;
1751
+ }
1752
+ const item = lane.queue[0];
1753
+ if (!item) {
1754
+ return;
1755
+ }
1756
+ const backpressureBytes = item.isMode5 ? mode5FrameBackpressureBytes : frameBackpressureBytes;
1757
+ if (ws.bufferedAmount > backpressureBytes) {
1758
+ ws.liveDeskFrameBackpressured = true;
1759
+ lane.retryTimer = setTimeout(() => {
1760
+ lane.retryTimer = null;
1761
+ drainFrameClientSendLane(ws);
1762
+ }, 10);
1763
+ lane.retryTimer.unref?.();
1764
+ return;
1765
+ }
1766
+
1767
+ lane.queue.shift();
1768
+ lane.sending = true;
1769
+ ws.liveDeskFrameBackpressured = false;
1770
+ const accepted = safeWebSocketSend(ws, item.packet, { binary: true }, error => {
1771
+ lane.sending = false;
1772
+ if (error || ws.readyState !== ws.OPEN) {
1773
+ lane.queue.length = 0;
1774
+ return;
1775
+ }
1776
+ drainFrameClientSendLane(ws);
1777
+ });
1778
+ if (!accepted) {
1779
+ lane.sending = false;
1780
+ lane.queue.length = 0;
1781
+ }
1782
+ }
1564
1783
 
1565
1784
  function enqueueFramePacketForClient(ws, packet, meta) {
1566
1785
  if (!ws || ws.readyState !== ws.OPEN) {
@@ -1610,7 +1829,7 @@ function enqueueFramePacketForClient(ws, packet, meta) {
1610
1829
  return true;
1611
1830
  }
1612
1831
 
1613
- function updateFrameSubscription(ws, payload = {}) {
1832
+ function updateFrameSubscription(ws, payload = {}) {
1614
1833
  const previousDeviceIds = ws.liveDeskDeviceIds instanceof Set
1615
1834
  ? new Set(ws.liveDeskDeviceIds)
1616
1835
  : new Set();
@@ -1620,14 +1839,18 @@ function updateFrameSubscription(ws, payload = {}) {
1620
1839
  registerFrameClient(ws);
1621
1840
  ws.liveDeskAutoStart = /^(1|true|yes|on|live)$/i.test(String(payload.autoStartLive ?? payload.startLive ?? ''));
1622
1841
  ws.liveDeskLiveOptions = normalizeLiveOptions(payload);
1623
- if (!(ws.liveDeskStreamIdsByDeviceId instanceof Map)) {
1624
- ws.liveDeskStreamIdsByDeviceId = new Map();
1625
- }
1626
- for (const subscribedDeviceId of ws.liveDeskStreamIdsByDeviceId.keys()) {
1627
- if (!ws.liveDeskDeviceIds.has(subscribedDeviceId)) {
1628
- ws.liveDeskStreamIdsByDeviceId.delete(subscribedDeviceId);
1629
- }
1630
- }
1842
+ if (!(ws.liveDeskStreamIdsByDeviceId instanceof Map)) {
1843
+ ws.liveDeskStreamIdsByDeviceId = new Map();
1844
+ }
1845
+ if (!(ws.liveDeskExpectedStreamBindingsByDeviceId instanceof Map)) {
1846
+ ws.liveDeskExpectedStreamBindingsByDeviceId = new Map();
1847
+ }
1848
+ for (const subscribedDeviceId of ws.liveDeskStreamIdsByDeviceId.keys()) {
1849
+ if (!ws.liveDeskDeviceIds.has(subscribedDeviceId)) {
1850
+ ws.liveDeskStreamIdsByDeviceId.delete(subscribedDeviceId);
1851
+ ws.liveDeskExpectedStreamBindingsByDeviceId.delete(subscribedDeviceId);
1852
+ }
1853
+ }
1631
1854
  sendJson(ws, {
1632
1855
  type: 'RemoteFrameSubscription',
1633
1856
  timestamp: new Date().toISOString(),
@@ -1681,15 +1904,46 @@ function startFrameSubscriptionLive(ws, reason = 'subscribe', onlyDeviceId = '',
1681
1904
  reuseExisting: liveOptions.forceRestart !== true
1682
1905
  && (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
1683
1906
  silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
1684
- });
1685
- if (result?.ok) {
1686
- ws.liveDeskStreamIdsByDeviceId.set(deviceId, result.streamId);
1687
- cancelPendingFrameStreamStop(deviceId, result.streamId, String(liveOptions.streamPurpose || 'wall'));
1688
- started.push({ deviceId, streamId: result.streamId, fps: result.fps, reused: result.reused === true });
1689
- } else {
1690
- ws.liveDeskStreamIdsByDeviceId.delete(deviceId);
1691
- skipped.push({ deviceId, reason: result?.error || 'start-failed' });
1692
- }
1907
+ });
1908
+ if (result?.ok) {
1909
+ const expectedBinding = {
1910
+ deviceId,
1911
+ sessionId: String(result.sessionId || device.sessionId || ''),
1912
+ streamId: String(result.streamId || ''),
1913
+ streamPurpose: String(result.streamPurpose || liveOptions.streamPurpose || 'wall'),
1914
+ commandId: String(result.commandId || ''),
1915
+ captureGeneration: Number(result.captureGeneration || 0),
1916
+ monitorIndex: Number(result.monitorIndex || 0),
1917
+ readySent: false
1918
+ };
1919
+ const activeStream = device?.activeLiveStream;
1920
+ const reusedFrameReady = result.reused === true
1921
+ && result.ready === true
1922
+ && Number(result.framesReceived ?? activeStream?.framesReceived ?? 0) > 0
1923
+ && String(activeStream?.streamId || expectedBinding.streamId) === expectedBinding.streamId
1924
+ && String(activeStream?.commandId || expectedBinding.commandId) === expectedBinding.commandId
1925
+ && Number(activeStream?.captureGeneration || expectedBinding.captureGeneration) === expectedBinding.captureGeneration;
1926
+ expectedBinding.readySent = reusedFrameReady;
1927
+ ws.liveDeskStreamIdsByDeviceId.set(deviceId, result.streamId);
1928
+ ws.liveDeskExpectedStreamBindingsByDeviceId.set(deviceId, expectedBinding);
1929
+ cancelPendingFrameStreamStop(deviceId, result.streamId, String(liveOptions.streamPurpose || 'wall'));
1930
+ started.push({
1931
+ deviceId: expectedBinding.deviceId,
1932
+ sessionId: expectedBinding.sessionId,
1933
+ streamId: expectedBinding.streamId,
1934
+ streamPurpose: expectedBinding.streamPurpose,
1935
+ commandId: expectedBinding.commandId,
1936
+ captureGeneration: expectedBinding.captureGeneration,
1937
+ monitorIndex: expectedBinding.monitorIndex,
1938
+ ready: reusedFrameReady,
1939
+ fps: result.fps,
1940
+ reused: result.reused === true
1941
+ });
1942
+ } else {
1943
+ ws.liveDeskStreamIdsByDeviceId.delete(deviceId);
1944
+ ws.liveDeskExpectedStreamBindingsByDeviceId.delete(deviceId);
1945
+ skipped.push({ deviceId, reason: result?.error || 'start-failed' });
1946
+ }
1693
1947
  }
1694
1948
  if (reason === 'watchdog' && !started.some(item => item.reused !== true)) {
1695
1949
  return;
@@ -1749,10 +2003,13 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
1749
2003
  kind: frameEvent.kind || 'live',
1750
2004
  deviceId: frameEvent.deviceId || frame.deviceId || '',
1751
2005
  frameSeq: Number(frame.frameSeq || 0) || 0,
1752
- streamFrameSeq: Number(frame.streamFrameSeq || 0) || 0,
1753
- streamId: frame.streamId || '',
1754
- commandId: frame.commandId || '',
1755
- width: Number(frame.width || 0) || 0,
2006
+ streamFrameSeq: Number(frame.streamFrameSeq || 0) || 0,
2007
+ streamId: frame.streamId || '',
2008
+ commandId: frame.commandId || '',
2009
+ sessionId: frame.sessionId || '',
2010
+ captureGeneration: Number(frame.captureGeneration || 0) || 0,
2011
+ streamPurpose: frame.streamPurpose || '',
2012
+ width: Number(frame.width || 0) || 0,
1756
2013
  height: Number(frame.height || 0) || 0,
1757
2014
  sourceWidth: Number(frame.sourceWidth || 0) || 0,
1758
2015
  sourceHeight: Number(frame.sourceHeight || 0) || 0,
@@ -1812,9 +2069,13 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
1812
2069
  sameContentStreak: Number(frame.sameContentStreak || 0) || 0,
1813
2070
  layoutVersion: Number(frame.layoutVersion || 0) || 0,
1814
2071
  atlasComposeMs: Number(frame.atlasComposeMs || 0) || 0,
1815
- atlasPoolStarved: Number(frame.atlasPoolStarved || 0) || 0,
1816
- atlasReadyTiles: Number(frame.atlasReadyTiles || 0) || 0,
1817
- atlasTileCount: Number(frame.atlasTileCount || 0) || 0
2072
+ atlasPoolStarved: Number(frame.atlasPoolStarved || 0) || 0,
2073
+ atlasReadyTiles: Number(frame.atlasReadyTiles || 0) || 0,
2074
+ atlasTileCount: Number(frame.atlasTileCount || 0) || 0,
2075
+ atlasInputRevision: Number(frame.atlasInputRevision || 0) || 0,
2076
+ atlasSourceNewestAtEpochMs: Number(frame.atlasSourceNewestAtEpochMs || 0) || 0,
2077
+ atlasSourceOldestAtEpochMs: Number(frame.atlasSourceOldestAtEpochMs || 0) || 0,
2078
+ atlasSourceMaxAgeMs: Number(frame.atlasSourceMaxAgeMs || 0) || 0
1818
2079
  };
1819
2080
  const metaBuffer = Buffer.from(JSON.stringify(metadata), 'utf8');
1820
2081
  const header = Buffer.allocUnsafe(4);
@@ -1860,10 +2121,8 @@ function buildRemoteAudioBinaryPacket(audioEvent) {
1860
2121
  return Buffer.concat([header, metaBuffer, payload], 4 + metaBuffer.length + payload.length);
1861
2122
  }
1862
2123
 
1863
- function broadcastRemoteBinaryFrame(frameEvent) {
1864
- for (const session of atlasClients.values()) {
1865
- session.ingest(frameEvent);
1866
- }
2124
+ function broadcastRemoteBinaryFrame(frameEvent) {
2125
+ atlasPool.ingest(frameEvent);
1867
2126
  const deviceId = String(frameEvent?.deviceId || frameEvent?.frame?.deviceId || '').trim();
1868
2127
  if (!deviceId || frameClients.size === 0) {
1869
2128
  return;
@@ -1879,18 +2138,40 @@ function broadcastRemoteBinaryFrame(frameEvent) {
1879
2138
  const isH264 = String(frame.codec || frame.mimeType || frame.format || '').toLowerCase().includes('h264')
1880
2139
  || String(frame.frameMode || frame.mode || '').toLowerCase() === 'mode3-h264-hw';
1881
2140
  const isMode5 = String(frame.frameMode || frame.mode || '').toLowerCase() === 'mode5-lzo-delta';
1882
- const isKeyFrame = frame.isKeyFrame === true || String(frame.chunkType || '').toLowerCase() === 'key';
1883
- const frameSeq = Number(frame.frameSeq || 0) || 0;
1884
- for (const client of targetClients) {
1885
- if (client.readyState !== client.OPEN) {
1886
- continue;
1887
- }
1888
- const expectedStreamId = client.liveDeskStreamIdsByDeviceId instanceof Map
1889
- ? client.liveDeskStreamIdsByDeviceId.get(deviceId)
1890
- : '';
1891
- if (expectedStreamId && String(frame.streamId || '') !== expectedStreamId) {
1892
- continue;
1893
- }
2141
+ const isKeyFrame = frame.isKeyFrame === true || String(frame.chunkType || '').toLowerCase() === 'key';
2142
+ const frameSeq = Number(frame.frameSeq || 0) || 0;
2143
+ let sharedPacket = null;
2144
+ for (const client of targetClients) {
2145
+ if (client.readyState !== client.OPEN) {
2146
+ continue;
2147
+ }
2148
+ const expectedBinding = client.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
2149
+ ? client.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
2150
+ : null;
2151
+ if (!expectedBinding
2152
+ || String(frame.streamId || '') !== expectedBinding.streamId
2153
+ || String(frame.sessionId || '') !== expectedBinding.sessionId
2154
+ || String(frame.commandId || '') !== expectedBinding.commandId
2155
+ || Number(frame.captureGeneration || 0) !== expectedBinding.captureGeneration
2156
+ || String(frame.streamPurpose || '') !== expectedBinding.streamPurpose
2157
+ || Number(frame.monitorIndex || 0) !== expectedBinding.monitorIndex) {
2158
+ continue;
2159
+ }
2160
+ if (!expectedBinding.readySent) {
2161
+ expectedBinding.readySent = sendJson(client, {
2162
+ type: 'RemoteFrameStreamReady',
2163
+ deviceId,
2164
+ sessionId: expectedBinding.sessionId,
2165
+ streamId: expectedBinding.streamId,
2166
+ streamPurpose: expectedBinding.streamPurpose,
2167
+ commandId: expectedBinding.commandId,
2168
+ captureGeneration: expectedBinding.captureGeneration,
2169
+ monitorIndex: expectedBinding.monitorIndex
2170
+ });
2171
+ if (!expectedBinding.readySent) {
2172
+ continue;
2173
+ }
2174
+ }
1894
2175
  const lane = ensureFrameClientSendLane(client);
1895
2176
  if (client.liveDeskFrameBackpressured && isH264 && !isKeyFrame) {
1896
2177
  lane.dropped += 1;
@@ -1898,13 +2179,11 @@ function broadcastRemoteBinaryFrame(frameEvent) {
1898
2179
  client.liveDeskFrameBackpressureDrops = Number(client.liveDeskFrameBackpressureDrops || 0) + 1;
1899
2180
  continue;
1900
2181
  }
1901
- const packet = buildRemoteFrameBinaryPacket(frameEvent, {
1902
- hubClientDropped: lane.dropped
1903
- });
1904
- if (!packet) {
1905
- continue;
1906
- }
1907
- enqueueFramePacketForClient(client, packet, { deviceId, frameSeq, isH264, isMode5, isKeyFrame });
2182
+ sharedPacket ||= buildRemoteFrameBinaryPacket(frameEvent);
2183
+ if (!sharedPacket) {
2184
+ continue;
2185
+ }
2186
+ enqueueFramePacketForClient(client, sharedPacket, { deviceId, frameSeq, isH264, isMode5, isKeyFrame });
1908
2187
  }
1909
2188
  }
1910
2189
 
@@ -1977,9 +2256,13 @@ function sendMode4AtlasFrame(ws, output) {
1977
2256
  encoderGeneration,
1978
2257
  layoutVersion: Number(metadata.layoutVersion || 0),
1979
2258
  atlasComposeMs: Number(metadata.composeMs || 0),
1980
- atlasPoolStarved: Number(metadata.poolStarved || 0),
1981
- atlasReadyTiles: Number(metadata.readyTileCount || 0),
1982
- atlasTileCount: Number(metadata.tileCount || 0)
2259
+ atlasPoolStarved: Number(metadata.poolStarved || 0),
2260
+ atlasReadyTiles: Number(metadata.readyTileCount || 0),
2261
+ atlasTileCount: Number(metadata.tileCount || 0),
2262
+ atlasInputRevision: Number(metadata.inputRevision || 0),
2263
+ atlasSourceNewestAtEpochMs: Number(metadata.sourceNewestAtEpochMs || 0),
2264
+ atlasSourceOldestAtEpochMs: Number(metadata.sourceOldestAtEpochMs || 0),
2265
+ atlasSourceMaxAgeMs: Number(metadata.sourceMaxAgeMs || 0)
1983
2266
  }
1984
2267
  };
1985
2268
  const lane = ensureFrameClientSendLane(ws);
@@ -1988,7 +2271,15 @@ function sendMode4AtlasFrame(ws, output) {
1988
2271
  lane.awaitingKeyFrames.add('__livedesk_mode4_atlas__');
1989
2272
  return;
1990
2273
  }
1991
- const packet = buildRemoteFrameBinaryPacket(frameEvent, { hubClientDropped: lane.dropped });
2274
+ const packetCacheKey = `${streamId}:${frameSeq}`;
2275
+ let packet = output.liveDeskBinaryPacketKey === packetCacheKey
2276
+ ? output.liveDeskBinaryPacket
2277
+ : null;
2278
+ if (!packet) {
2279
+ packet = buildRemoteFrameBinaryPacket(frameEvent);
2280
+ output.liveDeskBinaryPacketKey = packetCacheKey;
2281
+ output.liveDeskBinaryPacket = packet;
2282
+ }
1992
2283
  if (packet) {
1993
2284
  enqueueFramePacketForClient(ws, packet, {
1994
2285
  deviceId: '__livedesk_mode4_atlas__',
@@ -1999,67 +2290,114 @@ function sendMode4AtlasFrame(ws, output) {
1999
2290
  }
2000
2291
  }
2001
2292
 
2002
- function configureMode4Atlas(ws, payload = {}) {
2003
- const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId).slice(0, 100);
2004
- const captureDeviceIds = normalizeDeviceIds(payload.captureDeviceIds ?? payload.inputDeviceIds ?? deviceIds)
2005
- .filter(deviceId => deviceIds.includes(deviceId));
2006
- const monitorSelections = normalizeMonitorSelections(payload.monitorSelections);
2007
- const sharpTiles = Number(payload.tileWidth) >= 480 || Number(payload.tileHeight) >= 270;
2008
- const tileWidth = sharpTiles ? 480 : 320;
2009
- const tileHeight = sharpTiles ? 270 : 180;
2010
- const previousInputDeviceIds = ws.liveDeskAtlasInputDeviceIds instanceof Set
2011
- ? new Set(ws.liveDeskAtlasInputDeviceIds)
2012
- : new Set(deviceIds);
2013
- ws.liveDeskAtlasDeviceIds = new Set(deviceIds);
2014
- ws.liveDeskAtlasInputDeviceIds = new Set(captureDeviceIds);
2015
- ws.liveDeskAtlasInputOptions = { tileWidth, tileHeight, monitorSelections };
2016
- ws.liveDeskAtlasSession.configure({
2017
- deviceIds,
2018
- inputDeviceIds: captureDeviceIds,
2019
- width: clampNumber(payload.width, 640, 3840, 1920),
2020
- height: clampNumber(payload.height, 360, 2160, 1080),
2293
+ function configureMode4Atlas(ws, payload = {}) {
2294
+ const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId).slice(0, 100);
2295
+ const captureDeviceIds = normalizeDeviceIds(payload.captureDeviceIds ?? payload.inputDeviceIds ?? deviceIds)
2296
+ .filter(deviceId => deviceIds.includes(deviceId));
2297
+ const monitorSelections = normalizeMonitorSelections(payload.monitorSelections);
2298
+ const width = clampNumber(payload.width, 640, 3840, 1920);
2299
+ const height = clampNumber(payload.height, 360, 2160, 1080);
2300
+ const tileSize = resolveMode4AtlasTileSize({
2301
+ requestedWidth: payload.tileWidth,
2302
+ requestedHeight: payload.tileHeight,
2303
+ deviceCount: deviceIds.length,
2304
+ atlasWidth: width,
2305
+ atlasHeight: height
2306
+ });
2307
+ const tileWidth = tileSize.width;
2308
+ const tileHeight = tileSize.height;
2309
+ const inputFps = clampNumber(payload.inputFps, 1, 30, 8);
2310
+ const previousInputDeviceIds = ws.liveDeskAtlasInputDeviceIds instanceof Set
2311
+ ? new Set(ws.liveDeskAtlasInputDeviceIds)
2312
+ : new Set();
2313
+ const previousInputOptions = ws.liveDeskAtlasInputOptions || {};
2314
+ const atlasConfig = {
2315
+ deviceIds,
2316
+ // Composition ownership is independent from capture ownership. Keeping
2317
+ // every layout device ingestible lets a paused/reused input retain its
2318
+ // latest tile while only the changed device restarts capture.
2319
+ inputDeviceIds: deviceIds,
2320
+ width,
2321
+ height,
2021
2322
  tileWidth,
2022
2323
  tileHeight,
2023
- fps: clampNumber(payload.fps, 1, 30, 20)
2024
- });
2025
- for (const deviceId of previousInputDeviceIds) {
2026
- if (ws.liveDeskAtlasInputDeviceIds.has(deviceId)) {
2027
- continue;
2028
- }
2029
- stopMode4AtlasInput(ws, deviceId, 'atlas-input-paused');
2030
- }
2031
- for (const deviceId of captureDeviceIds) {
2032
- startMode4AtlasInput(ws, deviceId, 'atlas-configure');
2033
- }
2034
- sendJson(ws, {
2035
- type: 'Mode4AtlasSubscription',
2036
- deviceIds,
2324
+ fps: clampNumber(payload.fps, 1, 30, 20),
2325
+ monitorSelections
2326
+ };
2327
+ const previousHandle = ws.liveDeskAtlasHandle;
2328
+ const nextKey = buildMode4AtlasSessionKey(atlasConfig);
2329
+ if (!previousHandle || previousHandle.key !== nextKey) {
2330
+ const nextHandle = atlasPool.acquire(atlasConfig, {
2331
+ onFrame: output => sendMode4AtlasFrame(ws, output),
2332
+ onLayout: layout => sendJson(ws, { ...layout, type: 'Mode4AtlasLayout' }),
2333
+ onStatus: status => sendJson(ws, { ...status, type: 'Mode4AtlasStatus' })
2334
+ });
2335
+ ws.liveDeskAtlasHandle = nextHandle;
2336
+ ws.liveDeskAtlasSession = nextHandle.session;
2337
+ ws.liveDeskAtlasStreamId = nextHandle.streamId;
2338
+ atlasClients.set(ws, nextHandle);
2339
+ previousHandle?.release?.();
2340
+ }
2341
+ ws.liveDeskAtlasDeviceIds = new Set(deviceIds);
2342
+ ws.liveDeskAtlasInputDeviceIds = new Set(captureDeviceIds);
2343
+ ws.liveDeskAtlasInputOptions = { tileWidth, tileHeight, inputFps, monitorSelections };
2344
+ const inputTransitions = planMode4AtlasInputTransitions({
2345
+ inputDeviceIds: [...previousInputDeviceIds],
2346
+ tileWidth: previousInputOptions.tileWidth,
2347
+ tileHeight: previousInputOptions.tileHeight,
2348
+ inputFps: previousInputOptions.inputFps,
2349
+ monitorSelections: previousInputOptions.monitorSelections
2350
+ }, {
2351
+ inputDeviceIds: captureDeviceIds,
2352
+ tileWidth,
2353
+ tileHeight,
2354
+ inputFps,
2355
+ monitorSelections
2356
+ });
2357
+ for (const deviceId of inputTransitions.stop) {
2358
+ stopMode4AtlasInput(ws, deviceId, 'atlas-input-paused');
2359
+ }
2360
+ for (const deviceId of inputTransitions.start) {
2361
+ startMode4AtlasInput(ws, deviceId, 'atlas-input-started');
2362
+ }
2363
+ for (const deviceId of inputTransitions.restart) {
2364
+ startMode4AtlasInput(ws, deviceId, 'atlas-input-changed', {
2365
+ forceRestart: true
2366
+ });
2367
+ }
2368
+ sendJson(ws, {
2369
+ type: 'Mode4AtlasSubscription',
2370
+ streamId: ws.liveDeskAtlasStreamId,
2371
+ deviceIds,
2037
2372
  captureDeviceIds,
2038
2373
  inputMode: 'mode2-lzo',
2039
- outputMode: 'mode4-h264-atlas',
2040
- tileWidth,
2041
- tileHeight,
2042
- timestamp: new Date().toISOString()
2374
+ outputMode: 'mode4-h264-atlas',
2375
+ tileWidth,
2376
+ tileHeight,
2377
+ inputFps,
2378
+ timestamp: new Date().toISOString()
2043
2379
  });
2044
2380
  }
2045
2381
 
2046
- function startMode4AtlasInput(ws, deviceId, reason = 'atlas-configure') {
2047
- const options = ws?.liveDeskAtlasInputOptions;
2048
- if (!options || !ws.liveDeskAtlasInputDeviceIds?.has(deviceId)) return { ok: false, error: 'atlas-device-not-configured' };
2049
- return remoteHub.startLiveStream(deviceId, {
2050
- fps: 8,
2382
+ function startMode4AtlasInput(ws, deviceId, reason = 'atlas-configure', { forceRestart = false } = {}) {
2383
+ const options = ws?.liveDeskAtlasInputOptions;
2384
+ if (!options || !ws.liveDeskAtlasInputDeviceIds?.has(deviceId)) return { ok: false, error: 'atlas-device-not-configured' };
2385
+ return remoteHub.startLiveStream(deviceId, {
2386
+ fps: options.inputFps,
2051
2387
  maxWidth: options.tileWidth,
2052
2388
  maxHeight: options.tileHeight,
2053
2389
  quality: 60,
2054
2390
  mode: 'mode2-lzo',
2055
2391
  frameMode: 'mode2-lzo',
2056
- streamPurpose: 'atlas',
2057
- monitorIndex: Number(options.monitorSelections?.[deviceId] || 0),
2058
- reuseExisting: false,
2059
- silentReuse: true,
2060
- restartReason: reason
2061
- });
2062
- }
2392
+ streamPurpose: 'atlas',
2393
+ monitorIndex: Number(options.monitorSelections?.[deviceId] || 0),
2394
+ reuseExisting: !forceRestart,
2395
+ forceRestart,
2396
+ restartToken: forceRestart ? `atlas-monitor-${deviceId}-${Date.now()}` : '',
2397
+ silentReuse: !forceRestart,
2398
+ restartReason: reason
2399
+ });
2400
+ }
2063
2401
 
2064
2402
  function hasOtherAtlasInputOwner(ws, deviceId) {
2065
2403
  for (const candidate of atlasClients.keys()) {
@@ -2112,10 +2450,35 @@ function isCapabilityEnabled(device, key) {
2112
2450
  return /^(1|true|yes|on)$/i.test(String(value || '').trim());
2113
2451
  }
2114
2452
 
2115
- app.get('/api/health', (_req, res) => {
2116
- noStore(res);
2117
- res.json({ ok: true, product: 'LiveDesk', timestamp: new Date().toISOString() });
2118
- });
2453
+ app.get('/api/health', (_req, res) => {
2454
+ noStore(res);
2455
+ const memory = process.memoryUsage();
2456
+ const webSockets = {
2457
+ frame: frameWss.clients.size,
2458
+ atlas: atlasWss.clients.size,
2459
+ input: inputWss.clients.size,
2460
+ audio: audioWss.clients.size
2461
+ };
2462
+ res.json({
2463
+ ok: true,
2464
+ product: 'LiveDesk',
2465
+ timestamp: new Date().toISOString(),
2466
+ memory: {
2467
+ rssBytes: memory.rss,
2468
+ heapUsedBytes: memory.heapUsed,
2469
+ heapTotalBytes: memory.heapTotal,
2470
+ externalBytes: memory.external,
2471
+ arrayBuffersBytes: memory.arrayBuffers
2472
+ },
2473
+ webSockets: {
2474
+ ...webSockets,
2475
+ total: Object.values(webSockets).reduce((sum, count) => sum + count, 0),
2476
+ bufferedBytes: [...browserWebSocketServers]
2477
+ .flatMap(wss => [...wss.clients])
2478
+ .reduce((sum, ws) => sum + Math.max(0, Number(ws.bufferedAmount || 0)), 0)
2479
+ }
2480
+ });
2481
+ });
2119
2482
 
2120
2483
  app.get('/api/settings', async (_req, res) => {
2121
2484
  noStore(res);
@@ -2444,7 +2807,7 @@ app.post('/api/settings/agent/run', async (req, res) => {
2444
2807
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds).slice(0, 500);
2445
2808
  const connectedDeviceIds = new Set(connectedAgentDeviceIds());
2446
2809
  if (deviceIds.length === 0 || deviceIds.some(deviceId => !connectedDeviceIds.has(deviceId))) {
2447
- throw new AgentProviderError('agent-target-not-connected', 'Every selected workstation must have an active Agent control channel.', { status: 409 });
2810
+ throw new AgentProviderError('agent-target-not-connected', 'Every selected Client must have an active Agent control channel.', { status: 409 });
2448
2811
  }
2449
2812
  const permissionMode = String(req.body?.permissionMode || 'ask').trim().toLowerCase();
2450
2813
  if (!AGENT_PERMISSION_MODES.includes(permissionMode)) {
@@ -2580,8 +2943,8 @@ app.get('/api/remote/status', (_req, res) => {
2580
2943
  deviceId: runtimeDeviceId,
2581
2944
  deviceName: runtimeDeviceName,
2582
2945
  roleSource: runtimeRoleSource,
2583
- agentPackage: '@livedesk/client',
2584
- update: liveDeskUpdateManager?.getStatus() || null
2946
+ agentPackage: '@livedesk/client',
2947
+ update: getLiveDeskUpdateStatus()
2585
2948
  });
2586
2949
  });
2587
2950
 
@@ -2595,19 +2958,27 @@ app.get('/api/runtime/events', (_req, res) => {
2595
2958
  res.json({ ok: true, events: runtimeManager.getEvents() });
2596
2959
  });
2597
2960
 
2598
- app.post('/api/runtime/restart', (_req, res) => {
2599
- noStore(res);
2600
- runtimeManager.emit('runtime.restart.requested', { role: runtimeRole });
2601
- res.json({ ok: true, restarting: true, role: runtimeRole });
2602
- setTimeout(() => process.kill(process.pid, 'SIGTERM'), 150);
2603
- });
2604
-
2605
- app.post('/api/runtime/shutdown', (_req, res) => {
2606
- noStore(res);
2607
- runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
2608
- res.json({ ok: true, shuttingDown: true, role: runtimeRole });
2609
- setTimeout(() => process.kill(process.pid, 'SIGTERM'), 150);
2610
- });
2961
+ app.post('/api/runtime/restart', (_req, res) => {
2962
+ noStore(res);
2963
+ runtimeManager.emit('runtime.restart.requested', { role: runtimeRole });
2964
+ res.json({ ok: true, restarting: true, role: runtimeRole });
2965
+ setTimeout(() => {
2966
+ void shutdownHub('API_RESTART')
2967
+ .then(() => process.exit(0))
2968
+ .catch(() => process.exit(1));
2969
+ }, 150);
2970
+ });
2971
+
2972
+ app.post('/api/runtime/shutdown', (_req, res) => {
2973
+ noStore(res);
2974
+ runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
2975
+ res.json({ ok: true, shuttingDown: true, role: runtimeRole });
2976
+ setTimeout(() => {
2977
+ void shutdownHub('API_SHUTDOWN')
2978
+ .then(() => process.exit(0))
2979
+ .catch(() => process.exit(1));
2980
+ }, 150);
2981
+ });
2611
2982
 
2612
2983
  app.post('/api/runtime/switch-role', (req, res) => {
2613
2984
  noStore(res);
@@ -2642,13 +3013,19 @@ app.post('/api/auth/session', async (req, res) => {
2642
3013
  runtimeRefreshToken = refreshToken;
2643
3014
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
2644
3015
  runtimeManager.setAuthenticated(true, user.id);
3016
+ const persisted = persistRuntimeSession({
3017
+ access_token: accessToken,
3018
+ refresh_token: refreshToken,
3019
+ expires_at: expiresAt,
3020
+ user
3021
+ });
2645
3022
  const hostTarget = runtimeRole === 'hub'
2646
- ? await publishHubHostTarget({ reason: 'session-received' })
3023
+ ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
2647
3024
  : { ok: true, active: false };
2648
3025
  if (runtimeRole === 'hub') {
2649
3026
  startHubHostTargetLeaseRenewal();
2650
3027
  }
2651
- res.json({ ok: true, authenticated: true, userId: user.id, role: runtimeRole, hostTarget });
3028
+ res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
2652
3029
  } catch (error) {
2653
3030
  const message = error instanceof Error ? error.message : String(error);
2654
3031
  const status = authVerificationHttpStatus(message);
@@ -2877,6 +3254,21 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
2877
3254
  }
2878
3255
  }
2879
3256
 
3257
+ async function publishHubHostTargetWithPendingRoleTakeover(reason = 'renewal') {
3258
+ const takeover = roleTransitionTakeoverPending;
3259
+ const result = await publishHubHostTarget({
3260
+ takeover,
3261
+ reason: takeover ? `${reason}-role-takeover` : reason
3262
+ });
3263
+ if (takeover && result.ok) {
3264
+ // One explicit Client -> Hub selection authorizes one ownership handoff.
3265
+ // Normal renewals must never keep takeover authority after publication.
3266
+ roleTransitionTakeoverPending = false;
3267
+ delete process.env.LIVEDESK_ROLE_TAKEOVER;
3268
+ }
3269
+ return result;
3270
+ }
3271
+
2880
3272
  async function clearHubHostTarget(reason = 'shutdown') {
2881
3273
  if (hubHostTargetRenewTimer) {
2882
3274
  clearInterval(hubHostTargetRenewTimer);
@@ -2924,7 +3316,7 @@ function startHubHostTargetLeaseRenewal() {
2924
3316
  return;
2925
3317
  }
2926
3318
  hubHostTargetRenewTimer = setInterval(() => {
2927
- void publishHubHostTarget({ reason: 'renewal' });
3319
+ void publishHubHostTargetWithPendingRoleTakeover('renewal');
2928
3320
  }, HUB_HOST_TARGET_RENEW_MS);
2929
3321
  hubHostTargetRenewTimer.unref?.();
2930
3322
  }
@@ -2952,7 +3344,7 @@ app.get('/api/hub/status', (_req, res) => {
2952
3344
  roleSource: runtimeRoleSource,
2953
3345
  runtimeStarted: true,
2954
3346
  hostTargetLease: getHubHostTargetLeaseStatus(),
2955
- update: liveDeskUpdateManager?.getStatus() || null
3347
+ update: getLiveDeskUpdateStatus()
2956
3348
  });
2957
3349
  });
2958
3350
 
@@ -3063,23 +3455,30 @@ app.post('/api/runtime/role', async (req, res) => {
3063
3455
  }
3064
3456
  });
3065
3457
 
3066
- app.get('/api/update/status', (_req, res) => {
3067
- noStore(res);
3068
- if (process.env.LIVEDESK_DESKTOP_HOST === '1') {
3069
- res.json({ updateAvailable: false, state: 'electron-managed', canApply: false, disabled: true });
3070
- return;
3071
- }
3072
- res.json(liveDeskUpdateManager?.getStatus() || { updateAvailable: false, state: 'unavailable' });
3073
- });
3458
+ app.get('/api/update/status', (_req, res) => {
3459
+ noStore(res);
3460
+ if (process.env.LIVEDESK_DESKTOP_HOST === '1') {
3461
+ res.json({
3462
+ updateAvailable: false,
3463
+ state: 'electron-managed',
3464
+ canApply: false,
3465
+ disabled: true,
3466
+ restartResult: readHubRestartResult()
3467
+ });
3468
+ return;
3469
+ }
3470
+ res.json(getLiveDeskUpdateStatus());
3471
+ });
3074
3472
 
3075
3473
  app.post('/api/update/apply', async (_req, res) => {
3076
3474
  noStore(res);
3077
3475
  if (process.env.LIVEDESK_DESKTOP_HOST === '1') {
3078
3476
  res.status(409).json({ ok: false, error: 'electron-updater-managed', disabled: true });
3079
3477
  return;
3080
- }
3081
- try {
3082
- const result = await liveDeskUpdateManager?.startUpdate();
3478
+ }
3479
+ try {
3480
+ liveDeskUpdateManager?.reconcileHubRestartResult(readHubRestartResult());
3481
+ const result = await liveDeskUpdateManager?.startUpdate();
3083
3482
  res.status(result?.ok === false ? 409 : 200).json(result || { ok: false, error: 'update-manager-unavailable' });
3084
3483
  } catch (error) {
3085
3484
  res.status(500).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
@@ -3468,8 +3867,8 @@ app.post('/api/remote/devices/:deviceId/quick-actions', requireHubFeatureAccess,
3468
3867
 
3469
3868
  const label = action === 'shutdown' ? 'Shut down' : `${action.slice(0, 1).toUpperCase()}${action.slice(1)}`;
3470
3869
  const result = remoteHub.requestAgentTask(req.params.deviceId, {
3471
- instruction: `${label} this workstation. This action was explicitly requested and confirmed from the LiveDesk Hub action panel.`,
3472
- title: `${label} workstation`,
3870
+ instruction: `${label} this Client. This action was explicitly requested and confirmed from the LiveDesk Hub action panel.`,
3871
+ title: `${label} Client`,
3473
3872
  operation: 'system.power',
3474
3873
  approvalLevel: 'task-only',
3475
3874
  toolArguments: { action, delaySec: 0 },
@@ -3719,30 +4118,18 @@ frameWss.on('connection', (ws, req) => {
3719
4118
  });
3720
4119
  });
3721
4120
 
3722
- atlasWss.on('connection', ws => {
3723
- ws.liveDeskFrameClientId = `atlas-${++frameClientSeq}`;
3724
- ws.liveDeskAtlasStreamId = `mode4-atlas-${crypto.randomUUID()}`;
3725
- try { ws._socket?.setNoDelay?.(true); } catch {}
3726
- const session = new Mode4AtlasSession({
3727
- onFrame: output => sendMode4AtlasFrame(ws, output),
3728
- onLayout: layout => sendJson(ws, { ...layout, type: 'Mode4AtlasLayout' }),
3729
- onStatus: status => {
3730
- if (status?.type === 'log') {
3731
- const logger = status.level === 'warn' ? console.warn : console.log;
3732
- logger(`[LiveDesk Hub] ${status.message}`);
3733
- }
3734
- sendJson(ws, { ...status, type: 'Mode4AtlasStatus' });
3735
- }
3736
- });
3737
- ws.liveDeskAtlasSession = session;
3738
- atlasClients.set(ws, session);
3739
- const cleanup = () => {
3740
- atlasClients.delete(ws);
4121
+ atlasWss.on('connection', ws => {
4122
+ ws.liveDeskFrameClientId = `atlas-${++frameClientSeq}`;
4123
+ try { ws._socket?.setNoDelay?.(true); } catch {}
4124
+ const cleanup = () => {
4125
+ atlasClients.delete(ws);
3741
4126
  for (const deviceId of ws.liveDeskAtlasInputDeviceIds || []) {
3742
4127
  stopMode4AtlasInput(ws, deviceId, 'atlas-client-closed');
3743
- }
3744
- ws.liveDeskAtlasInputDeviceIds = new Set();
3745
- session.close();
4128
+ }
4129
+ ws.liveDeskAtlasInputDeviceIds = new Set();
4130
+ ws.liveDeskAtlasHandle?.release?.();
4131
+ ws.liveDeskAtlasHandle = null;
4132
+ ws.liveDeskAtlasSession = null;
3746
4133
  const lane = ws.liveDeskFrameSendLane;
3747
4134
  if (lane) {
3748
4135
  lane.queue.length = 0;
@@ -3813,11 +4200,12 @@ inputWss.on('connection', ws => {
3813
4200
  } catch {
3814
4201
  sendJson(ws, { type: 'RemoteInputError', error: 'invalid-json' });
3815
4202
  return;
3816
- }
3817
- const deviceId = String(payload?.deviceId || '').trim();
3818
- const input = payload?.input && typeof payload.input === 'object'
3819
- ? { ...payload.input, hubReceivedAtEpochMs: Date.now() }
3820
- : { ...payload, hubReceivedAtEpochMs: Date.now() };
4203
+ }
4204
+ const deviceId = String(payload?.deviceId || '').trim();
4205
+ const inputEventId = String(payload?.requestId || '').trim() || crypto.randomUUID();
4206
+ const input = payload?.input && typeof payload.input === 'object'
4207
+ ? { ...payload.input, inputEventId, hubConnectionId: ws.liveDeskInputClientId, hubReceivedAtEpochMs: Date.now() }
4208
+ : { ...payload, inputEventId, hubConnectionId: ws.liveDeskInputClientId, hubReceivedAtEpochMs: Date.now() };
3821
4209
  const result = remoteHub.sendInputControl(deviceId, input);
3822
4210
  const inputType = String(input?.type || '').toLowerCase();
3823
4211
  const fireAndForget = payload?.fireAndForget === true
@@ -3863,17 +4251,130 @@ const roleWatchTimer = runtimeRole === 'hub'
3863
4251
  : null;
3864
4252
  roleWatchTimer?.unref?.();
3865
4253
 
3866
- for (const signal of ['SIGINT', 'SIGTERM']) {
3867
- process.once(signal, async () => {
3868
- if (roleWatchTimer) {
3869
- clearInterval(roleWatchTimer);
4254
+ function waitForShutdownDelay(milliseconds) {
4255
+ return new Promise(resolveWait => {
4256
+ const timer = setTimeout(resolveWait, milliseconds);
4257
+ timer.unref?.();
4258
+ });
4259
+ }
4260
+
4261
+ async function boundedShutdownStep(label, action, timeoutMs) {
4262
+ let timer;
4263
+ try {
4264
+ await Promise.race([
4265
+ Promise.resolve().then(action),
4266
+ new Promise((_, reject) => {
4267
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
4268
+ timer.unref?.();
4269
+ })
4270
+ ]);
4271
+ console.log(`[LiveDesk Hub] Shutdown stage completed: ${label}.`);
4272
+ } catch (error) {
4273
+ console.warn(`[LiveDesk Hub] Shutdown stage warning: ${error instanceof Error ? error.message : String(error)}`);
4274
+ } finally {
4275
+ if (timer) clearTimeout(timer);
4276
+ }
4277
+ }
4278
+
4279
+ function runSynchronousShutdownStep(label, action) {
4280
+ try {
4281
+ action();
4282
+ } catch (error) {
4283
+ console.warn(`[LiveDesk Hub] Shutdown stage warning: ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
4284
+ }
4285
+ }
4286
+
4287
+ function closeBrowserWebSockets() {
4288
+ let closeRequested = 0;
4289
+ for (const wss of browserWebSocketServers) {
4290
+ for (const ws of wss.clients) {
4291
+ closeRequested += 1;
4292
+ try {
4293
+ ws.close(1012, 'LiveDesk Hub restarting');
4294
+ } catch {
4295
+ try { ws.terminate(); } catch { /* socket already closed */ }
4296
+ }
4297
+ }
4298
+ }
4299
+ console.log(`[LiveDesk Hub] Shutdown stage: requested ${closeRequested} browser WebSocket connection(s) to close.`);
4300
+ }
4301
+
4302
+ function terminateOpenConnections() {
4303
+ let terminatedWebSockets = 0;
4304
+ for (const wss of browserWebSocketServers) {
4305
+ for (const ws of wss.clients) {
4306
+ terminatedWebSockets += 1;
4307
+ try { ws.terminate(); } catch { /* socket already closed */ }
3870
4308
  }
3871
- liveDeskUpdateManager?.close();
3872
- for (const session of atlasClients.values()) session.close();
4309
+ try { wss.close(); } catch { /* no active WebSocket server state */ }
4310
+ }
4311
+ let destroyedHttpConnections = 0;
4312
+ for (const socket of httpConnections) {
4313
+ if (socket.destroyed) continue;
4314
+ destroyedHttpConnections += 1;
4315
+ try { socket.destroy(); } catch { /* socket already closed */ }
4316
+ }
4317
+ try { httpServer.closeAllConnections?.(); } catch { /* older Node runtime */ }
4318
+ console.log(`[LiveDesk Hub] Shutdown stage: force-closed ${terminatedWebSockets} WebSocket and ${destroyedHttpConnections} HTTP connection(s).`);
4319
+ }
4320
+
4321
+ let hubShutdownPromise = null;
4322
+ function shutdownHub(signal) {
4323
+ if (hubShutdownPromise) return hubShutdownPromise;
4324
+ hubShutdownPromise = (async () => {
4325
+ const startedAt = Date.now();
4326
+ console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
4327
+ if (roleWatchTimer) clearInterval(roleWatchTimer);
4328
+ clearInterval(browserWebSocketHeartbeatTimer);
4329
+ runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
4330
+ runSynchronousShutdownStep('atlas pool close', () => atlasPool.close());
3873
4331
  atlasClients.clear();
3874
- hubSharedFolders.close();
3875
- await clearHubHostTarget(`process-${signal.toLowerCase()}`);
3876
- await remoteHub.close();
3877
- httpServer.close(() => process.exit(0));
3878
- });
3879
- }
4332
+ runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
4333
+
4334
+ const httpClosed = new Promise(resolveClose => {
4335
+ try {
4336
+ httpServer.close(error => {
4337
+ if (error) {
4338
+ console.warn(`[LiveDesk Hub] HTTP listener close warning: ${error.message}`);
4339
+ } else {
4340
+ console.log('[LiveDesk Hub] Shutdown stage completed: HTTP listener closed.');
4341
+ }
4342
+ resolveClose();
4343
+ });
4344
+ httpServer.closeIdleConnections?.();
4345
+ } catch (error) {
4346
+ console.warn(`[LiveDesk Hub] HTTP listener close warning: ${error instanceof Error ? error.message : String(error)}`);
4347
+ resolveClose();
4348
+ }
4349
+ });
4350
+
4351
+ closeBrowserWebSockets();
4352
+ const forceCloseTimer = setTimeout(terminateOpenConnections, hubShutdownGraceMs);
4353
+ forceCloseTimer.unref?.();
4354
+
4355
+ const cleanupBudgetMs = Math.max(500, hubShutdownTimeoutMs - hubShutdownGraceMs - 500);
4356
+ await Promise.all([
4357
+ boundedShutdownStep('host target lease cleared', () => clearHubHostTarget(`process-${String(signal).toLowerCase()}`), Math.min(3_000, cleanupBudgetMs)),
4358
+ boundedShutdownStep('remote transport closed', () => remoteHub.close(), cleanupBudgetMs)
4359
+ ]);
4360
+
4361
+ const remainingMs = Math.max(0, hubShutdownTimeoutMs - (Date.now() - startedAt));
4362
+ await Promise.race([httpClosed, waitForShutdownDelay(remainingMs)]);
4363
+ clearTimeout(forceCloseTimer);
4364
+ terminateOpenConnections();
4365
+ console.log(`[LiveDesk Hub] Shutdown complete in ${Date.now() - startedAt}ms.`);
4366
+ })();
4367
+ return hubShutdownPromise;
4368
+ }
4369
+
4370
+ for (const signal of ['SIGINT', 'SIGTERM']) {
4371
+ process.once(signal, () => {
4372
+ void shutdownHub(signal)
4373
+ .then(() => process.exit(0))
4374
+ .catch(error => {
4375
+ console.error(`[LiveDesk Hub] Shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
4376
+ terminateOpenConnections();
4377
+ process.exit(1);
4378
+ });
4379
+ });
4380
+ }