@livedesk/client 0.1.215 → 0.1.217

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,20 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { execFile } from 'node:child_process';
4
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
5
- import { dirname, join, parse, resolve } from 'node:path';
4
+ import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
5
+ import { delimiter, dirname, join, parse, posix, resolve } from 'node:path';
6
6
  import os from 'node:os';
7
7
  import { createRuntimeManager, normalizeRuntimeAuthSession, runtimeRoleError, RuntimeState } from '../../../runtime-core/src/index.js';
8
8
 
9
- const DEFAULT_HOST = '127.0.0.1';
10
- const DEFAULT_PORT = 5179;
9
+ const DEFAULT_HOST = '127.0.0.1';
10
+ const DEFAULT_PORT = 5179;
11
+ const CLIENT_RUNTIME_BIND_TIMEOUT_MS = 10_000;
12
+ const CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS = 150;
13
+ const CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS = 750;
14
+ const CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS = 1_500;
15
+ const CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS = 15_000;
16
+ const CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS = 60_000;
17
+ const CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS = 300_000;
11
18
  const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
12
19
  const SUPABASE_URL = process.env.LIVEDESK_SUPABASE_URL || 'https://otbyfkjxrkngvjziawki.supabase.co';
13
20
  const SUPABASE_PUBLISHABLE_KEY = process.env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_NpUs0RDJH2YnllsqTKO6TQ_1jTdSsNQ';
@@ -181,15 +188,406 @@ function percentFromRatio(value) {
181
188
  return Math.round(Math.max(0, Math.min(100, number)) * 10) / 10;
182
189
  }
183
190
 
