livedesk 0.1.443 → 0.1.445

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,28 +671,52 @@ 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, 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', 'lstart='])
659
702
  ]);
660
703
  if (!nameResult.stdout && !commandResult.stdout) return null;
704
+ let executablePath = '';
705
+ if (os.platform() === 'linux') {
706
+ try {
707
+ executablePath = readlinkSync(`/proc/${processId}/exe`);
708
+ } catch {
709
+ executablePath = '';
710
+ }
711
+ }
661
712
  return {
713
+ processId,
714
+ parentProcessId: 0,
662
715
  processName: nameResult.stdout,
663
- commandLine: commandResult.stdout
716
+ commandLine: commandResult.stdout,
717
+ executablePath,
718
+ processGroupId: Number(groupResult.stdout || 0),
719
+ startMarker: readLinuxProcessStartMarker(processId) || startResult.stdout
664
720
  };
665
721
  }
666
722
 
@@ -893,52 +949,353 @@ async function stopUnixProcessesOnPorts(ports) {
893
949
  assertPortCleanupReady(finalRecords, portStates);
894
950
  }
895
951
 
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);
952
+ async function stopStaleClientAgents() {
953
+ const classifyLiveDeskProcess = details => {
954
+ if (isKnownLiveDeskClientAgentProcess(
955
+ details?.processName,
956
+ details?.commandLine,
957
+ details?.executablePath
958
+ )) return 'agent';
959
+ if (isKnownLiveDeskCaptureHelperProcess(
960
+ details?.processName,
961
+ details?.commandLine,
962
+ details?.executablePath
963
+ )) return 'capture-helper';
964
+ return '';
965
+ };
966
+
967
+ const enumerateCandidatePids = async () => {
968
+ if (os.platform() === 'win32') {
969
+ const script = [
970
+ '$ErrorActionPreference = "Stop";',
971
+ '$candidates = @(Get-CimInstance Win32_Process | Where-Object {',
972
+ ' $_.Name -match "^(?i:node(?:\\.exe)?|dotnet(?:\\.exe)?|livedesk-client-fast\\.exe)$" -and',
973
+ ' [string]$_.CommandLine -match "(?i:livedesk-client-fast|livedesk-client-node\\.js)"',
974
+ '});',
975
+ '@($candidates | ForEach-Object { [string]$_.ProcessId }) -join [Environment]::NewLine'
976
+ ].join(' ');
977
+ const result = await runQuiet(
978
+ 'powershell.exe',
979
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
980
+ { timeout: 10000, killSignal: 'SIGKILL' }
981
+ );
982
+ if (!result.ok && !result.stdout) {
983
+ throw new Error(
984
+ `LiveDesk Client lifecycle audit could not enumerate Windows Agent processes: `
985
+ + `${result.stderr || result.error?.message || 'CIM query failed'}`
986
+ );
987
+ }
988
+ return parseLsofPids(result.stdout);
904
989
  }
905
- }
906
990
 
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' });
991
+ // One complete ps snapshot avoids treating pgrep execution errors as
992
+ // "nothing is running". The strict identity classifier below decides
993
+ // whether each textual candidate is really a packaged LiveDesk process.
994
+ const result = await runQuiet(
995
+ 'ps',
996
+ ['-axo', 'pid=,args='],
997
+ { timeout: 10000, killSignal: 'SIGKILL' }
998
+ );
999
+ if (!result.ok && !result.stdout) {
1000
+ throw new Error(
1001
+ `LiveDesk Client lifecycle audit could not enumerate Unix processes: `
1002
+ + `${result.stderr || result.error?.message || 'ps failed'}`
1003
+ );
914
1004
  }
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.');
1005
+ const candidatePids = [];
1006
+ const patterns = ['livedesk-client-fast', 'livedesk-client-node.js', 'livedesk-sck-h264'];
1007
+ for (const line of result.stdout.split(/\r?\n/)) {
1008
+ if (!patterns.some(pattern => line.includes(pattern))) continue;
1009
+ const match = line.match(/^\s*(\d+)\s+/);
1010
+ const pid = Number(match?.[1] || 0);
1011
+ if (pid > 1 && pid !== process.pid) candidatePids.push(pid);
919
1012
  }
920
- return;
921
- }
1013
+ return [...new Set(candidatePids)];
1014
+ };
922
1015
 
