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 +487 -44
- package/bootstrap/client-process-identity.js +54 -10
- package/bootstrap/runtime-lock.js +145 -33
- package/client/bin/livedesk-client.js +426 -125
- package/client/package.json +5 -5
- package/client/src/runtime/agent-process-lifecycle.js +600 -77
- package/hub/package.json +1 -1
- package/hub/src/remote-hub.js +284 -88
- package/hub/src/server.js +173 -28
- package/hub/src/transport/relay-hub-control.js +1004 -0
- package/hub/src/transport/udp-hub-transport.js +146 -61
- package/hub/src/transport/udp-rendezvous.js +2 -0
- package/package.json +6 -6
- package/web/dist/assets/index-5opXQY8X.js +25 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-D3VhnrP6.js +0 -25
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 {
|
|
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(
|
|
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
|
|
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
|
-
:
|
|
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
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
|
|
924
|
-
|
|
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
|
-
|
|
929
|
-
|
|
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,
|
|
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
|
-
|
|
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') {
|