184
- function captureCommand(command, args, timeout = 3500) {
185
- return new Promise(resolveOutput => {
186
- execFile(command, args, {
187
- encoding: 'utf8',
188
- maxBuffer: 512 * 1024,
189
- timeout,
190
- windowsHide: true
191
- }, (error, stdout) => resolveOutput(error ? '' : String(stdout || '')));
191
+ function destroyChildRedirectedIo(child) {
192
+ for (const stream of [child?.stdin, child?.stdout, child?.stderr]) {
193
+ try { stream?.destroy(); } catch { /* an exact owned stream already closed */ }
194
+ }
195
+ }
196
+
197
+ function sanitizeDiagnosticCommandName(value) {
198
+ return normalizeString(value, 200) || 'unknown-diagnostic-command';
199
+ }
200
+
201
+ function defaultDiagnosticMonotonicNow() {
202
+ return Number(process.hrtime.bigint() / 1_000_000n);
203
+ }
204
+
205
+ /**
206
+ * Contract:
207
+ * - A probe is idle until an explicit diagnostic request calls read().
208
+ * - Every successful, unavailable, empty, or failed result is retained for at
209
+ * least 15 monotonic seconds measured from refresh completion.
210
+ * - Concurrent readers share one refresh promise. A failed tool therefore
211
+ * cannot respawn once per status heartbeat.
212
+ */
213
+ export function createClientDiagnosticProbeCache(options = {}) {
214
+ if (typeof options.refresh !== 'function') {
215
+ throw new TypeError('client-diagnostic-probe-refresh-required');
216
+ }
217
+ const probeId = normalizeString(options.probeId, 80) || 'client-diagnostic-probe';
218
+ const monotonicNow = typeof options.monotonicNow === 'function'
219
+ ? options.monotonicNow
220
+ : defaultDiagnosticMonotonicNow;
221
+ const ttlMs = Math.max(
222
+ CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
223
+ Number(options.ttlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
224
+ );
225
+ const negativeTtlMs = Math.max(
226
+ CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
227
+ Number(options.negativeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS
228
+ );
229
+ const fallbackValue = options.fallbackValue;
230
+ let initialized = false;
231
+ let sampledAtMonotonicMs = 0;
232
+ let generation = 0;
233
+ let refreshCount = 0;
234
+ let cacheHitCount = 0;
235
+ let sharedReadCount = 0;
236
+ let record = {
237
+ ok: false,
238
+ value: fallbackValue,
239
+ error: 'not-requested'
240
+ };
241
+ let inFlight = null;
242
+
243
+ const snapshot = () => {
244
+ const now = Number(monotonicNow());
245
+ const ageMs = initialized && Number.isFinite(now)
246
+ ? Math.max(0, now - sampledAtMonotonicMs)
247
+ : null;
248
+ return {
249
+ probeId,
250
+ initialized,
251
+ inFlight: Boolean(inFlight),
252
+ ok: initialized ? record.ok === true : false,
253
+ error: initialized ? normalizeString(record.error, 160) : 'not-requested',
254
+ ttlMs,
255
+ negativeTtlMs,
256
+ ageMs,
257
+ generation,
258
+ refreshCount,
259
+ cacheHitCount,
260
+ sharedReadCount
261
+ };
262
+ };
263
+
264
+ const read = () => {
265
+ const now = Number(monotonicNow());
266
+ const activeTtlMs = record.ok === true ? ttlMs : negativeTtlMs;
267
+ if (initialized
268
+ && Number.isFinite(now)
269
+ && Math.max(0, now - sampledAtMonotonicMs) < activeTtlMs) {
270
+ cacheHitCount += 1;
271
+ return Promise.resolve({
272
+ ...record,
273
+ cached: true,
274
+ shared: false,
275
+ generation
276
+ });
277
+ }
278
+ if (inFlight) {
279
+ sharedReadCount += 1;
280
+ return inFlight.then(result => ({ ...result, shared: true }));
281
+ }
282
+
283
+ inFlight = (async () => {
284
+ let next;
285
+ try {
286
+ const candidate = await options.refresh();
287
+ next = candidate && typeof candidate === 'object'
288
+ ? {
289
+ ok: candidate.ok === true,
290
+ value: candidate.value === undefined ? fallbackValue : candidate.value,
291
+ error: candidate.ok === true ? '' : normalizeString(candidate.error, 160) || 'probe-unavailable'
292
+ }
293
+ : {
294
+ ok: false,
295
+ value: fallbackValue,
296
+ error: 'probe-invalid-result'
297
+ };
298
+ } catch (error) {
299
+ next = {
300
+ ok: false,
301
+ value: fallbackValue,
302
+ error: normalizeString(error?.message || error, 160) || 'probe-failed'
303
+ };
304
+ }
305
+ record = next;
306
+ initialized = true;
307
+ generation += 1;
308
+ refreshCount += 1;
309
+ sampledAtMonotonicMs = Number(monotonicNow());
310
+ return {
311
+ ...record,
312
+ cached: false,
313
+ shared: false,
314
+ generation
315
+ };
316
+ })().finally(() => {
317
+ inFlight = null;
318
+ });
319
+ return inFlight;
320
+ };
321
+
322
+ return {
323
+ read,
324
+ getSnapshot: snapshot
325
+ };
326
+ }
327
+
328
+ function isExecutablePath(path) {
329
+ try {
330
+ accessSync(path, fsConstants.X_OK);
331
+ return true;
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
336
+
337
+ export function resolveNvidiaSmiCommand(options = {}) {
338
+ const platform = normalizeString(options.platform || process.platform, 20);
339
+ const executablePath = normalizeString(
340
+ options.explicitPath ?? process.env.LIVEDESK_NVIDIA_SMI_PATH,
341
+ 1000
342
+ );
343
+ const executableCheck = typeof options.isExecutable === 'function'
344
+ ? options.isExecutable
345
+ : isExecutablePath;
346
+ if (platform === 'win32') {
347
+ return executablePath || 'nvidia-smi.exe';
348
+ }
349
+
350
+ const pathValue = String(options.pathValue ?? process.env.PATH ?? '');
351
+ const pathDelimiter = normalizeString(options.pathDelimiter, 4) || delimiter;
352
+ const joinExecutablePath = platform === 'win32' ? join : posix.join;
353
+ const candidates = [
354
+ executablePath,
355
+ ...pathValue
356
+ .split(pathDelimiter)
357
+ .map(directory => directory.trim())
358
+ .filter(Boolean)
359
+ .map(directory => joinExecutablePath(directory, 'nvidia-smi')),
360
+ ...(platform === 'linux' ? ['/usr/bin/nvidia-smi', '/usr/local/bin/nvidia-smi'] : [])
361
+ ].filter(Boolean);
362
+ for (const candidate of [...new Set(candidates)]) {
363
+ try {
364
+ if (executableCheck(candidate) === true) return candidate;
365
+ } catch {
366
+ // An unreadable PATH entry is negative probe evidence.
367
+ }
368
+ }
369
+ return '';
370
+ }
371
+
372
+ /**
373
+ * Contract:
374
+ * - Owns only ChildProcess objects spawned through this instance.
375
+ * - Identity is ownerId + commandId + the immutable ChildProcess handle; a PID
376
+ * is diagnostic evidence and is never rediscovered to authorize termination.
377
+ * - A command deadline and owner close both abort, terminate, drain redirected
378
+ * stdio, and wait for the exact child's terminal `close` event.
379
+ * - Concurrent identical probes share the same command/output/terminal owner.
380
+ * - Output, close, and active-ledger retention are bounded independently so an
381
+ * inherited pipe can never keep the Client event loop alive without evidence.
382
+ */
383
+ export function createClientDiagnosticCommandOwner(options = {}) {
384
+ const execFileImpl = typeof options.execFileImpl === 'function' ? options.execFileImpl : execFile;
385
+ const ownerId = normalizeString(options.ownerId, 160)
386
+ || `client-diagnostics-${process.pid}-${randomBytes(8).toString('hex')}`;
387
+ const forceKillDelayMs = Math.max(
388
+ 10,
389
+ Number(options.forceKillDelayMs) || CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS
390
+ );
391
+ const commandDrainMs = Math.max(
392
+ forceKillDelayMs + 10,
393
+ Number(options.commandDrainMs) || CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS
394
+ );
395
+ const active = new Map();
396
+ const activeByKey = new Map();
397
+ let commandSequence = 0;
398
+ let spawnedCount = 0;
399
+ let joinedCount = 0;
400
+ let timedOutCount = 0;
401
+ let sharedRequestCount = 0;
402
+ let closePromise = null;
403
+ let closed = false;
404
+
405
+ const snapshotRecord = record => ({
406
+ ownerId,
407
+ commandId: record.commandId,
408
+ pid: record.pid,
409
+ command: record.command,
410
+ startedAt: record.startedAt,
411
+ terminationReason: record.terminationReason || ''
412
+ });
413
+
414
+ const getSnapshot = () => ({
415
+ ownerId,
416
+ closed,
417
+ spawnedCount,
418
+ joinedCount,
419
+ timedOutCount,
420
+ sharedRequestCount,
421
+ activeCount: active.size,
422
+ redirectedStreamCount: [...active.values()].reduce(
423
+ (count, record) => count + [record.child?.stdin, record.child?.stdout, record.child?.stderr]
424
+ .filter(stream => stream && !stream.destroyed).length,
425
+ 0
426
+ ),
427
+ active: [...active.values()].map(snapshotRecord)
192
428
  });
429
+
430
+ const settleOutput = (record, output = '') => {
431
+ if (record.outputSettled) return;
432
+ record.outputSettled = true;
433
+ record.resolveOutput(String(output || ''));
434
+ };
435
+
436
+ const markTerminal = record => {
437
+ if (record.terminal) return;
438
+ record.terminal = true;
439
+ clearTimeout(record.commandTimer);
440
+ clearTimeout(record.forceKillTimer);
441
+ clearTimeout(record.outputDrainTimer);
442
+ destroyChildRedirectedIo(record.child);
443
+ active.delete(record.commandId);
444
+ if (activeByKey.get(record.commandKey) === record) {
445
+ activeByKey.delete(record.commandKey);
446
+ }
447
+ joinedCount += 1;
448
+ settleOutput(record, record.error ? '' : record.stdout);
449
+ record.resolveTerminal();
450
+ };
451
+
452
+ const terminateOwnedRecord = (record, reason) => {
453
+ if (!record || record.terminal || record.terminationRequested) return;
454
+ record.terminationRequested = true;
455
+ record.terminationReason = normalizeString(reason, 80) || 'diagnostic-owner-close';
456
+ if (record.terminationReason === 'command-timeout') timedOutCount += 1;
457
+ clearTimeout(record.commandTimer);
458
+ try { record.abortController.abort(new Error(record.terminationReason)); } catch { /* already aborted */ }
459
+ try { record.child?.kill('SIGTERM'); } catch { /* exact child already exited */ }
460
+ record.forceKillTimer = setTimeout(() => {
461
+ if (record.terminal) return;
462
+ try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
463
+ // An inherited writer can keep execFile's callback pending even after the
464
+ // root exits. Closing our exact pipe ends the Client-side handle owner.
465
+ destroyChildRedirectedIo(record.child);
466
+ }, forceKillDelayMs);
467
+ record.forceKillTimer.unref?.();
468
+ record.outputDrainTimer = setTimeout(() => {
469
+ if (record.terminal) return;
470
+ settleOutput(record, '');
471
+ destroyChildRedirectedIo(record.child);
472
+ record.child?.unref?.();
473
+ }, commandDrainMs);
474
+ record.outputDrainTimer.unref?.();
475
+ };
476
+
477
+ const capture = (command, args = [], timeout = 3500) => {
478
+ if (closed) return Promise.resolve('');
479
+ const normalizedArgs = Array.isArray(args) ? args : [];
480
+ const commandKey = JSON.stringify([
481
+ String(command || ''),
482
+ ...normalizedArgs.map(value => String(value ?? ''))
483
+ ]);
484
+ const sharedRecord = activeByKey.get(commandKey);
485
+ if (sharedRecord) {
486
+ sharedRequestCount += 1;
487
+ return sharedRecord.outputPromise;
488
+ }
489
+ const commandId = `${ownerId}:${++commandSequence}`;
490
+ const abortController = new AbortController();
491
+ let resolveOutput;
492
+ let resolveTerminal;
493
+ const outputPromise = new Promise(resolve => { resolveOutput = resolve; });
494
+ const terminalPromise = new Promise(resolve => { resolveTerminal = resolve; });
495
+ const record = {
496
+ ownerId,
497
+ commandId,
498
+ commandKey,
499
+ command: sanitizeDiagnosticCommandName(command),
500
+ pid: 0,
501
+ startedAt: new Date().toISOString(),
502
+ child: null,
503
+ abortController,
504
+ resolveOutput,
505
+ resolveTerminal,
506
+ outputPromise,
507
+ terminalPromise,
508
+ stdout: '',
509
+ error: null,
510
+ outputSettled: false,
511
+ terminal: false,
512
+ terminationRequested: false,
513
+ terminationReason: '',
514
+ commandTimer: null,
515
+ forceKillTimer: null,
516
+ outputDrainTimer: null
517
+ };
518
+ active.set(commandId, record);
519
+ activeByKey.set(commandKey, record);
520
+
521
+ try {
522
+ record.child = execFileImpl(command, normalizedArgs, {
523
+ encoding: 'utf8',
524
+ maxBuffer: 512 * 1024,
525
+ windowsHide: true,
526
+ signal: abortController.signal
527
+ }, (error, stdout) => {
528
+ record.error = error || null;
529
+ record.stdout = error ? '' : String(stdout || '');
530
+ settleOutput(record, record.stdout);
531
+ });
532
+ record.pid = Number(record.child?.pid || 0);
533
+ spawnedCount += 1;
534
+ record.child.once('close', () => markTerminal(record));
535
+ record.child.once('error', error => {
536
+ record.error = record.error || error;
537
+ });
538
+ const commandTimeoutMs = Math.max(10, Number(timeout) || 3500);
539
+ record.commandTimer = setTimeout(
540
+ () => terminateOwnedRecord(record, 'command-timeout'),
541
+ commandTimeoutMs
542
+ );
543
+ record.commandTimer.unref?.();
544
+ } catch (error) {
545
+ record.error = error;
546
+ settleOutput(record, '');
547
+ markTerminal(record);
548
+ }
549
+
550
+ return outputPromise;
551
+ };
552
+
553
+ const close = (timeoutMs = CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS) => {
554
+ if (closePromise) return closePromise;
555
+ closed = true;
556
+ const boundedTimeoutMs = Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS);
557
+ closePromise = (async () => {
558
+ const records = [...active.values()];
559
+ for (const record of records) terminateOwnedRecord(record, 'diagnostic-owner-close');
560
+ if (records.length > 0) {
561
+ let deadlineTimer;
562
+ await Promise.race([
563
+ Promise.allSettled(records.map(record => record.terminalPromise)),
564
+ new Promise(resolve => {
565
+ deadlineTimer = setTimeout(resolve, boundedTimeoutMs);
566
+ })
567
+ ]);
568
+ clearTimeout(deadlineTimer);
569
+ }
570
+ for (const record of active.values()) {
571
+ settleOutput(record, '');
572
+ try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
573
+ destroyChildRedirectedIo(record.child);
574
+ record.child?.unref?.();
575
+ }
576
+ const snapshot = getSnapshot();
577
+ return {
578
+ ok: snapshot.activeCount === 0 && snapshot.redirectedStreamCount === 0,
579
+ ...snapshot
580
+ };
581
+ })();
582
+ return closePromise;
583
+ };
584
+
585
+ return {
586
+ ownerId,
587
+ capture,
588
+ close,
589
+ getSnapshot
590
+ };
193
591
  }
194
592
 
195
593
  export function parseNvidiaSmiOutput(output) {
@@ -280,17 +678,30 @@ function activeNetworkInterfaces() {
280
678
  return interfaces;
281
679
  }
282
680
 
283
- async function readNetworkTotals() {
284
- if (process.platform === 'win32') {
285
- return parseWindowsNetworkOutput(await captureCommand('netstat.exe', ['-e'], 2500));
681
+ async function readNetworkTotalsProbe(commandOwner, platform = process.platform) {
682
+ if (platform === 'win32') {
683
+ const output = await commandOwner.capture('netstat.exe', ['-e'], 2500);
684
+ return String(output || '').trim()
685
+ ? { ok: true, value: parseWindowsNetworkOutput(output) }
686
+ : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
286
687
  }
287
- if (process.platform === 'darwin') {
288
- return parseMacNetworkOutput(await captureCommand('netstat', ['-ibn'], 2500));
688
+ if (platform === 'darwin') {
689
+ const output = await commandOwner.capture('netstat', ['-ibn'], 2500);
690
+ return String(output || '').trim()
691
+ ? { ok: true, value: parseMacNetworkOutput(output) }
692
+ : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
289
693
  }
290
694
  try {
291
- return parseLinuxNetworkOutput(readFileSync('/proc/net/dev', 'utf8'));
695
+ return {
696
+ ok: true,
697
+ value: parseLinuxNetworkOutput(readFileSync('/proc/net/dev', 'utf8'))
698
+ };
292
699
  } catch {
293
- return { receivedBytes: 0, sentBytes: 0 };
700
+ return {
701
+ ok: false,
702
+ value: { receivedBytes: 0, sentBytes: 0 },
703
+ error: 'network-counters-unavailable'
704
+ };
294
705
  }
295
706
  }
296
707
 
@@ -444,7 +855,7 @@ export function readMacDiskSnapshots(options = {}) {
444
855
  return disks;
445
856
  }
446
857
 
447
- async function readWindowsHardwareSnapshot() {
858
+ async function readWindowsHardwareSnapshot(commandOwner) {
448
859
  const script = [
449
860
  '$ErrorActionPreference="Stop"',
450
861
  '$OutputEncoding=[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)',
@@ -453,7 +864,7 @@ async function readWindowsHardwareSnapshot() {
453
864
  '$disks=@(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | Where-Object { $physicalDriveIds -contains $_.DeviceID } | Sort-Object DeviceID | ForEach-Object { [pscustomobject]@{ name=[string]$_.VolumeName; mount=[string]$_.DeviceID; totalBytes=[double]$_.Size; freeBytes=[double]$_.FreeSpace } })',
454
865
  '[pscustomobject]@{ gpus=$gpus; disks=$disks } | ConvertTo-Json -Compress -Depth 5'
455
866
  ].join('; ');
456
- return parseWindowsHardwareOutput(await captureCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 5000));
867
+ return parseWindowsHardwareOutput(await commandOwner.capture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 5000));
457
868
  }
458
869
 
459
870
  function parseMacGpuOutput(output) {
@@ -467,37 +878,45 @@ function parseMacGpuOutput(output) {
467
878
  }
468
879
  }
469
880
 
470
- async function collectHardwareSnapshot() {
471
- const nvidiaPromise = collectNvidiaGpuSnapshot();
472
-
473
- let platformSnapshot = { gpus: [], disks: [] };
474
- if (process.platform === 'win32') {
475
- platformSnapshot = await readWindowsHardwareSnapshot();
476
- } else {
477
- const disk = process.platform === 'darwin' ? null : readRootDiskSnapshot();
478
- const gpus = process.platform === 'darwin'
479
- ? parseMacGpuOutput(await captureCommand('system_profiler', ['SPDisplaysDataType', '-json'], 5000))
480
- : [];
481
- platformSnapshot = { gpus, disks: process.platform === 'darwin' ? readMacDiskSnapshots() : disk ? [disk] : [] };
881
+ async function collectPlatformHardwareProbe(commandOwner, platform = process.platform) {
882
+ if (platform === 'win32') {
883
+ const snapshot = await readWindowsHardwareSnapshot(commandOwner);
884
+ return snapshot.gpus.length > 0 || snapshot.disks.length > 0
885
+ ? { ok: true, value: snapshot }
886
+ : { ok: false, value: snapshot, error: 'windows-hardware-unavailable' };
482
887
  }
483
-
484
- const nvidiaGpus = await nvidiaPromise;
888
+ if (platform === 'darwin') {
889
+ const output = await commandOwner.capture('system_profiler', ['SPDisplaysDataType', '-json'], 5000);
890
+ const snapshot = {
891
+ gpus: parseMacGpuOutput(output),
892
+ disks: readMacDiskSnapshots()
893
+ };
894
+ return String(output || '').trim()
895
+ ? { ok: true, value: snapshot }
896
+ : { ok: false, value: snapshot, error: 'system-profiler-unavailable' };
897
+ }
898
+ const disk = readRootDiskSnapshot();
485
899
  return {
486
- // Preserve the platform adapter inventory on hybrid-GPU computers. The
487
- // NVIDIA sampler enriches its matching adapter instead of replacing every
488
- // non-NVIDIA display adapter with the telemetry subset.
489
- gpus: mergeGpuSnapshots(platformSnapshot.gpus, nvidiaGpus),
490
- disks: platformSnapshot.disks,
491
- collectedAt: new Date().toISOString()
900
+ ok: Boolean(disk),
901
+ value: { gpus: [], disks: disk ? [disk] : [] },
902
+ error: disk ? '' : 'root-disk-unavailable'
492
903
  };
493
904
  }
494
905
 
495
- async function collectNvidiaGpuSnapshot() {
496
- return parseNvidiaSmiOutput(await captureCommand(
497
- process.platform === 'win32' ? 'nvidia-smi.exe' : 'nvidia-smi',
906
+ async function collectNvidiaGpuProbe(commandOwner, platform = process.platform) {
907
+ const command = resolveNvidiaSmiCommand({ platform });
908
+ if (!command) {
909
+ return { ok: false, value: [], error: 'nvidia-smi-unavailable' };
910
+ }
911
+ const output = await commandOwner.capture(
912
+ command,
498
913
  ['--query-gpu=name,utilization.gpu,memory.total,memory.used', '--format=csv,noheader,nounits'],
499
914
  3000
500
- ));
915
+ );
916
+ const gpus = parseNvidiaSmiOutput(output);
917
+ return gpus.length > 0
918
+ ? { ok: true, value: gpus }
919
+ : { ok: false, value: [], error: 'nvidia-smi-empty-or-failed' };
501
920
  }
502
921
 
503
922
  function normalizeOrigin(value) {
@@ -697,19 +1116,76 @@ export function createClientRuntimeServer(options = {}) {
697
1116
  const deviceId = normalizeString(options.deviceId || process.env.LIVEDESK_DEVICE_ID, 160);
698
1117
  const deviceName = normalizeString(options.deviceName || os.hostname(), 160) || os.hostname();
699
1118
  const appVersion = normalizeString(options.appVersion || process.env.LIVEDESK_NPM_LAUNCHER_VERSION, 80) || 'dev';
700
- const savedSession = options.savedSession?.refresh_token ? options.savedSession : readSavedSession();
1119
+ const savedSession = options.loadSavedSession === false
1120
+ ? null
1121
+ : options.savedSession?.refresh_token
1122
+ ? options.savedSession
1123
+ : readSavedSession();
701
1124
  const savedSessionPersisted = Boolean(savedSession?.refresh_token);
702
1125
  const savedAccountProfile = readClientAccountProfile(savedSession);
1126
+ const diagnosticPlatform = normalizeString(options.diagnosticPlatform || process.platform, 20);
1127
+ const diagnosticMonotonicNow = typeof options.diagnosticMonotonicNow === 'function'
1128
+ ? options.diagnosticMonotonicNow
1129
+ : defaultDiagnosticMonotonicNow;
1130
+ const diagnosticProbeTtlMs = Math.max(
1131
+ CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1132
+ Number(options.diagnosticProbeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
1133
+ );
703
1134
  let previousCpuTotals = readCpuTotals();
704
- let hardwareSnapshot = { gpus: [], disks: [], collectedAt: '', collecting: true };
1135
+ let hardwareSnapshot = {
1136
+ gpus: [],
1137
+ disks: [],
1138
+ collectedAt: '',
1139
+ collecting: false,
1140
+ lastError: options.diagnosticsEnabled === false ? 'diagnostics-disabled' : 'not-requested'
1141
+ };
705
1142
  let networkSnapshot = { interfaces: activeNetworkInterfaces(), receivedBytes: 0, sentBytes: 0, receiveBytesPerSecond: 0, sendBytesPerSecond: 0, sampledAt: '' };
706
1143
  let previousNetworkTotals = null;
707
- let hardwareRefreshInFlight = false;
708
- let gpuRefreshInFlight = false;
709
- let networkRefreshInFlight = false;
710
- let hardwareRefreshTimer;
711
- let gpuRefreshTimer;
712
- let networkRefreshTimer;
1144
+ let lastDiagnosticRequestAt = '';
1145
+ let lastDiagnosticCompletedAt = '';
1146
+ let diagnosticRefreshInFlight = null;
1147
+ let closed = false;
1148
+ let runtimeClosePromise = null;
1149
+ const diagnosticCommandOwner = options.diagnosticCommandOwner
1150
+ || createClientDiagnosticCommandOwner();
1151
+ if (typeof diagnosticCommandOwner.capture !== 'function'
1152
+ || typeof diagnosticCommandOwner.close !== 'function'
1153
+ || typeof diagnosticCommandOwner.getSnapshot !== 'function') {
1154
+ throw new TypeError('invalid-client-diagnostic-command-owner');
1155
+ }
1156
+ const diagnosticTasks = new Set();
1157
+ const trackDiagnosticTask = operation => {
1158
+ if (closed) return Promise.resolve();
1159
+ const task = Promise.resolve()
1160
+ .then(operation)
1161
+ .finally(() => diagnosticTasks.delete(task));
1162
+ diagnosticTasks.add(task);
1163
+ return task;
1164
+ };
1165
+ const hardwareProbeCache = createClientDiagnosticProbeCache({
1166
+ probeId: 'platform-hardware',
1167
+ ttlMs: Math.max(CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS, diagnosticProbeTtlMs),
1168
+ negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1169
+ monotonicNow: diagnosticMonotonicNow,
1170
+ fallbackValue: { gpus: [], disks: [] },
1171
+ refresh: () => collectPlatformHardwareProbe(diagnosticCommandOwner, diagnosticPlatform)
1172
+ });
1173
+ const nvidiaProbeCache = createClientDiagnosticProbeCache({
1174
+ probeId: 'nvidia-gpu',
1175
+ ttlMs: diagnosticProbeTtlMs,
1176
+ negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1177
+ monotonicNow: diagnosticMonotonicNow,
1178
+ fallbackValue: [],
1179
+ refresh: () => collectNvidiaGpuProbe(diagnosticCommandOwner, diagnosticPlatform)
1180
+ });
1181
+ const networkProbeCache = createClientDiagnosticProbeCache({
1182
+ probeId: 'network-totals',
1183
+ ttlMs: diagnosticProbeTtlMs,
1184
+ negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1185
+ monotonicNow: diagnosticMonotonicNow,
1186
+ fallbackValue: { receivedBytes: 0, sentBytes: 0 },
1187
+ refresh: () => readNetworkTotalsProbe(diagnosticCommandOwner, diagnosticPlatform)
1188
+ });
713
1189
  const csrfToken = randomBytes(32).toString('hex');
714
1190
  const state = {
715
1191
  route: '/computer',
@@ -759,76 +1235,81 @@ export function createClientRuntimeServer(options = {}) {
759
1235
  });
760
1236
  runtime.emit('runtime.waiting-auth', { role: 'client' });
761
1237
 
762
- const refreshHardwareSnapshot = async () => {
763
- if (hardwareRefreshInFlight) return;
764
- hardwareRefreshInFlight = true;
1238
+ const requestDiagnosticRefresh = () => {
1239
+ if (closed || options.diagnosticsEnabled === false) return Promise.resolve();
1240
+ if (diagnosticRefreshInFlight) return diagnosticRefreshInFlight;
1241
+ lastDiagnosticRequestAt = new Date().toISOString();
765
1242
  hardwareSnapshot = { ...hardwareSnapshot, collecting: true };
766
- try {
767
- hardwareSnapshot = { ...await collectHardwareSnapshot(), collecting: false };
768
- runtime.emit('client.system.hardware', {
769
- gpuCount: hardwareSnapshot.gpus.length,
770
- diskCount: hardwareSnapshot.disks.length
771
- });
772
- } catch (error) {
1243
+ diagnosticRefreshInFlight = trackDiagnosticTask(async () => {
1244
+ const [platformResult, nvidiaResult, networkResult] = await Promise.all([
1245
+ hardwareProbeCache.read(),
1246
+ nvidiaProbeCache.read(),
1247
+ networkProbeCache.read()
1248
+ ]);
1249
+ if (closed) return;
1250
+
1251
+ const platformSnapshot = platformResult.value && typeof platformResult.value === 'object'
1252
+ ? platformResult.value
1253
+ : { gpus: [], disks: [] };
1254
+ const nvidiaGpus = Array.isArray(nvidiaResult.value) ? nvidiaResult.value : [];
1255
+ const errors = [platformResult, nvidiaResult, networkResult]
1256
+ .filter(result => result.ok !== true && result.error)
1257
+ .map(result => normalizeString(result.error, 160));
773
1258
  hardwareSnapshot = {
774
- ...hardwareSnapshot,
1259
+ // Preserve the platform adapter inventory on hybrid-GPU computers. The
1260
+ // NVIDIA sampler enriches its matching adapter instead of replacing
1261
+ // every non-NVIDIA display adapter with the telemetry subset.
1262
+ gpus: mergeGpuSnapshots(platformSnapshot.gpus, nvidiaGpus),
1263
+ disks: Array.isArray(platformSnapshot.disks) ? platformSnapshot.disks : [],
1264
+ collectedAt: new Date().toISOString(),
775
1265
  collecting: false,
776
- lastError: normalizeString(error?.message || error, 160)
1266
+ lastError: errors.join(', ')
777
1267
  };
778
- } finally {
779
- hardwareRefreshInFlight = false;
780
- }
781
- };
782
1268
 
783
- const refreshGpuSnapshot = async () => {
784
- if (gpuRefreshInFlight) return;
785
- gpuRefreshInFlight = true;
786
- try {
787
- const gpus = await collectNvidiaGpuSnapshot();
788
- if (gpus.length > 0) {
1269
+ const totals = networkResult.value && typeof networkResult.value === 'object'
1270
+ ? networkResult.value
1271
+ : { receivedBytes: 0, sentBytes: 0 };
1272
+ if (networkResult.cached !== true || !networkSnapshot.sampledAt) {
1273
+ const sampledAtMs = Date.now();
1274
+ const elapsedSeconds = previousNetworkTotals
1275
+ ? Math.max(0.25, (sampledAtMs - previousNetworkTotals.sampledAtMs) / 1000)
1276
+ : 0;
1277
+ networkSnapshot = {
1278
+ interfaces: activeNetworkInterfaces(),
1279
+ receivedBytes: totals.receivedBytes,
1280
+ sentBytes: totals.sentBytes,
1281
+ receiveBytesPerSecond: elapsedSeconds > 0
1282
+ ? Math.max(0, (totals.receivedBytes - previousNetworkTotals.receivedBytes) / elapsedSeconds)
1283
+ : 0,
1284
+ sendBytesPerSecond: elapsedSeconds > 0
1285
+ ? Math.max(0, (totals.sentBytes - previousNetworkTotals.sentBytes) / elapsedSeconds)
1286
+ : 0,
1287
+ sampledAt: new Date(sampledAtMs).toISOString()
1288
+ };
1289
+ previousNetworkTotals = { ...totals, sampledAtMs };
1290
+ }
1291
+ lastDiagnosticCompletedAt = new Date().toISOString();
1292
+ runtime.emit('client.system.diagnostics', {
1293
+ gpuCount: hardwareSnapshot.gpus.length,
1294
+ diskCount: hardwareSnapshot.disks.length,
1295
+ platformHardwareGeneration: platformResult.generation,
1296
+ nvidiaGeneration: nvidiaResult.generation,
1297
+ networkGeneration: networkResult.generation
1298
+ });
1299
+ });
1300
+ void diagnosticRefreshInFlight.finally(() => {
1301
+ diagnosticRefreshInFlight = null;
1302
+ if (!closed && hardwareSnapshot.collecting) {
789
1303
  hardwareSnapshot = {
790
1304
  ...hardwareSnapshot,
791
- gpus: mergeGpuSnapshots(hardwareSnapshot.gpus, gpus),
792
- collectedAt: new Date().toISOString()
1305
+ collecting: false,
1306
+ lastError: hardwareSnapshot.lastError || 'diagnostic-refresh-failed'
793
1307
  };
794
1308
  }
795
- } finally {
796
- gpuRefreshInFlight = false;
797
- }
798
- };
799
-
800
- const refreshNetworkSnapshot = async () => {
801
- if (networkRefreshInFlight) return;
802
- networkRefreshInFlight = true;
803
- try {
804
- const totals = await readNetworkTotals();
805
- const sampledAtMs = Date.now();
806
- const elapsedSeconds = previousNetworkTotals
807
- ? Math.max(0.25, (sampledAtMs - previousNetworkTotals.sampledAtMs) / 1000)
808
- : 0;
809
- networkSnapshot = {
810
- interfaces: activeNetworkInterfaces(),
811
- receivedBytes: totals.receivedBytes,
812
- sentBytes: totals.sentBytes,
813
- receiveBytesPerSecond: elapsedSeconds > 0 ? Math.max(0, (totals.receivedBytes - previousNetworkTotals.receivedBytes) / elapsedSeconds) : 0,
814
- sendBytesPerSecond: elapsedSeconds > 0 ? Math.max(0, (totals.sentBytes - previousNetworkTotals.sentBytes) / elapsedSeconds) : 0,
815
- sampledAt: new Date(sampledAtMs).toISOString()
816
- };
817
- previousNetworkTotals = { ...totals, sampledAtMs };
818
- } finally {
819
- networkRefreshInFlight = false;
820
- }
1309
+ });
1310
+ return diagnosticRefreshInFlight;
821
1311
  };
822
1312
 
823
- void refreshHardwareSnapshot();
824
- void refreshNetworkSnapshot();
825
- hardwareRefreshTimer = setInterval(() => { void refreshHardwareSnapshot(); }, 60_000);
826
- hardwareRefreshTimer.unref?.();
827
- gpuRefreshTimer = setInterval(() => { void refreshGpuSnapshot(); }, 2500);
828
- gpuRefreshTimer.unref?.();
829
- networkRefreshTimer = setInterval(() => { void refreshNetworkSnapshot(); }, 2000);
830
- networkRefreshTimer.unref?.();
831
-
832
1313
  const recordAuthAttempt = (result, error = '') => {
833
1314
  const message = normalizeString(error, 240);
834
1315
  state.auth = { ...state.auth, lastAttemptAt: new Date().toISOString(), lastResult: normalizeString(result, 80), lastError: message };
@@ -880,6 +1361,7 @@ export function createClientRuntimeServer(options = {}) {
880
1361
  lastAttemptAt: state.connectedAt,
881
1362
  lastResult: 'accepted',
882
1363
  lastError: '',
1364
+ persisted: Boolean(choice?.session?.refresh_token) || state.auth.persisted,
883
1365
  email: accountProfile.email || state.auth.email,
884
1366
  name: accountProfile.name || state.auth.name,
885
1367
  avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
@@ -945,7 +1427,17 @@ export function createClientRuntimeServer(options = {}) {
945
1427
  disks: hardwareSnapshot.disks,
946
1428
  network: networkSnapshot,
947
1429
  hardwareCollectedAt: hardwareSnapshot.collectedAt,
948
- hardwareCollecting: hardwareSnapshot.collecting
1430
+ hardwareCollecting: hardwareSnapshot.collecting,
1431
+ hardwareLastError: hardwareSnapshot.lastError || '',
1432
+ diagnosticProbes: {
1433
+ requestDriven: true,
1434
+ minimumTtlMs: CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1435
+ requestedAt: lastDiagnosticRequestAt,
1436
+ completedAt: lastDiagnosticCompletedAt,
1437
+ platformHardware: hardwareProbeCache.getSnapshot(),
1438
+ nvidiaGpu: nvidiaProbeCache.getSnapshot(),
1439
+ networkTotals: networkProbeCache.getSnapshot()
1440
+ }
949
1441
  },
950
1442
  permissions: {
951
1443
  screenCapture: state.agent.state === 'running',
@@ -1014,11 +1506,16 @@ export function createClientRuntimeServer(options = {}) {
1014
1506
  respondJson(200, { ok: true, product: 'LiveDesk', role: 'client', timestamp: new Date().toISOString() });
1015
1507
  return;
1016
1508
  }
1017
- if (pathname === '/api/runtime' || pathname === '/api/runtime/status' || pathname === '/api/client/status' || pathname === '/api/client/computer' || pathname === '/api/client/system' || pathname === '/api/client/permissions' || pathname === '/api/client/diagnostics') {
1509
+ if (pathname === '/api/client/diagnostics') {
1510
+ await requestDiagnosticRefresh();
1018
1511
  respondJson(200, getState());
1019
- return;
1020
- }
1021
- if (pathname === '/api/runtime/events') {
1512
+ return;
1513
+ }
1514
+ if (pathname === '/api/runtime' || pathname === '/api/runtime/status' || pathname === '/api/client/status' || pathname === '/api/client/computer' || pathname === '/api/client/system' || pathname === '/api/client/permissions') {
1515
+ respondJson(200, getState());
1516
+ return;
1517
+ }
1518
+ if (pathname === '/api/runtime/events') {
1022
1519
  respondJson(200, { ok: true, events: runtime.getEvents() });
1023
1520
  return;
1024
1521
  }
@@ -1355,14 +1852,33 @@ export function createClientRuntimeServer(options = {}) {
1355
1852
 
1356
1853
  const start = () => new Promise((resolveStart, rejectStart) => {
1357
1854
  if (process.env.LIVEDESK_DESKTOP_HOST === '1') clearSavedSession();
1855
+ let settled = false;
1358
1856
  server = createServer((req, res) => {
1359
1857
  void handleRequest(req, res).catch(error => {
1360
1858
  if (!res.headersSent) sendJson(res, 500, { ok: false, error: normalizeString(error?.message || error) }, req.headers.origin, port, trustedWebOrigins);
1361
1859
  else res.end();
1362
- });
1363
- });
1364
- server.once('error', rejectStart);
1860
+ });
1861
+ });
1862
+ const bindTimer = setTimeout(() => {
1863
+ if (settled) return;
1864
+ settled = true;
1865
+ try { server.close(); } catch { /* bind never completed */ }
1866
+ rejectStart(new Error(`client-runtime-bind-timeout:${host}:${port}`));
1867
+ }, CLIENT_RUNTIME_BIND_TIMEOUT_MS);
1868
+ bindTimer.unref?.();
1869
+ server.once('error', error => {
1870
+ if (settled) return;
1871
+ settled = true;
1872
+ clearTimeout(bindTimer);
1873
+ rejectStart(error);
1874
+ });
1365
1875
  server.listen(port, host, () => {
1876
+ if (settled) {
1877
+ try { server.close(); } catch { /* timed-out bind already owns cleanup */ }
1878
+ return;
1879
+ }
1880
+ settled = true;
1881
+ clearTimeout(bindTimer);
1366
1882
  runtime.emit('runtime.http.started', { host, port });
1367
1883
  if (initialChoice) {
1368
1884
  setImmediate(() => {
@@ -1392,15 +1908,90 @@ export function createClientRuntimeServer(options = {}) {
1392
1908
  }
1393
1909
  resolveStart();
1394
1910
  });
1395
- });
1396
-
1397
- return {
1911
+ });
1912
+
1913
+ const closeHttpServer = timeoutMs => new Promise(resolveClose => {
1914
+ if (!server) {
1915
+ resolveClose({ ok: true, listening: false });
1916
+ return;
1917
+ }
1918
+ let settled = false;
1919
+ const finish = ok => {
1920
+ if (settled) return;
1921
+ settled = true;
1922
+ clearTimeout(timer);
1923
+ resolveClose({ ok, listening: Boolean(server?.listening) });
1924
+ };
1925
+ const timer = setTimeout(() => {
1926
+ try { server.closeAllConnections?.(); } catch { /* exact HTTP owner already closed */ }
1927
+ finish(!server.listening);
1928
+ }, Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS));
1929
+ try {
1930
+ server.close(error => finish(!error));
1931
+ server.closeIdleConnections?.();
1932
+ } catch {
1933
+ finish(true);
1934
+ }
1935
+ });
1936
+
1937
+ const closeRuntime = async () => {
1938
+ const closeStartedAt = Date.now();
1939
+ closed = true;
1940
+ runtime.emit('runtime.http.stopping', { port });
1941
+
1942
+ const taskOwners = [...diagnosticTasks];
1943
+ const [diagnosticClose, httpClose] = await Promise.all([
1944
+ diagnosticCommandOwner.close(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS),
1945
+ closeHttpServer(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS)
1946
+ ]);
1947
+ if (taskOwners.length > 0) {
1948
+ const remainingCloseMs = Math.max(
1949
+ 0,
1950
+ CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS - (Date.now() - closeStartedAt)
1951
+ );
1952
+ if (remainingCloseMs > 0) {
1953
+ await Promise.race([
1954
+ Promise.allSettled(taskOwners),
1955
+ new Promise(resolve => setTimeout(resolve, remainingCloseMs))
1956
+ ]);
1957
+ }
1958
+ }
1959
+ const finalDiagnostics = diagnosticCommandOwner.getSnapshot();
1960
+ const result = {
1961
+ ok: diagnosticClose.ok === true
1962
+ && httpClose.ok === true
1963
+ && diagnosticTasks.size === 0
1964
+ && finalDiagnostics.activeCount === 0
1965
+ && finalDiagnostics.redirectedStreamCount === 0,
1966
+ diagnostics: {
1967
+ ...diagnosticClose,
1968
+ taskCount: diagnosticTasks.size
1969
+ },
1970
+ http: httpClose
1971
+ };
1972
+ runtime.emit(
1973
+ result.ok ? 'client.diagnostics.closed' : 'client.diagnostics.close-timeout',
1974
+ {
1975
+ ownerId: finalDiagnostics.ownerId,
1976
+ activeCount: finalDiagnostics.activeCount,
1977
+ redirectedStreamCount: finalDiagnostics.redirectedStreamCount,
1978
+ taskCount: diagnosticTasks.size
1979
+ }
1980
+ );
1981
+ return result;
1982
+ };
1983
+
1984
+ return {
1398
1985
  runtime,
1399
- waitForChoice,
1400
- start,
1986
+ waitForChoice,
1987
+ start,
1401
1988
  get url() { return `http://${host}:${port}/`; },
1402
1989
  getLastChoice() { return lastChoice; },
1403
- update(patch = {}) {
1990
+ acceptChoice(choice, message) {
1991
+ complete(choice, message);
1992
+ return lastChoice;
1993
+ },
1994
+ update(patch = {}) {
1404
1995
  Object.assign(state, patch);
1405
1996
  if (patch.agent && typeof patch.agent === 'object') {
1406
1997
  state.agent = { ...state.agent, ...patch.agent };
@@ -1418,11 +2009,8 @@ export function createClientRuntimeServer(options = {}) {
1418
2009
  if (Array.isArray(patch.endpointCandidates)) state.endpointCandidates = patch.endpointCandidates;
1419
2010
  },
1420
2011
  close() {
1421
- runtime.emit('runtime.http.stopping', { port });
1422
- if (hardwareRefreshTimer) clearInterval(hardwareRefreshTimer);
1423
- if (gpuRefreshTimer) clearInterval(gpuRefreshTimer);
1424
- if (networkRefreshTimer) clearInterval(networkRefreshTimer);
1425
- try { server?.close(); } catch { /* already closed */ }
2012
+ if (!runtimeClosePromise) runtimeClosePromise = closeRuntime();
2013
+ return runtimeClosePromise;
1426
2014
  },
1427
2015
  getRoleRestartRequest() { return options.getRoleRestartRequest?.() || null; }
1428
2016
  };