923
- for (const { pid } of staleProcesses) {
924
- try { process.kill(pid, 'SIGTERM'); } catch (error) {
1016
+ const findStaleProcesses = async () => {
1017
+ const found = [];
1018
+ for (const pid of await enumerateCandidatePids()) {
1019
+ if (pid <= 1 || pid === process.pid) continue;
1020
+ const details = await getProcessDetails(pid);
1021
+ if (!details) {
1022
+ if (isPidAlive(pid)) {
1023
+ throw new Error(
1024
+ `LiveDesk Client lifecycle audit could not establish the identity of candidate pid=${pid}; `
1025
+ + 'startup was preserved instead of signaling an unknown process.'
1026
+ );
1027
+ }
1028
+ continue;
1029
+ }
1030
+ const type = classifyLiveDeskProcess(details);
1031
+ if (!type) continue;
1032
+ const instanceMarker = processInstanceMarker(details);
1033
+ if (!instanceMarker) {
1034
+ throw new Error(
1035
+ `LiveDesk Client lifecycle audit could not establish a start marker for verified ${type} pid=${pid}.`
1036
+ );
1037
+ }
1038
+ found.push({
1039
+ pid,
1040
+ type,
1041
+ instanceMarker,
1042
+ processGroupId: Number(details.processGroupId || 0),
1043
+ startOrder: String(details.startOrder || '')
1044
+ });
1045
+ }
1046
+ return found;
1047
+ };
1048
+
1049
+ const getMatchingDetails = async record => {
1050
+ const details = await getProcessDetails(record.pid);
1051
+ if (!details || processInstanceMarker(details) !== record.instanceMarker) return null;
1052
+ if (record.type === 'descendant') return details;
1053
+ return classifyLiveDeskProcess(details) === record.type ? details : null;
1054
+ };
1055
+
1056
+ const waitForExactRecordsExit = async (records, timeoutMs = 4000) => {
1057
+ const deadline = Date.now() + timeoutMs;
1058
+ let remaining = records;
1059
+ while (Date.now() < deadline) {
1060
+ const checks = await Promise.all(remaining.map(async record => (
1061
+ await getMatchingDetails(record) ? record : null
1062
+ )));
1063
+ remaining = checks.filter(Boolean);
1064
+ if (remaining.length === 0) return [];
1065
+ await waitMilliseconds(Math.min(100, Math.max(1, deadline - Date.now())));
1066
+ }
1067
+ const finalChecks = await Promise.all(remaining.map(async record => (
1068
+ await getMatchingDetails(record) ? record : null
1069
+ )));
1070
+ return finalChecks.filter(Boolean);
1071
+ };
1072
+
1073
+ const isUnixGroupAlive = processGroupId => {
1074
+ if (!Number.isInteger(processGroupId) || processGroupId <= 1) return false;
1075
+ try {
1076
+ process.kill(-processGroupId, 0);
1077
+ return true;
1078
+ } catch (error) {
1079
+ return error?.code === 'EPERM';
1080
+ }
1081
+ };
1082
+
1083
+ const waitForUnixGroupExit = async (processGroupId, timeoutMs) => {
1084
+ const deadline = Date.now() + timeoutMs;
1085
+ while (Date.now() < deadline) {
1086
+ if (!isUnixGroupAlive(processGroupId)) return true;
1087
+ await waitMilliseconds(Math.min(50, Math.max(1, deadline - Date.now())));
1088
+ }
1089
+ return !isUnixGroupAlive(processGroupId);
1090
+ };
1091
+
1092
+ const terminateUnixAgentGroup = async record => {
1093
+ const details = await getMatchingDetails(record);
1094
+ if (!details) return;
1095
+ const processGroupId = Number(details.processGroupId || 0);
1096
+ if (processGroupId !== record.pid || record.processGroupId !== record.pid) {
1097
+ throw new Error(
1098
+ `verified stale Agent pid=${record.pid} is not its own dedicated process-group leader `
1099
+ + `(pgid=${processGroupId || 'unknown'}); it was preserved to avoid signaling unrelated terminal processes.`
1100
+ );
1101
+ }
1102
+ try {
1103
+ process.kill(-processGroupId, 'SIGTERM');
1104
+ } catch (error) {
925
1105
  if (error?.code !== 'ESRCH') throw error;
1106
+ return;
926
1107
  }
927
- }
928
- await waitMilliseconds(500);
929
- for (const { pid } of staleProcesses) {
1108
+ if (await waitForUnixGroupExit(processGroupId, 500)) return;
930
1109
  try {
931
- process.kill(pid, 0);
932
- process.kill(pid, 'SIGKILL');
1110
+ process.kill(-processGroupId, 'SIGKILL');
933
1111
  } catch (error) {
934
1112
  if (error?.code !== 'ESRCH') throw error;
935
1113
  }
1114
+ if (!await waitForUnixGroupExit(processGroupId, 4000)) {
1115
+ throw new Error(
1116
+ `verified stale Agent group pgid=${processGroupId} remained alive after SIGKILL; `
1117
+ + 'startup will not overlap its capture descendants.'
1118
+ );
1119
+ }
1120
+ };
1121
+
1122
+ const terminateExactUnixHelper = async record => {
1123
+ if (!await getMatchingDetails(record)) return;
1124
+ try {
1125
+ process.kill(record.pid, 'SIGTERM');
1126
+ } catch (error) {
1127
+ if (error?.code !== 'ESRCH') throw error;
1128
+ return;
1129
+ }
1130
+ if ((await waitForExactRecordsExit([record], 500)).length === 0) return;
1131
+ // The immutable start marker is rechecked immediately before escalation.
1132
+ if (!await getMatchingDetails(record)) return;
1133
+ try {
1134
+ process.kill(record.pid, 'SIGKILL');
1135
+ } catch (error) {
1136
+ if (error?.code !== 'ESRCH') throw error;
1137
+ }
1138
+ const remaining = await waitForExactRecordsExit([record], 4000);
1139
+ if (remaining.length > 0) {
1140
+ throw new Error(`verified stale capture helper pid=${record.pid} remained alive after SIGKILL`);
1141
+ }
1142
+ };
1143
+
1144
+ const enumerateWindowsParentPairs = async () => {
1145
+ const script = '$ErrorActionPreference = "Stop"; @(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId } }) | ConvertTo-Json -Compress';
1146
+ const result = await runQuiet(
1147
+ 'powershell.exe',
1148
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1149
+ { timeout: 10000, killSignal: 'SIGKILL' }
1150
+ );
1151
+ if (!result.ok || !result.stdout) {
1152
+ throw new Error(
1153
+ `LiveDesk Client lifecycle audit could not snapshot the Windows process tree: `
1154
+ + `${result.stderr || result.error?.message || 'CIM query failed'}`
1155
+ );
1156
+ }
1157
+ const parsed = JSON.parse(result.stdout);
1158
+ return (Array.isArray(parsed) ? parsed : [parsed]).map(item => ({
1159
+ pid: Number(item?.ProcessId || 0),
1160
+ parentPid: Number(item?.ParentProcessId || 0)
1161
+ }));
1162
+ };
1163
+
1164
+ const captureWindowsTreeRecords = async rootRecord => {
1165
+ const pairs = await enumerateWindowsParentPairs();
1166
+ const descendantsByParent = new Map();
1167
+ for (const pair of pairs) {
1168
+ if (!descendantsByParent.has(pair.parentPid)) descendantsByParent.set(pair.parentPid, []);
1169
+ descendantsByParent.get(pair.parentPid).push(pair.pid);
1170
+ }
1171
+ const processIds = [];
1172
+ const queue = [rootRecord.pid];
1173
+ const seen = new Set();
1174
+ while (queue.length > 0) {
1175
+ const pid = queue.shift();
1176
+ if (!Number.isInteger(pid) || pid <= 1 || seen.has(pid)) continue;
1177
+ seen.add(pid);
1178
+ processIds.push(pid);
1179
+ queue.push(...(descendantsByParent.get(pid) || []));
1180
+ }
1181
+ const records = [];
1182
+ for (const pid of processIds) {
1183
+ const details = await getProcessDetails(pid);
1184
+ if (!details) continue;
1185
+ const instanceMarker = processInstanceMarker(details);
1186
+ if (!instanceMarker) {
1187
+ throw new Error(`Windows process-tree verification could not establish a start marker for pid=${pid}.`);
1188
+ }
1189
+ if (pid !== rootRecord.pid && rootRecord.startOrder && details.startOrder) {
1190
+ const rootStart = BigInt(rootRecord.startOrder);
1191
+ const descendantStart = BigInt(details.startOrder);
1192
+ if (descendantStart < rootStart) {
1193
+ throw new Error(
1194
+ `Windows process-tree verification found pid=${pid} with reused parent pid=${rootRecord.pid}; `
1195
+ + 'the older process was preserved instead of passing an ambiguous tree to taskkill.'
1196
+ );
1197
+ }
1198
+ }
1199
+ records.push(pid === rootRecord.pid
1200
+ ? rootRecord
1201
+ : {
1202
+ pid,
1203
+ type: 'descendant',
1204
+ instanceMarker,
1205
+ startOrder: String(details.startOrder || '')
1206
+ });
1207
+ }
1208
+ if (!records.some(record => (
1209
+ record.pid === rootRecord.pid && record.instanceMarker === rootRecord.instanceMarker
1210
+ ))) {
1211
+ return [];
1212
+ }
1213
+ return records;
1214
+ };
1215
+
1216
+ const taskkillExactWindowsRecord = async (record, includeTree) => {
1217
+ const details = await getMatchingDetails(record);
1218
+ if (!details) return;
1219
+ const expectedStart = String(details.startMarker || '').replaceAll("'", "''");
1220
+ const treeArgument = includeTree ? ', "/T"' : '';
1221
+ const script = [
1222
+ '$ErrorActionPreference = "Stop";',
1223
+ `$targetPid = ${record.pid};`,
1224
+ `$expectedStart = '${expectedStart}';`,
1225
+ '$target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -ErrorAction SilentlyContinue | Select-Object -First 1;',
1226
+ 'if (-not $target) { exit 0 };',
1227
+ 'if ([string]$target.CreationDate -ne $expectedStart) { exit 3 };',
1228
+ `$arguments = @("/PID", [string]$targetPid${treeArgument}, "/F");`,
1229
+ '$taskkill = Start-Process -FilePath "taskkill.exe" -ArgumentList $arguments -NoNewWindow -Wait -PassThru;',
1230
+ 'exit $taskkill.ExitCode'
1231
+ ].join(' ');
1232
+ await runQuiet(
1233
+ 'powershell.exe',
1234
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1235
+ { timeout: 15000, killSignal: 'SIGKILL' }
1236
+ );
1237
+ };
1238
+
1239
+ const terminateWindowsAgentTree = async record => {
1240
+ if (!await getMatchingDetails(record)) return;
1241
+ const treeRecords = await captureWindowsTreeRecords(record);
1242
+ if (treeRecords.length === 0) return;
1243
+ await taskkillExactWindowsRecord(record, true);
1244
+ let remaining = await waitForExactRecordsExit(treeRecords, 4000);
1245
+ if (remaining.length > 0) {
1246
+ // These are immutable pre-taskkill descendants of the exact Agent tree,
1247
+ // not globally matched ffmpeg processes.
1248
+ for (const descendant of [...remaining].reverse()) {
1249
+ await taskkillExactWindowsRecord(descendant, true);
1250
+ }
1251
+ remaining = await waitForExactRecordsExit(remaining, 4000);
1252
+ }
1253
+ if (remaining.length > 0) {
1254
+ throw new Error(
1255
+ `verified stale Windows Agent tree pid=${record.pid} retained exact descendant(s): `
1256
+ + remaining.map(item => item.pid).join(', ')
1257
+ );
1258
+ }
1259
+ };
1260
+
1261
+ const stoppedProcesses = new Map();
1262
+ for (let auditRound = 1; auditRound <= 3; auditRound += 1) {
1263
+ const staleProcesses = await findStaleProcesses();
1264
+ if (staleProcesses.length === 0) break;
1265
+ for (const record of staleProcesses) {
1266
+ stoppedProcesses.set(`${record.type}:${record.instanceMarker}`, record);
1267
+ }
1268
+
1269
+ const agents = staleProcesses.filter(record => record.type === 'agent');
1270
+ const helpers = staleProcesses.filter(record => record.type === 'capture-helper');
1271
+ if (os.platform() === 'win32') {
1272
+ for (const record of agents) await terminateWindowsAgentTree(record);
1273
+ } else {
1274
+ for (const record of agents) await terminateUnixAgentGroup(record);
1275
+ for (const record of helpers) await terminateExactUnixHelper(record);
1276
+ }
1277
+ }
1278
+
1279
+ const remainingProcesses = await findStaleProcesses();
1280
+ if (remainingProcesses.length > 0) {
1281
+ throw new Error(
1282
+ 'LiveDesk Client startup was stopped because new stale capture processes appeared during cleanup: '
1283
+ + remainingProcesses.map(record => `${record.type} pid=${record.pid}`).join('; ')
1284
+ );
1285
+ }
1286
+
1287
+ const staleProcesses = [...stoppedProcesses.values()];
1288
+ if (staleProcesses.length === 0) {
1289
+ if (process.env.LIVEDESK_DEBUG === '1') {
1290
+ console.log('[LiveDesk] Client lifecycle audit: verified stale agents=0 capture-helpers=0.');
1291
+ }
1292
+ return;
936
1293
  }
937
1294
  const agentPids = staleProcesses.filter(item => item.type === 'agent').map(item => item.pid);
938
1295
  const helperPids = staleProcesses.filter(item => item.type === 'capture-helper').map(item => item.pid);
939
1296
  console.log(
940
1297
  `[LiveDesk] Client lifecycle audit: verified stale agents=${agentPids.length} `
941
- + `capture-helpers=${helperPids.length}; stopped pids=${staleProcesses.map(item => item.pid).join(', ')}.`
1298
+ + `capture-helpers=${helperPids.length}; confirmed stopped pids=${staleProcesses.map(item => item.pid).join(', ')}.`
942
1299
  );
943
1300
  }
944
1301
 
@@ -1821,7 +2178,13 @@ async function main() {
1821
2178
  DEFAULT_MANAGER_HTTP_PORT
1822
2179
  );
1823
2180
  await stopProcessesOnPorts([clientRuntimePort]);
1824
- await stopStaleUnixClientAgents();
2181
+ const explicitMultiInstance = topLevel.noSingleInstance
2182
+ || process.env.LIVEDESK_DISABLE_SINGLE_INSTANCE === '1';
2183
+ if (!explicitMultiInstance) {
2184
+ await stopStaleClientAgents();
2185
+ } else if (process.env.LIVEDESK_DEBUG === '1') {
2186
+ console.log('[LiveDesk] Client lifecycle audit skipped for explicit multi-instance mode.');
2187
+ }
1825
2188
  }
