livedesk 0.1.444 → 0.1.446

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/bin/livedesk.js CHANGED
@@ -5,7 +5,17 @@ import net from 'node:net';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { execFile, spawn } from 'node:child_process';
8
- import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
+ import {
9
+ createWriteStream,
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ readlinkSync,
15
+ renameSync,
16
+ rmSync,
17
+ writeFileSync
18
+ } from 'node:fs';
9
19
  import { randomBytes } from 'node:crypto';
10
20
  import os from 'node:os';
11
21
  import { resolveDeviceRole, writeRoleCache } from '../bootstrap/device-role.js';
@@ -558,9 +568,9 @@ async function isManagerAlreadyRunning(url = DEFAULT_MANAGER_URL) {
558
568
  }
559
569
  }
560
570
 
561
- function runQuiet(command, args) {
562
- return new Promise(resolve => {
563
- execFile(command, args, { windowsHide: true }, (error, stdout, stderr) => {
571
+ function runQuiet(command, args, options = {}) {
572
+ return new Promise(resolve => {
573
+ execFile(command, args, { windowsHide: true, ...options }, (error, stdout, stderr) => {
564
574
  resolve({
565
575
  ok: !error,
566
576
  stdout: String(stdout || '').trim(),
@@ -632,6 +642,28 @@ async function probeExistingRuntimeIdentity(existing) {
632
642
  return null;
633
643
  }
634
644
 
645
+ function readLinuxProcessStartMarker(pid) {
646
+ try {
647
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
648
+ const commandEnd = stat.lastIndexOf(')');
649
+ if (commandEnd < 0) return '';
650
+ const fieldsAfterCommand = stat.slice(commandEnd + 1).trim().split(/\s+/);
651
+ // /proc/<pid>/stat field 22 is the process start time in clock ticks.
652
+ // fieldsAfterCommand[0] is field 3 (state).
653
+ return fieldsAfterCommand[19] ? `linux-start-ticks:${fieldsAfterCommand[19]}` : '';
654
+ } catch {
655
+ return '';
656
+ }
657
+ }
658
+
659
+ function processInstanceMarker(details) {
660
+ const processId = Number(details?.processId || 0);
661
+ const startMarker = String(details?.startMarker || '').trim();
662
+ return Number.isInteger(processId) && processId > 1 && startMarker
663
+ ? `${processId}:${startMarker}`
664
+ : '';
665
+ }
666
+
635
667
  async function getProcessDetails(pid) {
636
668
  const processId = Number(pid);
637
669
  if (!Number.isInteger(processId) || processId <= 1) {
@@ -639,31 +671,114 @@ async function getProcessDetails(pid) {
639
671
  }
640
672
 
641
673
  if (os.platform() === 'win32') {
642
- const script = `$targetPid = ${processId}; $process = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -ErrorAction SilentlyContinue | Select-Object -First 1; if ($process) { [pscustomobject]@{ ProcessName = [string]$process.Name; CommandLine = [string]$process.CommandLine } | ConvertTo-Json -Compress }`;
643
- const result = await runQuiet('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script]);
674
+ const script = `$targetPid = ${processId}; $process = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -ErrorAction SilentlyContinue | Select-Object -First 1; if ($process) { $creationUtcTicks = if ($process.CreationDate) { [string]$process.CreationDate.ToUniversalTime().Ticks } else { "" }; [pscustomobject]@{ ProcessId = [int]$process.ProcessId; ParentProcessId = [int]$process.ParentProcessId; ProcessName = [string]$process.Name; CommandLine = [string]$process.CommandLine; ExecutablePath = [string]$process.ExecutablePath; CreationDate = [string]$process.CreationDate; CreationUtcTicks = $creationUtcTicks } | ConvertTo-Json -Compress }`;
675
+ const result = await runQuiet(
676
+ 'powershell.exe',
677
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
678
+ { timeout: 10000, killSignal: 'SIGKILL' }
679
+ );
644
680
  if (!result.stdout) return null;
645
681
  try {
646
682
  const parsed = JSON.parse(result.stdout);
647
683
  return {
684
+ processId: Number(parsed?.ProcessId || processId),
685
+ parentProcessId: Number(parsed?.ParentProcessId || 0),
648
686
  processName: String(parsed?.ProcessName || ''),
649
- commandLine: String(parsed?.CommandLine || '')
687
+ commandLine: String(parsed?.CommandLine || ''),
688
+ executablePath: String(parsed?.ExecutablePath || ''),
689
+ startMarker: String(parsed?.CreationDate || ''),
690
+ startOrder: String(parsed?.CreationUtcTicks || '')
650
691
  };
651
692
  } catch {
652
693
  return null;
653
694
  }
654
695
  }
655
696
 
656
- const [nameResult, commandResult] = await Promise.all([
697
+ const [nameResult, commandResult, groupResult, parentResult, startResult] = await Promise.all([
657
698
  runQuiet('ps', ['-p', String(processId), '-o', 'comm=']),
658
- runQuiet('ps', ['-p', String(processId), '-o', 'args='])
699
+ runQuiet('ps', ['-p', String(processId), '-o', 'args=']),
700
+ runQuiet('ps', ['-p', String(processId), '-o', 'pgid=']),
701
+ runQuiet('ps', ['-p', String(processId), '-o', 'ppid=']),
702
+ runQuiet('ps', ['-p', String(processId), '-o', 'lstart='])
659
703
  ]);
660
704
  if (!nameResult.stdout && !commandResult.stdout) return null;
705
+ let executablePath = '';
706
+ if (os.platform() === 'linux') {
707
+ try {
708
+ executablePath = readlinkSync(`/proc/${processId}/exe`);
709
+ } catch {
710
+ executablePath = '';
711
+ }
712
+ }
661
713
  return {
714
+ processId,
715
+ parentProcessId: Number(parentResult.stdout || 0),
662
716
  processName: nameResult.stdout,
663
- commandLine: commandResult.stdout
717
+ commandLine: commandResult.stdout,
718
+ executablePath,
719
+ processGroupId: Number(groupResult.stdout || 0),
720
+ startMarker: readLinuxProcessStartMarker(processId) || startResult.stdout
664
721
  };
665
722
  }
666
723
 
724
+ async function hasKnownLiveDeskClientAgentDescendant(rootPid) {
725
+ const processId = Number(rootPid);
726
+ if (!Number.isInteger(processId) || processId <= 1) return false;
727
+ let pairs = [];
728
+ if (os.platform() === 'win32') {
729
+ const script = '$ErrorActionPreference = "Stop"; @(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId } }) | ConvertTo-Json -Compress';
730
+ const result = await runQuiet(
731
+ 'powershell.exe',
732
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
733
+ { timeout: 10000, killSignal: 'SIGKILL' }
734
+ );
735
+ if (!result.ok || !result.stdout) return false;
736
+ try {
737
+ const parsed = JSON.parse(result.stdout);
738
+ pairs = (Array.isArray(parsed) ? parsed : [parsed]).map(item => ({
739
+ pid: Number(item?.ProcessId || 0),
740
+ parentPid: Number(item?.ParentProcessId || 0)
741
+ }));
742
+ } catch {
743
+ return false;
744
+ }
745
+ } else {
746
+ const result = await runQuiet(
747
+ 'ps',
748
+ ['-axo', 'pid=,ppid='],
749
+ { timeout: 10000, killSignal: 'SIGKILL' }
750
+ );
751
+ if (!result.ok || !result.stdout) return false;
752
+ pairs = result.stdout.split(/\r?\n/).flatMap(line => {
753
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s*$/);
754
+ return match ? [{ pid: Number(match[1]), parentPid: Number(match[2]) }] : [];
755
+ });
756
+ }
757
+
758
+ const childrenByParent = new Map();
759
+ for (const pair of pairs) {
760
+ if (!childrenByParent.has(pair.parentPid)) childrenByParent.set(pair.parentPid, []);
761
+ childrenByParent.get(pair.parentPid).push(pair.pid);
762
+ }
763
+ const queue = [...(childrenByParent.get(processId) || [])];
764
+ const seen = new Set();
765
+ while (queue.length > 0) {
766
+ const candidatePid = queue.shift();
767
+ if (!Number.isInteger(candidatePid) || candidatePid <= 1 || seen.has(candidatePid)) continue;
768
+ seen.add(candidatePid);
769
+ const details = await getProcessDetails(candidatePid);
770
+ if (details && isKnownLiveDeskClientAgentProcess(
771
+ details.processName,
772
+ details.commandLine,
773
+ details.executablePath
774
+ )) {
775
+ return true;
776
+ }
777
+ queue.push(...(childrenByParent.get(candidatePid) || []));
778
+ }
779
+ return false;
780
+ }
781
+
667
782
  async function waitForProcessExit(pid, timeoutMs = EXISTING_RUNTIME_STOP_TIMEOUT_MS) {
668
783
  const processId = Number(pid);
669
784
  const deadline = Date.now() + timeoutMs;
@@ -692,16 +807,26 @@ async function stopExistingRuntime(existing) {
692
807
  return null;
693
808
  }
694
809
  const commandLineConfirmed = isKnownLiveDeskLauncherProcess(details.processName, details.commandLine);
695
- const runtimeProof = commandLineConfirmed
810
+ const lockInstanceConfirmed = String(existing?.ownerToken || '').trim().length >= 16
811
+ && String(existing?.ownerInstanceMarker || '').trim()
812
+ === processInstanceMarker(details);
813
+ const legacyClientTreeConfirmed = !String(existing?.ownerToken || '').trim()
814
+ && String(existing?.role || '').trim().toLowerCase() === 'client'
815
+ && await hasKnownLiveDeskClientAgentDescendant(processId);
816
+ const runtimeProof = commandLineConfirmed || lockInstanceConfirmed || legacyClientTreeConfirmed
696
817
  ? null
697
818
  : await probeExistingRuntimeIdentity(existing);
698
- if (!commandLineConfirmed && !runtimeProof) {
819
+ if (!commandLineConfirmed && !lockInstanceConfirmed && !legacyClientTreeConfirmed && !runtimeProof) {
699
820
  throw new Error(`Existing runtime pid ${processId} is not a confirmed LiveDesk launcher; it was preserved.`);
700
821
  }
701
822
 
702
823
  const identityProof = commandLineConfirmed
703
824
  ? 'command-line'
704
- : `runtime-api:${runtimeProof.port}:${runtimeProof.appVersion || 'unknown'}`;
825
+ : lockInstanceConfirmed
826
+ ? 'lock-instance'
827
+ : legacyClientTreeConfirmed
828
+ ? 'legacy-lock-exact-client-descendant'
829
+ : `runtime-api:${runtimeProof.port}:${runtimeProof.appVersion || 'unknown'}`;
705
830
  console.log(`[LiveDesk] Stopping existing ${existing?.role || 'runtime'} launcher pid ${processId} before starting a fresh runtime (identity=${identityProof}).`);
706
831
  if (os.platform() === 'win32') {
707
832
  await runQuiet('taskkill.exe', ['/PID', String(processId), '/T', '/F']);
@@ -893,52 +1018,359 @@ async function stopUnixProcessesOnPorts(ports) {
893
1018
  assertPortCleanupReady(finalRecords, portStates);
894
1019
  }
895
1020
 
896
- async function stopStaleUnixClientAgents() {
897
- if (os.platform() === 'win32') return;
898
- const candidatePids = new Set();
899
- for (const pattern of ['livedesk-client-fast', 'livedesk-client-node.js', 'livedesk-sck-h264']) {
900
- const result = await runQuiet('pgrep', ['-f', pattern]);
901
- if (result.error?.code === 'ENOENT') return;
902
- for (const pid of parseLsofPids(result.stdout)) {
903
- if (pid > 1 && pid !== process.pid) candidatePids.add(pid);
1021
+ async function stopStaleClientAgents() {
1022
+ const classifyLiveDeskProcess = details => {
1023
+ if (isKnownLiveDeskClientAgentProcess(
1024
+ details?.processName,
1025
+ details?.commandLine,
1026
+ details?.executablePath
1027
+ )) return 'agent';
1028
+ if (isKnownLiveDeskCaptureHelperProcess(
1029
+ details?.processName,
1030
+ details?.commandLine,
1031
+ details?.executablePath
1032
+ )) return 'capture-helper';
1033
+ return '';
1034
+ };
1035
+
1036
+ const enumerateCandidatePids = async () => {
1037
+ if (os.platform() === 'win32') {
1038
+ const script = [
1039
+ '$ErrorActionPreference = "Stop";',
1040
+ '$candidates = @(Get-CimInstance Win32_Process | Where-Object {',
1041
+ ' $_.Name -match "^(?i:node(?:\\.exe)?|dotnet(?:\\.exe)?|livedesk-client-fast\\.exe)$" -and',
1042
+ ' [string]$_.CommandLine -match "(?i:livedesk-client-fast|livedesk-client-node\\.js)"',
1043
+ '});',
1044
+ '@($candidates | ForEach-Object { [string]$_.ProcessId }) -join [Environment]::NewLine'
1045
+ ].join(' ');
1046
+ const result = await runQuiet(
1047
+ 'powershell.exe',
1048
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1049
+ { timeout: 10000, killSignal: 'SIGKILL' }
1050
+ );
1051
+ if (!result.ok && !result.stdout) {
1052
+ throw new Error(
1053
+ `LiveDesk Client lifecycle audit could not enumerate Windows Agent processes: `
1054
+ + `${result.stderr || result.error?.message || 'CIM query failed'}`
1055
+ );
1056
+ }
1057
+ return parseLsofPids(result.stdout);
904
1058
  }
905
- }
906
1059
 
907
- const staleProcesses = [];
908
- for (const pid of candidatePids) {
909
- const details = await getProcessDetails(pid);
910
- if (details && isKnownLiveDeskClientAgentProcess(details.processName, details.commandLine)) {
911
- staleProcesses.push({ pid, type: 'agent' });
912
- } else if (details && isKnownLiveDeskCaptureHelperProcess(details.processName, details.commandLine)) {
913
- staleProcesses.push({ pid, type: 'capture-helper' });
1060
+ // One complete ps snapshot avoids treating pgrep execution errors as
1061
+ // "nothing is running". The strict identity classifier below decides
1062
+ // whether each textual candidate is really a packaged LiveDesk process.
1063
+ const result = await runQuiet(
1064
+ 'ps',
1065
+ ['-axo', 'pid=,args='],
1066
+ { timeout: 10000, killSignal: 'SIGKILL' }
1067
+ );
1068
+ if (!result.ok && !result.stdout) {
1069
+ throw new Error(
1070
+ `LiveDesk Client lifecycle audit could not enumerate Unix processes: `
1071
+ + `${result.stderr || result.error?.message || 'ps failed'}`
1072
+ );
914
1073
  }
915
- }
916
- if (staleProcesses.length === 0) {
917
- if (process.env.LIVEDESK_DEBUG === '1') {
918
- console.log('[LiveDesk] Client lifecycle audit: verified stale agents=0 capture-helpers=0.');
1074
+ const candidatePids = [];
1075
+ const patterns = [
1076
+ 'livedesk-client-fast',
1077
+ 'livedesk-client-node.js',
1078
+ 'livedesk-sck-h264',
1079
+ 'livedesk-sck-audio',
1080
+ 'livedesk-raw-capture-helper.mjs'
1081
+ ];
1082
+ for (const line of result.stdout.split(/\r?\n/)) {
1083
+ if (!patterns.some(pattern => line.includes(pattern))) continue;
1084
+ const match = line.match(/^\s*(\d+)\s+/);
1085
+ const pid = Number(match?.[1] || 0);
1086
+ if (pid > 1 && pid !== process.pid) candidatePids.push(pid);
919
1087
  }
920
- return;
921
- }
1088
+ return [...new Set(candidatePids)];
1089
+ };
1090
+
1091
+ const findStaleProcesses = async () => {
1092
+ const found = [];
1093
+ for (const pid of await enumerateCandidatePids()) {
1094
+ if (pid <= 1 || pid === process.pid) continue;
1095
+ const details = await getProcessDetails(pid);
1096
+ if (!details) {
1097
+ if (isPidAlive(pid)) {
1098
+ throw new Error(
1099
+ `LiveDesk Client lifecycle audit could not establish the identity of candidate pid=${pid}; `
1100
+ + 'startup was preserved instead of signaling an unknown process.'
1101
+ );
1102
+ }
1103
+ continue;
1104
+ }
1105
+ const type = classifyLiveDeskProcess(details);
1106
+ if (!type) continue;
1107
+ const instanceMarker = processInstanceMarker(details);
1108
+ if (!instanceMarker) {
1109
+ throw new Error(
1110
+ `LiveDesk Client lifecycle audit could not establish a start marker for verified ${type} pid=${pid}.`
1111
+ );
1112
+ }
1113
+ found.push({
1114
+ pid,
1115
+ type,
1116
+ instanceMarker,
1117
+ processGroupId: Number(details.processGroupId || 0),
1118
+ startOrder: String(details.startOrder || '')
1119
+ });
1120
+ }
1121
+ return found;
1122
+ };
1123
+
1124
+ const getMatchingDetails = async record => {
1125
+ const details = await getProcessDetails(record.pid);
1126
+ if (!details || processInstanceMarker(details) !== record.instanceMarker) return null;
1127
+ if (record.type === 'descendant') return details;
1128
+ return classifyLiveDeskProcess(details) === record.type ? details : null;
1129
+ };
1130
+
1131
+ const waitForExactRecordsExit = async (records, timeoutMs = 4000) => {
1132
+ const deadline = Date.now() + timeoutMs;
1133
+ let remaining = records;
1134
+ while (Date.now() < deadline) {
1135
+ const checks = await Promise.all(remaining.map(async record => (
1136
+ await getMatchingDetails(record) ? record : null
1137
+ )));
1138
+ remaining = checks.filter(Boolean);
1139
+ if (remaining.length === 0) return [];
1140
+ await waitMilliseconds(Math.min(100, Math.max(1, deadline - Date.now())));
1141
+ }
1142
+ const finalChecks = await Promise.all(remaining.map(async record => (
1143
+ await getMatchingDetails(record) ? record : null
1144
+ )));
1145
+ return finalChecks.filter(Boolean);
1146
+ };
1147
+
1148
+ const isUnixGroupAlive = processGroupId => {
1149
+ if (!Number.isInteger(processGroupId) || processGroupId <= 1) return false;
1150
+ try {
1151
+ process.kill(-processGroupId, 0);
1152
+ return true;
1153
+ } catch (error) {
1154
+ return error?.code === 'EPERM';
1155
+ }
1156
+ };
922
1157
 
923
- for (const { pid } of staleProcesses) {
924
- try { process.kill(pid, 'SIGTERM'); } catch (error) {
1158
+ const waitForUnixGroupExit = async (processGroupId, timeoutMs) => {
1159
+ const deadline = Date.now() + timeoutMs;
1160
+ while (Date.now() < deadline) {
1161
+ if (!isUnixGroupAlive(processGroupId)) return true;
1162
+ await waitMilliseconds(Math.min(50, Math.max(1, deadline - Date.now())));
1163
+ }
1164
+ return !isUnixGroupAlive(processGroupId);
1165
+ };
1166
+
1167
+ const terminateUnixAgentGroup = async record => {
1168
+ const details = await getMatchingDetails(record);
1169
+ if (!details) return;
1170
+ const processGroupId = Number(details.processGroupId || 0);
1171
+ if (processGroupId !== record.pid || record.processGroupId !== record.pid) {
1172
+ throw new Error(
1173
+ `verified stale Agent pid=${record.pid} is not its own dedicated process-group leader `
1174
+ + `(pgid=${processGroupId || 'unknown'}); it was preserved to avoid signaling unrelated terminal processes.`
1175
+ );
1176
+ }
1177
+ try {
1178
+ process.kill(-processGroupId, 'SIGTERM');
1179
+ } catch (error) {
925
1180
  if (error?.code !== 'ESRCH') throw error;
1181
+ return;
926
1182
  }
927
- }
928
- await waitMilliseconds(500);
929
- for (const { pid } of staleProcesses) {
1183
+ if (await waitForUnixGroupExit(processGroupId, 500)) return;
1184
+ try {
1185
+ process.kill(-processGroupId, 'SIGKILL');
1186
+ } catch (error) {
1187
+ if (error?.code !== 'ESRCH') throw error;
1188
+ }
1189
+ if (!await waitForUnixGroupExit(processGroupId, 4000)) {
1190
+ throw new Error(
1191
+ `verified stale Agent group pgid=${processGroupId} remained alive after SIGKILL; `
1192
+ + 'startup will not overlap its capture descendants.'
1193
+ );
1194
+ }
1195
+ };
1196
+
1197
+ const terminateExactUnixHelper = async record => {
1198
+ if (!await getMatchingDetails(record)) return;
1199
+ try {
1200
+ process.kill(record.pid, 'SIGTERM');
1201
+ } catch (error) {
1202
+ if (error?.code !== 'ESRCH') throw error;
1203
+ return;
1204
+ }
1205
+ if ((await waitForExactRecordsExit([record], 500)).length === 0) return;
1206
+ // The immutable start marker is rechecked immediately before escalation.
1207
+ if (!await getMatchingDetails(record)) return;
930
1208
  try {
931
- process.kill(pid, 0);
932
- process.kill(pid, 'SIGKILL');
1209
+ process.kill(record.pid, 'SIGKILL');
933
1210
  } catch (error) {
934
1211
  if (error?.code !== 'ESRCH') throw error;
935
1212
  }
1213
+ const remaining = await waitForExactRecordsExit([record], 4000);
1214
+ if (remaining.length > 0) {
1215
+ throw new Error(`verified stale capture helper pid=${record.pid} remained alive after SIGKILL`);
1216
+ }
1217
+ };
1218
+
1219
+ const enumerateWindowsParentPairs = async () => {
1220
+ const script = '$ErrorActionPreference = "Stop"; @(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId } }) | ConvertTo-Json -Compress';
1221
+ const result = await runQuiet(
1222
+ 'powershell.exe',
1223
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1224
+ { timeout: 10000, killSignal: 'SIGKILL' }
1225
+ );
1226
+ if (!result.ok || !result.stdout) {
1227
+ throw new Error(
1228
+ `LiveDesk Client lifecycle audit could not snapshot the Windows process tree: `
1229
+ + `${result.stderr || result.error?.message || 'CIM query failed'}`
1230
+ );
1231
+ }
1232
+ const parsed = JSON.parse(result.stdout);
1233
+ return (Array.isArray(parsed) ? parsed : [parsed]).map(item => ({
1234
+ pid: Number(item?.ProcessId || 0),
1235
+ parentPid: Number(item?.ParentProcessId || 0)
1236
+ }));
1237
+ };
1238
+
1239
+ const captureWindowsTreeRecords = async rootRecord => {
1240
+ const pairs = await enumerateWindowsParentPairs();
1241
+ const descendantsByParent = new Map();
1242
+ for (const pair of pairs) {
1243
+ if (!descendantsByParent.has(pair.parentPid)) descendantsByParent.set(pair.parentPid, []);
1244
+ descendantsByParent.get(pair.parentPid).push(pair.pid);
1245
+ }
1246
+ const processIds = [];
1247
+ const queue = [rootRecord.pid];
1248
+ const seen = new Set();
1249
+ while (queue.length > 0) {
1250
+ const pid = queue.shift();
1251
+ if (!Number.isInteger(pid) || pid <= 1 || seen.has(pid)) continue;
1252
+ seen.add(pid);
1253
+ processIds.push(pid);
1254
+ queue.push(...(descendantsByParent.get(pid) || []));
1255
+ }
1256
+ const records = [];
1257
+ for (const pid of processIds) {
1258
+ const details = await getProcessDetails(pid);
1259
+ if (!details) continue;
1260
+ const instanceMarker = processInstanceMarker(details);
1261
+ if (!instanceMarker) {
1262
+ throw new Error(`Windows process-tree verification could not establish a start marker for pid=${pid}.`);
1263
+ }
1264
+ if (pid !== rootRecord.pid && rootRecord.startOrder && details.startOrder) {
1265
+ const rootStart = BigInt(rootRecord.startOrder);
1266
+ const descendantStart = BigInt(details.startOrder);
1267
+ if (descendantStart < rootStart) {
1268
+ throw new Error(
1269
+ `Windows process-tree verification found pid=${pid} with reused parent pid=${rootRecord.pid}; `
1270
+ + 'the older process was preserved instead of passing an ambiguous tree to taskkill.'
1271
+ );
1272
+ }
1273
+ }
1274
+ records.push(pid === rootRecord.pid
1275
+ ? rootRecord
1276
+ : {
1277
+ pid,
1278
+ type: 'descendant',
1279
+ instanceMarker,
1280
+ startOrder: String(details.startOrder || '')
1281
+ });
1282
+ }
1283
+ if (!records.some(record => (
1284
+ record.pid === rootRecord.pid && record.instanceMarker === rootRecord.instanceMarker
1285
+ ))) {
1286
+ return [];
1287
+ }
1288
+ return records;
1289
+ };
1290
+
1291
+ const taskkillExactWindowsRecord = async (record, includeTree) => {
1292
+ const details = await getMatchingDetails(record);
1293
+ if (!details) return;
1294
+ const expectedStart = String(details.startMarker || '').replaceAll("'", "''");
1295
+ const treeArgument = includeTree ? ', "/T"' : '';
1296
+ const script = [
1297
+ '$ErrorActionPreference = "Stop";',
1298
+ `$targetPid = ${record.pid};`,
1299
+ `$expectedStart = '${expectedStart}';`,
1300
+ '$target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -ErrorAction SilentlyContinue | Select-Object -First 1;',
1301
+ 'if (-not $target) { exit 0 };',
1302
+ 'if ([string]$target.CreationDate -ne $expectedStart) { exit 3 };',
1303
+ `$arguments = @("/PID", [string]$targetPid${treeArgument}, "/F");`,
1304
+ '$taskkill = Start-Process -FilePath "taskkill.exe" -ArgumentList $arguments -NoNewWindow -Wait -PassThru;',
1305
+ 'exit $taskkill.ExitCode'
1306
+ ].join(' ');
1307
+ await runQuiet(
1308
+ 'powershell.exe',
1309
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1310
+ { timeout: 15000, killSignal: 'SIGKILL' }
1311
+ );
1312
+ };
1313
+
1314
+ const terminateWindowsAgentTree = async record => {
1315
+ if (!await getMatchingDetails(record)) return;
1316
+ const treeRecords = await captureWindowsTreeRecords(record);
1317
+ if (treeRecords.length === 0) return;
1318
+ await taskkillExactWindowsRecord(record, true);
1319
+ let remaining = await waitForExactRecordsExit(treeRecords, 4000);
1320
+ if (remaining.length > 0) {
1321
+ // These are immutable pre-taskkill descendants of the exact Agent tree,
1322
+ // not globally matched ffmpeg processes.
1323
+ for (const descendant of [...remaining].reverse()) {
1324
+ await taskkillExactWindowsRecord(descendant, true);
1325
+ }
1326
+ remaining = await waitForExactRecordsExit(remaining, 4000);
1327
+ }
1328
+ if (remaining.length > 0) {
1329
+ throw new Error(
1330
+ `verified stale Windows Agent tree pid=${record.pid} retained exact descendant(s): `
1331
+ + remaining.map(item => item.pid).join(', ')
1332
+ );
1333
+ }
1334
+ };
1335
+
1336
+ const stoppedProcesses = new Map();
1337
+ for (let auditRound = 1; auditRound <= 3; auditRound += 1) {
1338
+ const staleProcesses = await findStaleProcesses();
1339
+ if (staleProcesses.length === 0) break;
1340
+ for (const record of staleProcesses) {
1341
+ stoppedProcesses.set(`${record.type}:${record.instanceMarker}`, record);
1342
+ }
1343
+
1344
+ const agents = staleProcesses.filter(record => record.type === 'agent');
1345
+ const helpers = staleProcesses.filter(record => record.type === 'capture-helper');
1346
+ if (os.platform() === 'win32') {
1347
+ for (const record of agents) await terminateWindowsAgentTree(record);
1348
+ } else {
1349
+ for (const record of agents) await terminateUnixAgentGroup(record);
1350
+ for (const record of helpers) await terminateExactUnixHelper(record);
1351
+ }
1352
+ }
1353
+
1354
+ const remainingProcesses = await findStaleProcesses();
1355
+ if (remainingProcesses.length > 0) {
1356
+ throw new Error(
1357
+ 'LiveDesk Client startup was stopped because new stale capture processes appeared during cleanup: '
1358
+ + remainingProcesses.map(record => `${record.type} pid=${record.pid}`).join('; ')
1359
+ );
1360
+ }
1361
+
1362
+ const staleProcesses = [...stoppedProcesses.values()];
1363
+ if (staleProcesses.length === 0) {
1364
+ if (process.env.LIVEDESK_DEBUG === '1') {
1365
+ console.log('[LiveDesk] Client lifecycle audit: verified stale agents=0 capture-helpers=0.');
1366
+ }
1367
+ return;
936
1368
  }
937
1369
  const agentPids = staleProcesses.filter(item => item.type === 'agent').map(item => item.pid);
938
1370
  const helperPids = staleProcesses.filter(item => item.type === 'capture-helper').map(item => item.pid);
939
1371
  console.log(
940
1372
  `[LiveDesk] Client lifecycle audit: verified stale agents=${agentPids.length} `
941
- + `capture-helpers=${helperPids.length}; stopped pids=${staleProcesses.map(item => item.pid).join(', ')}.`
1373
+ + `capture-helpers=${helperPids.length}; confirmed stopped pids=${staleProcesses.map(item => item.pid).join(', ')}.`
942
1374
  );
943
1375
  }
944
1376
 
@@ -1774,6 +2206,8 @@ async function main() {
1774
2206
  if (topLevel.noSingleInstance || process.env.LIVEDESK_DISABLE_SINGLE_INSTANCE === '1') {
1775
2207
  console.warn('[LiveDesk] Single-instance lock disabled for this run.');
1776
2208
  }
2209
+ const launcherProcessDetails = await getProcessDetails(process.pid);
2210
+ const launcherInstanceMarker = processInstanceMarker(launcherProcessDetails);
1777
2211
  const reportExistingRuntime = existing => {
1778
2212
  const port = DEFAULT_MANAGER_HTTP_PORT;
1779
2213
  openBrowser(`http://127.0.0.1:${port}`);
@@ -1784,6 +2218,7 @@ async function main() {
1784
2218
  : acquireRuntimeLock({
1785
2219
  stateDir: MANAGER_STATE_DIR,
1786
2220
  role: resolvedRole.role,
2221
+ ownerInstanceMarker: launcherInstanceMarker,
1787
2222
  openExisting: () => undefined
1788
2223
  });
1789
2224
 
@@ -1794,6 +2229,7 @@ async function main() {
1794
2229
  lock = acquireRuntimeLock({
1795
2230
  stateDir: MANAGER_STATE_DIR,
1796
2231
  role: resolvedRole.role,
2232
+ ownerInstanceMarker: launcherInstanceMarker,
1797
2233
  openExisting: () => undefined
1798
2234
  });
1799
2235
  }
@@ -1805,6 +2241,7 @@ async function main() {
1805
2241
  lock = acquireRuntimeLock({
1806
2242
  stateDir: MANAGER_STATE_DIR,
1807
2243
  role: resolvedRole.role,
2244
+ ownerInstanceMarker: launcherInstanceMarker,
1808
2245
  openExisting: () => undefined
1809
2246
  });
1810
2247
  }
@@ -1821,7 +2258,13 @@ async function main() {
1821
2258
  DEFAULT_MANAGER_HTTP_PORT
1822
2259
  );
1823
2260
  await stopProcessesOnPorts([clientRuntimePort]);
1824
- await stopStaleUnixClientAgents();
2261
+ const explicitMultiInstance = topLevel.noSingleInstance
2262
+ || process.env.LIVEDESK_DISABLE_SINGLE_INSTANCE === '1';
2263
+ if (!explicitMultiInstance) {
2264
+ await stopStaleClientAgents();
2265
+ } else if (process.env.LIVEDESK_DEBUG === '1') {
2266
+ console.log('[LiveDesk] Client lifecycle audit skipped for explicit multi-instance mode.');
2267
+ }
1825
2268
  }
1826
2269
 
1827
2270
  if (command === 'client') {