@livedesk/client 0.1.214 → 0.1.216

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';
@@ -165,6 +172,12 @@ function readCpuTotals() {
165
172
  }
166
173
 
167
174
  function finiteNumber(value) {
175
+ if (value === null
176
+ || value === undefined
177
+ || typeof value === 'boolean'
178
+ || (typeof value === 'string' && value.trim() === '')) {
179
+ return null;
180
+ }
168
181
  const number = Number(value);
169
182
  return Number.isFinite(number) && number >= 0 ? number : null;
170
183
  }
@@ -175,15 +188,406 @@ function percentFromRatio(value) {
175
188
  return Math.round(Math.max(0, Math.min(100, number)) * 10) / 10;
176
189
  }
177
190
 
178
- function captureCommand(command, args, timeout = 3500) {
179
- return new Promise(resolveOutput => {
180
- execFile(command, args, {
181
- encoding: 'utf8',
182
- maxBuffer: 512 * 1024,
183
- timeout,
184
- windowsHide: true
185
- }, (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)
186
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
+ };
187
591
  }
188
592
 
189
593
  export function parseNvidiaSmiOutput(output) {
@@ -274,17 +678,30 @@ function activeNetworkInterfaces() {
274
678
  return interfaces;
275
679
  }
276
680
 
277
- async function readNetworkTotals() {
278
- if (process.platform === 'win32') {
279
- 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' };
280
687
  }
281
- if (process.platform === 'darwin') {
282
- 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' };
283
693
  }
284
694
  try {
285
- return parseLinuxNetworkOutput(readFileSync('/proc/net/dev', 'utf8'));
695
+ return {
696
+ ok: true,
697
+ value: parseLinuxNetworkOutput(readFileSync('/proc/net/dev', 'utf8'))
698
+ };
286
699
  } catch {
287
- return { receivedBytes: 0, sentBytes: 0 };
700
+ return {
701
+ ok: false,
702
+ value: { receivedBytes: 0, sentBytes: 0 },
703
+ error: 'network-counters-unavailable'
704
+ };
288
705
  }
289
706
  }
290
707
 
@@ -300,6 +717,53 @@ function normalizeGpu(value) {
300
717
  };
301
718
  }
302
719
 
720
+ export function normalizeGpuIdentityName(value) {
721
+ return normalizeString(value, 160)
722
+ .normalize('NFKC')
723
+ .replace(/\((?:r|tm)\)/giu, '')
724
+ .replace(/[®™]/gu, '')
725
+ .toLocaleLowerCase('en-US')
726
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
727
+ .trim();
728
+ }
729
+
730
+ export function mergeGpuSnapshots(platformGpus = [], telemetryGpus = []) {
731
+ const merged = [];
732
+ const indexesByIdentity = new Map();
733
+ for (const value of Array.isArray(platformGpus) ? platformGpus : []) {
734
+ const gpu = normalizeGpu(value);
735
+ const identity = normalizeGpuIdentityName(gpu?.name);
736
+ if (!gpu || !identity) continue;
737
+ const index = merged.length;
738
+ merged.push(gpu);
739
+ const indexes = indexesByIdentity.get(identity) || [];
740
+ indexes.push(index);
741
+ indexesByIdentity.set(identity, indexes);
742
+ }
743
+
744
+ const matchedIndexes = new Set();
745
+ for (const value of Array.isArray(telemetryGpus) ? telemetryGpus : []) {
746
+ const gpu = normalizeGpu(value);
747
+ const identity = normalizeGpuIdentityName(gpu?.name);
748
+ if (!gpu || !identity) continue;
749
+ const existingIndex = (indexesByIdentity.get(identity) || [])
750
+ .find(index => !matchedIndexes.has(index));
751
+ if (existingIndex === undefined) {
752
+ merged.push(gpu);
753
+ continue;
754
+ }
755
+ matchedIndexes.add(existingIndex);
756
+ const existing = merged[existingIndex];
757
+ merged[existingIndex] = {
758
+ ...existing,
759
+ usagePercent: gpu.usagePercent ?? existing.usagePercent,
760
+ memoryTotalBytes: gpu.memoryTotalBytes ?? existing.memoryTotalBytes,
761
+ memoryUsedBytes: gpu.memoryUsedBytes ?? existing.memoryUsedBytes
762
+ };
763
+ }
764
+ return merged;
765
+ }
766
+
303
767
  function normalizeDisk(value) {
304
768
  if (!value || typeof value !== 'object') return null;
305
769
  const mount = normalizeString(value.mount, 80);
@@ -391,7 +855,7 @@ export function readMacDiskSnapshots(options = {}) {
391
855
  return disks;
392
856
  }
393
857
 
394
- async function readWindowsHardwareSnapshot() {
858
+ async function readWindowsHardwareSnapshot(commandOwner) {
395
859
  const script = [
396
860
  '$ErrorActionPreference="Stop"',
397
861
  '$OutputEncoding=[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)',
@@ -400,7 +864,7 @@ async function readWindowsHardwareSnapshot() {
400
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 } })',
401
865
  '[pscustomobject]@{ gpus=$gpus; disks=$disks } | ConvertTo-Json -Compress -Depth 5'
402
866
  ].join('; ');
403
- return parseWindowsHardwareOutput(await captureCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 5000));
867
+ return parseWindowsHardwareOutput(await commandOwner.capture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 5000));
404
868
  }