1826
2189
 
1827
2190
  if (command === 'client') {
@@ -1,30 +1,60 @@
1
- export function isKnownLiveDeskClientAgentProcess(processName, commandLine) {
1
+ function firstCommandArgument(commandLine) {
2
+ const value = String(commandLine || '').trim();
3
+ if (!value) return '';
4
+ const quote = value[0] === '"' || value[0] === "'" ? value[0] : '';
5
+ if (!quote) return value.split(/\s+/, 1)[0] || '';
6
+ const end = value.indexOf(quote, 1);
7
+ return end > 0 ? value.slice(1, end) : '';
8
+ }
9
+
10
+ function normalizeExecutablePath(value) {
11
+ return String(value || '').trim().replaceAll('\\', '/').replace(/\s+\(deleted\)$/i, '');
12
+ }
13
+
14
+ const BUNDLED_FAST_ROOT = '(?:node_modules/(?:livedesk/client|@livedesk/client)|packages/(?:livedesk/client|client))/fast/[^/\\s]+';
15
+ const OPTIONAL_FAST_ROOT = 'node_modules/@livedesk/fast-(?:linux-x64|osx-arm64|osx-x64|win-x64)/fast';
16
+ const FAST_ROOT = `(?:${BUNDLED_FAST_ROOT}|${OPTIONAL_FAST_ROOT})`;
17
+ const KNOWN_FAST_EXECUTABLE_PATH = new RegExp(`${FAST_ROOT}/livedesk-client-fast(?:\\.exe)?$`, 'i');
18
+
19
+ export function isKnownLiveDeskClientAgentProcess(processName, commandLine, executablePath = '') {
2
20
  const normalizedName = String(processName || '').trim().replaceAll('\\', '/').split('/').pop() || '';
3
21
  const normalizedCommandLine = String(commandLine || '').replaceAll('\\', '/');
4
22
  const isNode = /^(?:node|node\.exe)$/i.test(normalizedName);
5
23
  const isDotnet = /^(?:dotnet|dotnet\.exe)$/i.test(normalizedName);
6
24
  const isFast = /^livedesk-client-fast(?:\.exe)?$/i.test(normalizedName);
25
+ // Linux TASK_COMM_LEN exposes a 15-character `comm`, so the native apphost
26
+ // appears as `livedesk-client`. Accept that truncated name only when an
27
+ // independently resolved executable path, or argv[0], proves the canonical
28
+ // packaged LiveDesk Fast binary. A matching path elsewhere in argv is not
29
+ // sufficient.
30
+ const isTruncatedLinuxFast = /^livedesk-client$/i.test(normalizedName);
7
31
  const isKnownNodeAgent = /(?:node_modules\/(?:livedesk\/client|@livedesk\/client)|packages\/(?:livedesk\/client|client))\/bin\/livedesk-client-node\.js(?:\s|["']|$)/i.test(normalizedCommandLine);
8
- const bundledFastRoot = '(?:node_modules/(?:livedesk/client|@livedesk/client)|packages/(?:livedesk/client|client))/fast/[^/\\s]+';
9
- const optionalFastRoot = 'node_modules/@livedesk/fast-(?:linux-x64|osx-arm64|osx-x64|win-x64)/fast';
10
- const fastRoot = `(?:${bundledFastRoot}|${optionalFastRoot})`;
11
32
  const isKnownFastExecutable = new RegExp(
12
- `${fastRoot}/livedesk-client-fast(?:\\.exe)?(?:\\s|["']|$)`,
33
+ `${FAST_ROOT}/livedesk-client-fast(?:\\.exe)?(?:\\s|["']|$)`,
13
34
  'i'
14
35
  ).test(normalizedCommandLine);
15
36
  const isKnownFastDll = new RegExp(
16
- `${fastRoot}/livedesk-client-fast\\.dll(?:\\s|["']|$)`,
37
+ `${FAST_ROOT}/livedesk-client-fast\\.dll(?:\\s|["']|$)`,
17
38
  'i'
18
39
  ).test(normalizedCommandLine);
40
+ const resolvedExecutable = normalizeExecutablePath(executablePath);
41
+ const argvExecutable = normalizeExecutablePath(firstCommandArgument(normalizedCommandLine));
42
+ const hasStrictFastExecutableProof = KNOWN_FAST_EXECUTABLE_PATH.test(resolvedExecutable)
43
+ || KNOWN_FAST_EXECUTABLE_PATH.test(argvExecutable);
19
44
  return (isNode && isKnownNodeAgent)
20
45
  || (isFast && isKnownFastExecutable)
46
+ || (isTruncatedLinuxFast && hasStrictFastExecutableProof)
21
47
  || (isDotnet && isKnownFastDll);
22
48
  }
23
49
 
24
- export function isKnownLiveDeskCaptureHelperProcess(processName, commandLine) {
50
+ export function isKnownLiveDeskCaptureHelperProcess(processName, commandLine, executablePath = '') {
25
51
  const normalizedName = String(processName || '').trim().replaceAll('\\', '/').split('/').pop() || '';
26
52
  const normalizedCommandLine = String(commandLine || '').replaceAll('\\', '/');
27
- return /^livedesk-sck-h264$/i.test(normalizedName)
28
- && /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264(?:\s|["']|$)/i
29
- .test(normalizedCommandLine);
53
+ const helperPathPattern = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264$/i;
54
+ const knownCommand = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264(?:\s|["']|$)/i
55
+ .test(normalizedCommandLine);
56
+ const strictExecutableProof = helperPathPattern.test(normalizeExecutablePath(executablePath))
57
+ || helperPathPattern.test(normalizeExecutablePath(firstCommandArgument(normalizedCommandLine)));
58
+ return (/^livedesk-sck-h264$/i.test(normalizedName) && knownCommand)
59
+ || (/^livedesk-sck-h2$/i.test(normalizedName) && strictExecutableProof);
30
60
  }