405
869
 
406
870
  function parseMacGpuOutput(output) {
@@ -414,34 +878,45 @@ function parseMacGpuOutput(output) {
414
878
  }
415
879
  }
416
880
 
417
- async function collectHardwareSnapshot() {
418
- const nvidiaPromise = collectNvidiaGpuSnapshot();
419
-
420
- let platformSnapshot = { gpus: [], disks: [] };
421
- if (process.platform === 'win32') {
422
- platformSnapshot = await readWindowsHardwareSnapshot();
423
- } else {
424
- const disk = process.platform === 'darwin' ? null : readRootDiskSnapshot();
425
- const gpus = process.platform === 'darwin'
426
- ? parseMacGpuOutput(await captureCommand('system_profiler', ['SPDisplaysDataType', '-json'], 5000))
427
- : [];
428
- 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' };
429
887
  }
430
-
431
- 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();
432
899
  return {
433
- gpus: nvidiaGpus.length > 0 ? nvidiaGpus : platformSnapshot.gpus,
434
- disks: platformSnapshot.disks,
435
- collectedAt: new Date().toISOString()
900
+ ok: Boolean(disk),
901
+ value: { gpus: [], disks: disk ? [disk] : [] },
902
+ error: disk ? '' : 'root-disk-unavailable'
436
903
  };
437
904
  }
438
905
 
439
- async function collectNvidiaGpuSnapshot() {
440
- return parseNvidiaSmiOutput(await captureCommand(
441
- 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,
442
913
  ['--query-gpu=name,utilization.gpu,memory.total,memory.used', '--format=csv,noheader,nounits'],
443
914
  3000
444
- ));
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' };
445
920
  }
446
921
 
447
922
  function normalizeOrigin(value) {
@@ -641,19 +1116,76 @@ export function createClientRuntimeServer(options = {}) {
641
1116
  const deviceId = normalizeString(options.deviceId || process.env.LIVEDESK_DEVICE_ID, 160);
642
1117
  const deviceName = normalizeString(options.deviceName || os.hostname(), 160) || os.hostname();
643
1118
  const appVersion = normalizeString(options.appVersion || process.env.LIVEDESK_NPM_LAUNCHER_VERSION, 80) || 'dev';
644
- 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();
645
1124
  const savedSessionPersisted = Boolean(savedSession?.refresh_token);
646
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
+ );
647
1134
  let previousCpuTotals = readCpuTotals();
648
- 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
+ };
649
1142
  let networkSnapshot = { interfaces: activeNetworkInterfaces(), receivedBytes: 0, sentBytes: 0, receiveBytesPerSecond: 0, sendBytesPerSecond: 0, sampledAt: '' };
650
1143
  let previousNetworkTotals = null;
651
- let hardwareRefreshInFlight = false;
652
- let gpuRefreshInFlight = false;
653
- let networkRefreshInFlight = false;
654
- let hardwareRefreshTimer;
655
- let gpuRefreshTimer;
656
- 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
+ });
657
1189
  const csrfToken = randomBytes(32).toString('hex');
658
1190
  const state = {
659
1191
  route: '/computer',
@@ -703,72 +1235,81 @@ export function createClientRuntimeServer(options = {}) {
703
1235
  });
704
1236
  runtime.emit('runtime.waiting-auth', { role: 'client' });
705
1237
 
706
- const refreshHardwareSnapshot = async () => {
707
- if (hardwareRefreshInFlight) return;
708
- hardwareRefreshInFlight = true;
1238
+ const requestDiagnosticRefresh = () => {
1239
+ if (closed || options.diagnosticsEnabled === false) return Promise.resolve();
1240
+ if (diagnosticRefreshInFlight) return diagnosticRefreshInFlight;
1241
+ lastDiagnosticRequestAt = new Date().toISOString();
709
1242
  hardwareSnapshot = { ...hardwareSnapshot, collecting: true };
710
- try {
711
- hardwareSnapshot = { ...await collectHardwareSnapshot(), collecting: false };
712
- runtime.emit('client.system.hardware', {
713
- gpuCount: hardwareSnapshot.gpus.length,
714
- diskCount: hardwareSnapshot.disks.length
715
- });
716
- } 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));
717
1258
  hardwareSnapshot = {
718
- ...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(),
719
1265
  collecting: false,
720
- lastError: normalizeString(error?.message || error, 160)
1266
+ lastError: errors.join(', ')
721
1267
  };
722
- } finally {
723
- hardwareRefreshInFlight = false;
724
- }
725
- };
726
1268
 
727
- const refreshGpuSnapshot = async () => {
728
- if (gpuRefreshInFlight) return;
729
- gpuRefreshInFlight = true;
730
- try {
731
- const gpus = await collectNvidiaGpuSnapshot();
732
- if (gpus.length > 0) {
733
- hardwareSnapshot = { ...hardwareSnapshot, gpus, collectedAt: new Date().toISOString() };
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 };
734
1290
  }
735
- } finally {
736
- gpuRefreshInFlight = false;
737
- }
738
- };
739
-
740
- const refreshNetworkSnapshot = async () => {
741
- if (networkRefreshInFlight) return;
742
- networkRefreshInFlight = true;
743
- try {
744
- const totals = await readNetworkTotals();
745
- const sampledAtMs = Date.now();
746
- const elapsedSeconds = previousNetworkTotals
747
- ? Math.max(0.25, (sampledAtMs - previousNetworkTotals.sampledAtMs) / 1000)
748
- : 0;
749
- networkSnapshot = {
750
- interfaces: activeNetworkInterfaces(),
751
- receivedBytes: totals.receivedBytes,
752
- sentBytes: totals.sentBytes,
753
- receiveBytesPerSecond: elapsedSeconds > 0 ? Math.max(0, (totals.receivedBytes - previousNetworkTotals.receivedBytes) / elapsedSeconds) : 0,
754
- sendBytesPerSecond: elapsedSeconds > 0 ? Math.max(0, (totals.sentBytes - previousNetworkTotals.sentBytes) / elapsedSeconds) : 0,
755
- sampledAt: new Date(sampledAtMs).toISOString()
756
- };
757
- previousNetworkTotals = { ...totals, sampledAtMs };
758
- } finally {
759
- networkRefreshInFlight = false;
760
- }
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) {
1303
+ hardwareSnapshot = {
1304
+ ...hardwareSnapshot,
1305
+ collecting: false,
1306
+ lastError: hardwareSnapshot.lastError || 'diagnostic-refresh-failed'
1307
+ };
1308
+ }
1309
+ });
1310
+ return diagnosticRefreshInFlight;
761
1311
  };
762
1312
 
763
- void refreshHardwareSnapshot();
764
- void refreshNetworkSnapshot();
765
- hardwareRefreshTimer = setInterval(() => { void refreshHardwareSnapshot(); }, 60_000);
766
- hardwareRefreshTimer.unref?.();
767
- gpuRefreshTimer = setInterval(() => { void refreshGpuSnapshot(); }, 2500);
768
- gpuRefreshTimer.unref?.();
769
- networkRefreshTimer = setInterval(() => { void refreshNetworkSnapshot(); }, 2000);
770
- networkRefreshTimer.unref?.();
771
-
772
1313
  const recordAuthAttempt = (result, error = '') => {
773
1314
  const message = normalizeString(error, 240);
774
1315
  state.auth = { ...state.auth, lastAttemptAt: new Date().toISOString(), lastResult: normalizeString(result, 80), lastError: message };
@@ -820,6 +1361,7 @@ export function createClientRuntimeServer(options = {}) {
820
1361
  lastAttemptAt: state.connectedAt,
821
1362
  lastResult: 'accepted',
822
1363
  lastError: '',
1364
+ persisted: Boolean(choice?.session?.refresh_token) || state.auth.persisted,
823
1365
  email: accountProfile.email || state.auth.email,
824
1366
  name: accountProfile.name || state.auth.name,
825
1367
  avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
@@ -885,7 +1427,17 @@ export function createClientRuntimeServer(options = {}) {
885
1427
  disks: hardwareSnapshot.disks,
886
1428
  network: networkSnapshot,
887
1429
  hardwareCollectedAt: hardwareSnapshot.collectedAt,
888
- 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
+ }
889
1441
  },
890
1442
  permissions: {
891
1443
  screenCapture: state.agent.state === 'running',
@@ -954,11 +1506,16 @@ export function createClientRuntimeServer(options = {}) {
954
1506
  respondJson(200, { ok: true, product: 'LiveDesk', role: 'client', timestamp: new Date().toISOString() });
955
1507
  return;
956
1508
  }
957
- 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();
958
1511
  respondJson(200, getState());
959
- return;
960
- }
961
- 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') {
962
1519
  respondJson(200, { ok: true, events: runtime.getEvents() });
963
1520
  return;
964
1521
  }
@@ -1295,14 +1852,33 @@ export function createClientRuntimeServer(options = {}) {
1295
1852
 
1296
1853
  const start = () => new Promise((resolveStart, rejectStart) => {
1297
1854
  if (process.env.LIVEDESK_DESKTOP_HOST === '1') clearSavedSession();
1855
+ let settled = false;
1298
1856
  server = createServer((req, res) => {
1299
1857
  void handleRequest(req, res).catch(error => {
1300
1858
  if (!res.headersSent) sendJson(res, 500, { ok: false, error: normalizeString(error?.message || error) }, req.headers.origin, port, trustedWebOrigins);
1301
1859
  else res.end();
1302
- });
1303
- });
1304
- 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
+ });
1305
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);
1306
1882
  runtime.emit('runtime.http.started', { host, port });
1307
1883
  if (initialChoice) {
1308
1884
  setImmediate(() => {
@@ -1332,15 +1908,90 @@ export function createClientRuntimeServer(options = {}) {
1332
1908
  }
1333
1909
  resolveStart();
1334
1910
  });
1335
- });
1336
-
1337
- 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 {
1338
1985
  runtime,
1339
- waitForChoice,
1340
- start,
1986
+ waitForChoice,
1987
+ start,
1341
1988
  get url() { return `http://${host}:${port}/`; },
1342
1989
  getLastChoice() { return lastChoice; },
1343
- update(patch = {}) {
1990
+ acceptChoice(choice, message) {
1991
+ complete(choice, message);
1992
+ return lastChoice;
1993
+ },
1994
+ update(patch = {}) {
1344
1995
  Object.assign(state, patch);
1345
1996
  if (patch.agent && typeof patch.agent === 'object') {
1346
1997
  state.agent = { ...state.agent, ...patch.agent };
@@ -1358,11 +2009,8 @@ export function createClientRuntimeServer(options = {}) {
1358
2009
  if (Array.isArray(patch.endpointCandidates)) state.endpointCandidates = patch.endpointCandidates;
1359
2010
  },
1360
2011
  close() {
1361
- runtime.emit('runtime.http.stopping', { port });
1362
- if (hardwareRefreshTimer) clearInterval(hardwareRefreshTimer);
1363
- if (gpuRefreshTimer) clearInterval(gpuRefreshTimer);
1364
- if (networkRefreshTimer) clearInterval(networkRefreshTimer);
1365
- try { server?.close(); } catch { /* already closed */ }
2012
+ if (!runtimeClosePromise) runtimeClosePromise = closeRuntime();
2013
+ return runtimeClosePromise;
1366
2014
  },
1367
2015
  getRoleRestartRequest() { return options.getRoleRestartRequest?.() || null; }
1368
2016
  };