livedesk 0.1.445 → 0.1.447
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 +974 -304
- package/bootstrap/client-process-identity.js +20 -6
- package/bootstrap/legacy-client-update.js +22 -20
- package/bootstrap/runtime-lock.js +362 -45
- package/bootstrap/runtime-shutdown.js +414 -0
- package/client/bin/livedesk-client-node.js +17 -4
- package/client/bin/livedesk-client.js +365 -104
- package/client/package.json +5 -5
- package/client/src/runtime/agent-process-lifecycle.js +194 -58
- package/client/src/runtime/windows-owned-process-manifest.js +235 -0
- package/electron/main.mjs +55 -38
- 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 +1045 -0
- package/hub/src/transport/udp-rendezvous.js +2 -0
- package/package.json +6 -6
- package/runtime-core/src/role-transition-supervisor.js +20 -18
- package/runtime-core/src/windows-owned-process-tree.js +432 -0
- 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
|
@@ -18,9 +18,13 @@ import {
|
|
|
18
18
|
} from 'node:fs';
|
|
19
19
|
import { randomBytes } from 'node:crypto';
|
|
20
20
|
import os from 'node:os';
|
|
21
|
-
import { resolveDeviceRole, writeRoleCache } from '../bootstrap/device-role.js';
|
|
22
|
-
import { acquireRuntimeLock } from '../bootstrap/runtime-lock.js';
|
|
23
|
-
import {
|
|
21
|
+
import { resolveDeviceRole, writeRoleCache } from '../bootstrap/device-role.js';
|
|
22
|
+
import { acquireRuntimeLock, getRuntimeLockPath } from '../bootstrap/runtime-lock.js';
|
|
23
|
+
import {
|
|
24
|
+
requestCooperativeRuntimeShutdown,
|
|
25
|
+
startRuntimeShutdownWatcher
|
|
26
|
+
} from '../bootstrap/runtime-shutdown.js';
|
|
27
|
+
import { migrateLegacyClientState } from '../bootstrap/state-migration.js';
|
|
24
28
|
import { createRoleBootstrapServer } from '../bootstrap/role-bootstrap-server.js';
|
|
25
29
|
import { parseLsofPids } from '../bootstrap/port-inspection.js';
|
|
26
30
|
import {
|
|
@@ -29,6 +33,7 @@ import {
|
|
|
29
33
|
} from '../bootstrap/client-process-identity.js';
|
|
30
34
|
import { runLegacyClientUpdateSupervisorCli } from '../bootstrap/legacy-client-update.js';
|
|
31
35
|
import { transferSavedClientSessionToHub } from '../bootstrap/hub-auth-handoff.js';
|
|
36
|
+
import { readWindowsOwnedProcessManifest } from '../client/src/runtime/windows-owned-process-manifest.js';
|
|
32
37
|
|
|
33
38
|
const require = createRequire(import.meta.url);
|
|
34
39
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -694,10 +699,11 @@ async function getProcessDetails(pid) {
|
|
|
694
699
|
}
|
|
695
700
|
}
|
|
696
701
|
|
|
697
|
-
const [nameResult, commandResult, groupResult, startResult] = await Promise.all([
|
|
702
|
+
const [nameResult, commandResult, groupResult, parentResult, startResult] = await Promise.all([
|
|
698
703
|
runQuiet('ps', ['-p', String(processId), '-o', 'comm=']),
|
|
699
704
|
runQuiet('ps', ['-p', String(processId), '-o', 'args=']),
|
|
700
705
|
runQuiet('ps', ['-p', String(processId), '-o', 'pgid=']),
|
|
706
|
+
runQuiet('ps', ['-p', String(processId), '-o', 'ppid=']),
|
|
701
707
|
runQuiet('ps', ['-p', String(processId), '-o', 'lstart='])
|
|
702
708
|
]);
|
|
703
709
|
if (!nameResult.stdout && !commandResult.stdout) return null;
|
|
@@ -709,17 +715,77 @@ async function getProcessDetails(pid) {
|
|
|
709
715
|
executablePath = '';
|
|
710
716
|
}
|
|
711
717
|
}
|
|
718
|
+
const nativeStartOrder = readLinuxProcessStartMarker(processId) || startResult.stdout;
|
|
712
719
|
return {
|
|
713
720
|
processId,
|
|
714
|
-
parentProcessId: 0,
|
|
721
|
+
parentProcessId: Number(parentResult.stdout || 0),
|
|
715
722
|
processName: nameResult.stdout,
|
|
716
723
|
commandLine: commandResult.stdout,
|
|
717
724
|
executablePath,
|
|
718
725
|
processGroupId: Number(groupResult.stdout || 0),
|
|
719
|
-
startMarker:
|
|
726
|
+
startMarker: nativeStartOrder,
|
|
727
|
+
startOrder: nativeStartOrder
|
|
720
728
|
};
|
|
721
729
|
}
|
|
722
730
|
|
|
731
|
+
async function hasKnownLiveDeskClientAgentDescendant(rootPid) {
|
|
732
|
+
const processId = Number(rootPid);
|
|
733
|
+
if (!Number.isInteger(processId) || processId <= 1) return false;
|
|
734
|
+
let pairs = [];
|
|
735
|
+
if (os.platform() === 'win32') {
|
|
736
|
+
const script = '$ErrorActionPreference = "Stop"; @(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId } }) | ConvertTo-Json -Compress';
|
|
737
|
+
const result = await runQuiet(
|
|
738
|
+
'powershell.exe',
|
|
739
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
740
|
+
{ timeout: 10000, killSignal: 'SIGKILL' }
|
|
741
|
+
);
|
|
742
|
+
if (!result.ok || !result.stdout) return false;
|
|
743
|
+
try {
|
|
744
|
+
const parsed = JSON.parse(result.stdout);
|
|
745
|
+
pairs = (Array.isArray(parsed) ? parsed : [parsed]).map(item => ({
|
|
746
|
+
pid: Number(item?.ProcessId || 0),
|
|
747
|
+
parentPid: Number(item?.ParentProcessId || 0)
|
|
748
|
+
}));
|
|
749
|
+
} catch {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
} else {
|
|
753
|
+
const result = await runQuiet(
|
|
754
|
+
'ps',
|
|
755
|
+
['-axo', 'pid=,ppid='],
|
|
756
|
+
{ timeout: 10000, killSignal: 'SIGKILL' }
|
|
757
|
+
);
|
|
758
|
+
if (!result.ok || !result.stdout) return false;
|
|
759
|
+
pairs = result.stdout.split(/\r?\n/).flatMap(line => {
|
|
760
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s*$/);
|
|
761
|
+
return match ? [{ pid: Number(match[1]), parentPid: Number(match[2]) }] : [];
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const childrenByParent = new Map();
|
|
766
|
+
for (const pair of pairs) {
|
|
767
|
+
if (!childrenByParent.has(pair.parentPid)) childrenByParent.set(pair.parentPid, []);
|
|
768
|
+
childrenByParent.get(pair.parentPid).push(pair.pid);
|
|
769
|
+
}
|
|
770
|
+
const queue = [...(childrenByParent.get(processId) || [])];
|
|
771
|
+
const seen = new Set();
|
|
772
|
+
while (queue.length > 0) {
|
|
773
|
+
const candidatePid = queue.shift();
|
|
774
|
+
if (!Number.isInteger(candidatePid) || candidatePid <= 1 || seen.has(candidatePid)) continue;
|
|
775
|
+
seen.add(candidatePid);
|
|
776
|
+
const details = await getProcessDetails(candidatePid);
|
|
777
|
+
if (details && isKnownLiveDeskClientAgentProcess(
|
|
778
|
+
details.processName,
|
|
779
|
+
details.commandLine,
|
|
780
|
+
details.executablePath
|
|
781
|
+
)) {
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
queue.push(...(childrenByParent.get(candidatePid) || []));
|
|
785
|
+
}
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
|
|
723
789
|
async function waitForProcessExit(pid, timeoutMs = EXISTING_RUNTIME_STOP_TIMEOUT_MS) {
|
|
724
790
|
const processId = Number(pid);
|
|
725
791
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -737,67 +803,490 @@ async function waitForProcessExit(pid, timeoutMs = EXISTING_RUNTIME_STOP_TIMEOUT
|
|
|
737
803
|
throw new Error(`Existing LiveDesk runtime ${processId} did not exit before the restart deadline.`);
|
|
738
804
|
}
|
|
739
805
|
|
|
806
|
+
function normalizeExactWindowsProcessRecord(value, depth = 0) {
|
|
807
|
+
const pid = Number(value?.pid ?? value?.processId ?? value?.ProcessId ?? 0);
|
|
808
|
+
const parentPid = Number(
|
|
809
|
+
value?.parentPid ?? value?.parentProcessId ?? value?.ParentProcessId ?? 0
|
|
810
|
+
);
|
|
811
|
+
const startOrder = String(
|
|
812
|
+
value?.startOrder ?? value?.CreationUtcTicks ?? ''
|
|
813
|
+
).trim();
|
|
814
|
+
if (!Number.isInteger(pid) || pid <= 1 || !/^\d+$/.test(startOrder)) return null;
|
|
815
|
+
return {
|
|
816
|
+
pid,
|
|
817
|
+
parentPid: Number.isInteger(parentPid) && parentPid >= 0 ? parentPid : 0,
|
|
818
|
+
startOrder,
|
|
819
|
+
depth: Math.max(0, Number(depth) || 0)
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function sameExactWindowsProcess(left, right) {
|
|
824
|
+
return Number(left?.pid || 0) === Number(right?.pid || 0)
|
|
825
|
+
&& String(left?.startOrder || '') === String(right?.startOrder || '');
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function windowsProcessStartedWithinParentLifetime(record, parent, parentExitedAtMs = null) {
|
|
829
|
+
try {
|
|
830
|
+
const childStart = BigInt(record.startOrder);
|
|
831
|
+
if (childStart < BigInt(parent.startOrder)) return false;
|
|
832
|
+
if (parentExitedAtMs === null) return true;
|
|
833
|
+
const parentExitTicks = BigInt(Math.trunc(parentExitedAtMs)) * 10_000n
|
|
834
|
+
+ 621_355_968_000_000_000n;
|
|
835
|
+
return childStart <= parentExitTicks;
|
|
836
|
+
} catch {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function queryExactWindowsProcessSnapshot() {
|
|
842
|
+
const script = [
|
|
843
|
+
'$ErrorActionPreference = "Stop";',
|
|
844
|
+
'$records = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object {',
|
|
845
|
+
' $creationUtcTicks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
846
|
+
' [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationUtcTicks = $creationUtcTicks }',
|
|
847
|
+
'});',
|
|
848
|
+
'[pscustomobject]@{ Records = $records } | ConvertTo-Json -Compress -Depth 4'
|
|
849
|
+
].join(' ');
|
|
850
|
+
const result = await runQuiet(
|
|
851
|
+
'powershell.exe',
|
|
852
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
853
|
+
{ timeout: 10000, killSignal: 'SIGKILL' }
|
|
854
|
+
);
|
|
855
|
+
if (!result.ok || !result.stdout) {
|
|
856
|
+
throw new Error(
|
|
857
|
+
'LiveDesk could not establish an immutable Windows process snapshot: '
|
|
858
|
+
+ `${result.stderr || result.error?.message || 'CIM query failed'}`
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
let parsed;
|
|
862
|
+
try {
|
|
863
|
+
parsed = JSON.parse(result.stdout);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
throw new Error(`LiveDesk could not parse the immutable Windows process snapshot: ${error.message}`);
|
|
866
|
+
}
|
|
867
|
+
const records = parsed?.Records == null
|
|
868
|
+
? []
|
|
869
|
+
: Array.isArray(parsed.Records) ? parsed.Records : [parsed.Records];
|
|
870
|
+
return records.map(item => normalizeExactWindowsProcessRecord(item)).filter(Boolean);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function captureExactWindowsProcessTree(rootDetails, { rootExitedAtMs = null } = {}) {
|
|
874
|
+
const rootRecord = normalizeExactWindowsProcessRecord(rootDetails, 0);
|
|
875
|
+
if (!rootRecord) {
|
|
876
|
+
throw new Error('LiveDesk could not establish the launcher CreationDate identity; it was preserved.');
|
|
877
|
+
}
|
|
878
|
+
const snapshot = await queryExactWindowsProcessSnapshot();
|
|
879
|
+
const currentByPid = new Map(snapshot.map(record => [record.pid, record]));
|
|
880
|
+
const currentRoot = currentByPid.get(rootRecord.pid) || null;
|
|
881
|
+
if (currentRoot && !sameExactWindowsProcess(currentRoot, rootRecord)) {
|
|
882
|
+
throw new Error(
|
|
883
|
+
`Windows pid=${rootRecord.pid} was reused before its exact tree could be captured; `
|
|
884
|
+
+ 'the replacement process was preserved.'
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
if (!currentRoot && rootExitedAtMs === null) return [];
|
|
888
|
+
|
|
889
|
+
const childrenByParent = new Map();
|
|
890
|
+
for (const record of snapshot) {
|
|
891
|
+
if (!childrenByParent.has(record.parentPid)) childrenByParent.set(record.parentPid, []);
|
|
892
|
+
childrenByParent.get(record.parentPid).push(record);
|
|
893
|
+
}
|
|
894
|
+
const records = currentRoot ? [{ ...currentRoot, depth: 0 }] : [];
|
|
895
|
+
const queue = [{
|
|
896
|
+
record: currentRoot || rootRecord,
|
|
897
|
+
depth: 0,
|
|
898
|
+
exitedAtMs: currentRoot ? null : rootExitedAtMs
|
|
899
|
+
}];
|
|
900
|
+
const seen = new Set([rootRecord.pid]);
|
|
901
|
+
while (queue.length > 0) {
|
|
902
|
+
const parent = queue.shift();
|
|
903
|
+
for (const child of childrenByParent.get(parent.record.pid) || []) {
|
|
904
|
+
if (seen.has(child.pid)) continue;
|
|
905
|
+
if (!windowsProcessStartedWithinParentLifetime(
|
|
906
|
+
child,
|
|
907
|
+
parent.record,
|
|
908
|
+
parent.exitedAtMs
|
|
909
|
+
)) continue;
|
|
910
|
+
seen.add(child.pid);
|
|
911
|
+
const descendant = { ...child, depth: parent.depth + 1 };
|
|
912
|
+
records.push(descendant);
|
|
913
|
+
queue.push({ record: descendant, depth: descendant.depth, exitedAtMs: null });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return records;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function exactWindowsRecordsStillRunning(records) {
|
|
920
|
+
if (!Array.isArray(records) || records.length === 0) return [];
|
|
921
|
+
const snapshot = await queryExactWindowsProcessSnapshot();
|
|
922
|
+
const currentByPid = new Map(snapshot.map(record => [record.pid, record]));
|
|
923
|
+
return records.filter(record => sameExactWindowsProcess(currentByPid.get(record.pid), record));
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function waitForExactWindowsRecordsExit(records, timeoutMs = 2500) {
|
|
927
|
+
const deadline = Date.now() + timeoutMs;
|
|
928
|
+
let remaining = records;
|
|
929
|
+
while (remaining.length > 0 && Date.now() < deadline) {
|
|
930
|
+
remaining = await exactWindowsRecordsStillRunning(remaining);
|
|
931
|
+
if (remaining.length === 0) return [];
|
|
932
|
+
await waitMilliseconds(Math.min(100, Math.max(1, deadline - Date.now())));
|
|
933
|
+
}
|
|
934
|
+
return exactWindowsRecordsStillRunning(remaining);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async function terminateExactWindowsProcesses(recordOrRecords) {
|
|
938
|
+
const targets = (Array.isArray(recordOrRecords) ? recordOrRecords : [recordOrRecords])
|
|
939
|
+
.map(record => normalizeExactWindowsProcessRecord(record, record?.depth))
|
|
940
|
+
.filter(Boolean);
|
|
941
|
+
if (targets.length === 0) {
|
|
942
|
+
throw new Error('LiveDesk refused to terminate Windows processes without immutable CreationDates.');
|
|
943
|
+
}
|
|
944
|
+
const targetsJson = JSON.stringify(targets.map(target => ({
|
|
945
|
+
pid: target.pid,
|
|
946
|
+
startOrder: target.startOrder
|
|
947
|
+
}))).replaceAll("'", "''");
|
|
948
|
+
const script = [
|
|
949
|
+
'$ErrorActionPreference = "Stop";',
|
|
950
|
+
`$targets = ConvertFrom-Json -InputObject '${targetsJson}';`,
|
|
951
|
+
'$failures = [System.Collections.Generic.List[string]]::new();',
|
|
952
|
+
'foreach ($item in $targets) {',
|
|
953
|
+
' $targetPid = [int]$item.pid;',
|
|
954
|
+
' $expectedStartTicks = [string]$item.startOrder;',
|
|
955
|
+
' try {',
|
|
956
|
+
' $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
957
|
+
' if (-not $target) { continue };',
|
|
958
|
+
' $targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
959
|
+
' if ($targetStartTicks -ne $expectedStartTicks) { continue };',
|
|
960
|
+
' Stop-Process -Id $targetPid -Force -ErrorAction Stop;',
|
|
961
|
+
' } catch {',
|
|
962
|
+
' $failures.Add("pid=$targetPid $($_.Exception.Message)");',
|
|
963
|
+
' }',
|
|
964
|
+
'}',
|
|
965
|
+
'if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 };',
|
|
966
|
+
'exit 0;'
|
|
967
|
+
].join(' ');
|
|
968
|
+
return runQuiet(
|
|
969
|
+
'powershell.exe',
|
|
970
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
971
|
+
{ timeout: 15000, killSignal: 'SIGKILL' }
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function terminateExactWindowsProcessTree(rootDetails, {
|
|
976
|
+
label = 'LiveDesk process tree',
|
|
977
|
+
maxAttempts = 4,
|
|
978
|
+
additionalRecords = []
|
|
979
|
+
} = {}) {
|
|
980
|
+
const rootRecord = normalizeExactWindowsProcessRecord(rootDetails, 0);
|
|
981
|
+
if (!rootRecord) {
|
|
982
|
+
throw new Error(`Cannot terminate ${label} without an immutable Windows CreationDate.`);
|
|
983
|
+
}
|
|
984
|
+
const tracked = new Map();
|
|
985
|
+
const mergeRecords = records => {
|
|
986
|
+
for (const record of records) {
|
|
987
|
+
const normalized = normalizeExactWindowsProcessRecord(record, record?.depth);
|
|
988
|
+
if (normalized) tracked.set(`${normalized.pid}:${normalized.startOrder}`, normalized);
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
let rootExitedAtMs = null;
|
|
992
|
+
// A Client persists every immutable Agent/capture descendant it has
|
|
993
|
+
// observed. Seed that exact handoff before walking the still-live PPID tree:
|
|
994
|
+
// an FFmpeg whose Agent parent already exited is no longer discoverable by
|
|
995
|
+
// ancestry, but PID + CreationDate still makes it safe to terminate.
|
|
996
|
+
mergeRecords(additionalRecords);
|
|
997
|
+
mergeRecords(await captureExactWindowsProcessTree(rootRecord));
|
|
998
|
+
if (tracked.size === 0) {
|
|
999
|
+
rootExitedAtMs = Date.now();
|
|
1000
|
+
mergeRecords(await captureExactWindowsProcessTree(rootRecord, { rootExitedAtMs }));
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const attempts = Math.max(1, Number(maxAttempts) || 4);
|
|
1004
|
+
let lastTaskkillError = null;
|
|
1005
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1006
|
+
let remaining = await exactWindowsRecordsStillRunning([...tracked.values()]);
|
|
1007
|
+
if (!remaining.some(record => sameExactWindowsProcess(record, rootRecord))
|
|
1008
|
+
&& rootExitedAtMs === null) {
|
|
1009
|
+
rootExitedAtMs = Date.now();
|
|
1010
|
+
}
|
|
1011
|
+
mergeRecords(await captureExactWindowsProcessTree(rootRecord, { rootExitedAtMs }));
|
|
1012
|
+
remaining = await exactWindowsRecordsStillRunning([...tracked.values()]);
|
|
1013
|
+
if (remaining.length === 0) return;
|
|
1014
|
+
|
|
1015
|
+
const descendantFirst = [...remaining].sort((left, right) => (
|
|
1016
|
+
Number(right.depth || 0) - Number(left.depth || 0)
|
|
1017
|
+
));
|
|
1018
|
+
// The whole descendant-first batch uses one PowerShell invocation. Each
|
|
1019
|
+
// target is still rechecked immediately before its own Stop-Process.
|
|
1020
|
+
// Never use taskkill /T: its descendants are not identity-checked.
|
|
1021
|
+
const result = await terminateExactWindowsProcesses(descendantFirst);
|
|
1022
|
+
if (!result.ok) lastTaskkillError = result.error || new Error(result.stderr || 'termination failed');
|
|
1023
|
+
remaining = await waitForExactWindowsRecordsExit(remaining);
|
|
1024
|
+
if (remaining.length === 0) {
|
|
1025
|
+
if (rootExitedAtMs === null) rootExitedAtMs = Date.now();
|
|
1026
|
+
const lateDescendants = await captureExactWindowsProcessTree(rootRecord, { rootExitedAtMs });
|
|
1027
|
+
mergeRecords(lateDescendants);
|
|
1028
|
+
remaining = await exactWindowsRecordsStillRunning([...tracked.values()]);
|
|
1029
|
+
if (remaining.length === 0) return;
|
|
1030
|
+
}
|
|
1031
|
+
if (attempt < attempts) await waitMilliseconds(100);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const remaining = await exactWindowsRecordsStillRunning([...tracked.values()]);
|
|
1035
|
+
throw new Error(
|
|
1036
|
+
`${label} retained exact pid(s): ${remaining.map(record => record.pid).join(', ') || 'unknown'}`
|
|
1037
|
+
+ `${lastTaskkillError?.message ? ` (${lastTaskkillError.message})` : ''}`
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
740
1041
|
async function stopExistingRuntime(existing) {
|
|
741
1042
|
const processId = Number(existing?.pid);
|
|
742
|
-
if (!Number.isInteger(processId) || processId <= 1
|
|
1043
|
+
if (!Number.isInteger(processId) || processId <= 1) {
|
|
743
1044
|
throw new Error('Existing LiveDesk runtime has an invalid process id; it was preserved.');
|
|
744
1045
|
}
|
|
1046
|
+
const storedOwnerToken = String(existing?.ownerToken || '').trim();
|
|
1047
|
+
const storedOwnerInstanceMarker = String(existing?.ownerInstanceMarker || '').trim();
|
|
1048
|
+
const storedOwnerStartOrder = String(existing?.ownerStartOrder || '').trim();
|
|
1049
|
+
const hasCompleteStoredOwner = storedOwnerToken.length >= 16
|
|
1050
|
+
&& !!storedOwnerInstanceMarker
|
|
1051
|
+
&& !!storedOwnerStartOrder;
|
|
1052
|
+
const canRequestCooperativeStop = /^[a-f0-9]{32}$/i.test(storedOwnerToken)
|
|
1053
|
+
&& !!storedOwnerInstanceMarker
|
|
1054
|
+
&& !!storedOwnerStartOrder;
|
|
1055
|
+
const exactStaleOwnerProof = hasCompleteStoredOwner
|
|
1056
|
+
? {
|
|
1057
|
+
pid: processId,
|
|
1058
|
+
ownerToken: storedOwnerToken,
|
|
1059
|
+
ownerInstanceMarker: storedOwnerInstanceMarker,
|
|
1060
|
+
ownerStartOrder: storedOwnerStartOrder
|
|
1061
|
+
}
|
|
1062
|
+
: null;
|
|
1063
|
+
if (processId === process.pid) {
|
|
1064
|
+
throw new Error('Existing LiveDesk runtime resolves to this launcher without an exact stale-owner proof; it was preserved.');
|
|
1065
|
+
}
|
|
745
1066
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1067
|
+
// The mailbox is non-destructive and authenticates the current lock token.
|
|
1068
|
+
// Try it before command-line/API proof: on macOS an npx shim can be opaque
|
|
1069
|
+
// and the local status API can be busy, while the exact live owner can still
|
|
1070
|
+
// acknowledge and drain its own capture tree safely.
|
|
1071
|
+
const existingRole = String(existing?.role || '').trim().toLowerCase();
|
|
1072
|
+
const cooperativeTimeoutMs = existingRole === 'hub' ? 16_000 : 14_000;
|
|
1073
|
+
// Calling the requester publishes the atomic mailbox file synchronously
|
|
1074
|
+
// before its first await. Start it before process inspection so a blocked
|
|
1075
|
+
// CIM/ps query cannot delay the old launcher seeing the request.
|
|
1076
|
+
const cooperativeStopPromise = canRequestCooperativeStop
|
|
1077
|
+
? requestCooperativeRuntimeShutdown({
|
|
1078
|
+
stateDir: MANAGER_STATE_DIR,
|
|
1079
|
+
target: existing,
|
|
1080
|
+
reason: 'replacement-launch',
|
|
1081
|
+
timeoutMs: cooperativeTimeoutMs,
|
|
1082
|
+
ackTimeoutMs: 2_000,
|
|
1083
|
+
pollMs: 100,
|
|
1084
|
+
// A cheap PID probe is sufficient while waiting: every destructive
|
|
1085
|
+
// fallback below rechecks the immutable CreationDate/start marker.
|
|
1086
|
+
isTargetAlive: async () => isPidAlive(processId)
|
|
1087
|
+
})
|
|
1088
|
+
: Promise.resolve({
|
|
1089
|
+
requested: false,
|
|
1090
|
+
acknowledged: false,
|
|
1091
|
+
exited: false,
|
|
1092
|
+
timedOut: false,
|
|
1093
|
+
ackStatus: ''
|
|
1094
|
+
});
|
|
1095
|
+
const detailsPromise = getProcessDetails(processId).catch(() => null);
|
|
1096
|
+
const cooperativeStop = await cooperativeStopPromise;
|
|
1097
|
+
if (cooperativeStop.exited) {
|
|
1098
|
+
console.log(
|
|
1099
|
+
`[LiveDesk] Existing ${existing?.role || 'runtime'} launcher pid ${processId} `
|
|
1100
|
+
+ 'accepted cooperative cleanup and exited before replacement (identity=exact-owner-mailbox).'
|
|
1101
|
+
);
|
|
1102
|
+
return { runtimeProof: null, staleOwnerProof: null };
|
|
749
1103
|
}
|
|
1104
|
+
let details = await detailsPromise;
|
|
1105
|
+
|
|
1106
|
+
const fallbackDetails = await getProcessDetails(processId);
|
|
1107
|
+
if (!fallbackDetails) {
|
|
1108
|
+
if (!isPidAlive(processId)) {
|
|
1109
|
+
return { runtimeProof: null, staleOwnerProof: null };
|
|
1110
|
+
}
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
`Existing runtime pid ${processId} remained alive but its process identity could not be revalidated; `
|
|
1113
|
+
+ 'it was preserved.'
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
if (details
|
|
1117
|
+
&& (processInstanceMarker(fallbackDetails) !== processInstanceMarker(details)
|
|
1118
|
+
|| String(fallbackDetails.startOrder || '') !== String(details.startOrder || ''))) {
|
|
1119
|
+
console.warn(
|
|
1120
|
+
`[LiveDesk] Launcher pid ${processId} changed identity while cooperative cleanup was pending; `
|
|
1121
|
+
+ 'the reused process was preserved.'
|
|
1122
|
+
);
|
|
1123
|
+
if (exactStaleOwnerProof) {
|
|
1124
|
+
return { runtimeProof: null, staleOwnerProof: exactStaleOwnerProof };
|
|
1125
|
+
}
|
|
1126
|
+
throw new Error(`Existing runtime pid ${processId} was reused during shutdown; the current process was preserved.`);
|
|
1127
|
+
}
|
|
1128
|
+
details = fallbackDetails;
|
|
1129
|
+
const currentOwnerStartOrder = String(details?.startOrder || '').trim();
|
|
1130
|
+
if (hasCompleteStoredOwner
|
|
1131
|
+
&& currentOwnerStartOrder
|
|
1132
|
+
&& storedOwnerStartOrder !== currentOwnerStartOrder) {
|
|
1133
|
+
console.warn(
|
|
1134
|
+
`[LiveDesk] Runtime lock pid ${processId} was reused by a different process instance; `
|
|
1135
|
+
+ 'the current process was preserved and only the exact stale lock may be reclaimed.'
|
|
1136
|
+
);
|
|
1137
|
+
return { runtimeProof: null, staleOwnerProof: exactStaleOwnerProof };
|
|
1138
|
+
}
|
|
1139
|
+
|
|
750
1140
|
const commandLineConfirmed = isKnownLiveDeskLauncherProcess(details.processName, details.commandLine);
|
|
751
|
-
const
|
|
1141
|
+
const preciseOwnerStartOrderSupported = os.platform() === 'win32' || os.platform() === 'linux';
|
|
1142
|
+
const lockInstanceConfirmed = storedOwnerToken.length >= 16
|
|
1143
|
+
&& storedOwnerInstanceMarker === processInstanceMarker(details)
|
|
1144
|
+
&& preciseOwnerStartOrderSupported
|
|
1145
|
+
&& storedOwnerStartOrder.length > 0
|
|
1146
|
+
&& storedOwnerStartOrder === currentOwnerStartOrder;
|
|
1147
|
+
const legacyClientTreeConfirmed = !storedOwnerToken
|
|
1148
|
+
&& existingRole === 'client'
|
|
1149
|
+
&& await hasKnownLiveDeskClientAgentDescendant(processId);
|
|
1150
|
+
const runtimeProof = commandLineConfirmed || lockInstanceConfirmed || legacyClientTreeConfirmed
|
|
752
1151
|
? null
|
|
753
1152
|
: await probeExistingRuntimeIdentity(existing);
|
|
754
|
-
|
|
1153
|
+
const cooperativeOwnerConfirmed = cooperativeStop.acknowledged === true;
|
|
1154
|
+
if (!commandLineConfirmed
|
|
1155
|
+
&& !lockInstanceConfirmed
|
|
1156
|
+
&& !legacyClientTreeConfirmed
|
|
1157
|
+
&& !runtimeProof
|
|
1158
|
+
&& !cooperativeOwnerConfirmed) {
|
|
755
1159
|
throw new Error(`Existing runtime pid ${processId} is not a confirmed LiveDesk launcher; it was preserved.`);
|
|
756
1160
|
}
|
|
757
1161
|
|
|
758
|
-
const identityProof =
|
|
759
|
-
?
|
|
760
|
-
:
|
|
1162
|
+
const identityProof = cooperativeOwnerConfirmed
|
|
1163
|
+
? `cooperative-ack:${cooperativeStop.ackStatus || 'accepted'}`
|
|
1164
|
+
: commandLineConfirmed
|
|
1165
|
+
? 'command-line'
|
|
1166
|
+
: lockInstanceConfirmed
|
|
1167
|
+
? 'lock-instance'
|
|
1168
|
+
: legacyClientTreeConfirmed
|
|
1169
|
+
? 'legacy-lock-exact-client-descendant'
|
|
1170
|
+
: `runtime-api:${runtimeProof.port}:${runtimeProof.appVersion || 'unknown'}`;
|
|
1171
|
+
if (os.platform() === 'darwin'
|
|
1172
|
+
&& !cooperativeOwnerConfirmed
|
|
1173
|
+
&& !runtimeProof) {
|
|
1174
|
+
throw new Error(
|
|
1175
|
+
`Existing macOS runtime pid ${processId} did not acknowledge its exact lock generation; `
|
|
1176
|
+
+ 'coarse ps start time is insufficient for destructive fallback, so it was preserved.'
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
if (hasCompleteStoredOwner) {
|
|
1180
|
+
const currentLock = readJsonFile(getRuntimeLockPath(MANAGER_STATE_DIR));
|
|
1181
|
+
if (!currentLock) {
|
|
1182
|
+
return { runtimeProof: null, staleOwnerProof: null };
|
|
1183
|
+
}
|
|
1184
|
+
if (Number(currentLock.pid) !== processId
|
|
1185
|
+
|| currentLock.ownerToken !== storedOwnerToken
|
|
1186
|
+
|| currentLock.ownerInstanceMarker !== storedOwnerInstanceMarker
|
|
1187
|
+
|| currentLock.ownerStartOrder !== storedOwnerStartOrder) {
|
|
1188
|
+
throw new Error(
|
|
1189
|
+
`LiveDesk runtime-lock ownership changed before fallback for pid ${processId}; `
|
|
1190
|
+
+ 'the current process and newer lock generation were preserved.'
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
let inheritedWindowsProcessRecords = [];
|
|
1195
|
+
if (os.platform() === 'win32'
|
|
1196
|
+
&& existingRole === 'client'
|
|
1197
|
+
&& hasCompleteStoredOwner) {
|
|
1198
|
+
const inheritedManifest = readWindowsOwnedProcessManifest({
|
|
1199
|
+
stateDir: MANAGER_STATE_DIR,
|
|
1200
|
+
owner: exactStaleOwnerProof,
|
|
1201
|
+
maxAgeMs: 60_000
|
|
1202
|
+
});
|
|
1203
|
+
if (inheritedManifest.ok) {
|
|
1204
|
+
inheritedWindowsProcessRecords = inheritedManifest.records;
|
|
1205
|
+
} else if (cooperativeOwnerConfirmed) {
|
|
1206
|
+
throw new Error(
|
|
1207
|
+
`Existing Client pid ${processId} acknowledged cleanup but its exact Windows child manifest `
|
|
1208
|
+
+ `could not be inherited (${inheritedManifest.error || 'missing-or-stale'}); `
|
|
1209
|
+
+ 'the cleanup coordinator was preserved to avoid orphaning FFmpeg.'
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (cooperativeStop.requested) {
|
|
1214
|
+
const status = cooperativeStop.acknowledged
|
|
1215
|
+
? `ack=${cooperativeStop.ackStatus || 'accepted'}`
|
|
1216
|
+
: 'no-ack';
|
|
1217
|
+
console.warn(
|
|
1218
|
+
`[LiveDesk] Existing ${existing?.role || 'runtime'} launcher pid ${processId} `
|
|
1219
|
+
+ `did not finish cooperative cleanup within its ${cooperativeTimeoutMs / 1000}-second `
|
|
1220
|
+
+ `maximum window (${status}); `
|
|
1221
|
+
+ 'continuing with exact process-identity fallback.'
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
761
1224
|
console.log(`[LiveDesk] Stopping existing ${existing?.role || 'runtime'} launcher pid ${processId} before starting a fresh runtime (identity=${identityProof}).`);
|
|
762
1225
|
if (os.platform() === 'win32') {
|
|
763
|
-
await
|
|
1226
|
+
await terminateExactWindowsProcessTree(details, {
|
|
1227
|
+
label: `existing LiveDesk ${existing?.role || 'runtime'} launcher pid=${processId}`,
|
|
1228
|
+
additionalRecords: inheritedWindowsProcessRecords
|
|
1229
|
+
});
|
|
764
1230
|
} else {
|
|
765
1231
|
try { process.kill(processId, 'SIGTERM'); } catch (error) {
|
|
766
1232
|
if (error?.code !== 'ESRCH') throw error;
|
|
767
1233
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1234
|
+
try {
|
|
1235
|
+
await waitForProcessExit(processId);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
console.warn(
|
|
1238
|
+
`[LiveDesk] Confirmed launcher pid ${processId} did not finish graceful Client cleanup; `
|
|
1239
|
+
+ 'force-stopping that launcher only. Verified orphan agents/helpers will be audited before restart.'
|
|
1240
|
+
);
|
|
1241
|
+
try { process.kill(processId, 'SIGKILL'); } catch (killError) {
|
|
1242
|
+
if (killError?.code !== 'ESRCH') throw killError;
|
|
1243
|
+
}
|
|
1244
|
+
await waitForProcessExit(processId, 2000);
|
|
779
1245
|
}
|
|
780
|
-
await waitForProcessExit(processId, 2000);
|
|
781
1246
|
}
|
|
782
|
-
return runtimeProof;
|
|
1247
|
+
return { runtimeProof, staleOwnerProof: null };
|
|
783
1248
|
}
|
|
784
1249
|
|
|
785
1250
|
async function restartExistingRuntime(existing, role) {
|
|
786
|
-
const
|
|
1251
|
+
const stopResult = await stopExistingRuntime(existing);
|
|
1252
|
+
const runtimeProof = stopResult?.runtimeProof || null;
|
|
787
1253
|
const runtimePort = Number(runtimeProof?.port || existingRuntimeProbePorts()[0] || DEFAULT_MANAGER_HTTP_PORT);
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}
|
|
1254
|
+
return {
|
|
1255
|
+
runtimePort,
|
|
1256
|
+
staleOwnerProof: stopResult?.staleOwnerProof || null,
|
|
1257
|
+
role: role || existing?.role || ''
|
|
1258
|
+
};
|
|
794
1259
|
}
|
|
795
1260
|
|
|
796
|
-
function waitMilliseconds(milliseconds) {
|
|
797
|
-
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
function
|
|
1261
|
+
function waitMilliseconds(milliseconds) {
|
|
1262
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function startOwnedRuntimeShutdownWatcher(runtimeOwner, onShutdown) {
|
|
1266
|
+
const exactOwnerIsComplete = Number(runtimeOwner?.pid) === process.pid
|
|
1267
|
+
&& /^[a-f0-9]{32}$/i.test(String(runtimeOwner?.ownerToken || '').trim())
|
|
1268
|
+
&& String(runtimeOwner?.ownerInstanceMarker || '').trim()
|
|
1269
|
+
&& String(runtimeOwner?.ownerStartOrder || '').trim();
|
|
1270
|
+
if (!exactOwnerIsComplete) {
|
|
1271
|
+
if (runtimeOwner?.ownerToken) {
|
|
1272
|
+
console.warn(
|
|
1273
|
+
'[LiveDesk] Cooperative shutdown watcher is unavailable because the launcher '
|
|
1274
|
+
+ 'could not establish a complete process-start identity. Exact fallback remains enabled.'
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
const watcher = startRuntimeShutdownWatcher({
|
|
1280
|
+
stateDir: MANAGER_STATE_DIR,
|
|
1281
|
+
owner: runtimeOwner,
|
|
1282
|
+
pollMs: 100,
|
|
1283
|
+
onShutdown
|
|
1284
|
+
});
|
|
1285
|
+
process.once('exit', () => watcher.stop());
|
|
1286
|
+
return watcher;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function probePortBind(port, host = '0.0.0.0') {
|
|
801
1290
|
return new Promise(resolve => {
|
|
802
1291
|
const server = net.createServer();
|
|
803
1292
|
let settled = false;
|
|
@@ -844,7 +1333,7 @@ function isKnownLiveDeskProcess(processName, commandLine) {
|
|
|
844
1333
|
return false;
|
|
845
1334
|
}
|
|
846
1335
|
|
|
847
|
-
const isKnownHubServer = /(?:
|
|
1336
|
+
const isKnownHubServer = /(?:packages\/(?:livedesk\/)?hub|node_modules\/@?livedesk\/hub)\/src\/server\.js/i.test(normalizedCommandLine);
|
|
848
1337
|
const isUnifiedLauncher = /(?:node_modules\/livedesk|packages\/livedesk)\/bin\/livedesk\.js/i.test(normalizedCommandLine);
|
|
849
1338
|
const isLegacyClient = /(?:node_modules\/@livedesk\/client|packages\/client|packages\/livedesk\/client)\/bin\/livedesk-client\.js/i.test(normalizedCommandLine);
|
|
850
1339
|
return isKnownHubServer || isUnifiedLauncher || isLegacyClient;
|
|
@@ -858,7 +1347,7 @@ function formatPortOwner(record) {
|
|
|
858
1347
|
return `${record?.Pid || 'unknown'}:${record?.ProcessName || 'unknown'} cmd=${String(record?.CommandLine || 'unavailable').replace(/\s+/g, ' ').slice(0, 240)}`;
|
|
859
1348
|
}
|
|
860
1349
|
|
|
861
|
-
function assertPortCleanupReady(finalRecords, portStates) {
|
|
1350
|
+
function assertPortCleanupReady(finalRecords, portStates) {
|
|
862
1351
|
const blockedPorts = portStates.filter(port => port?.Ready !== true);
|
|
863
1352
|
if (blockedPorts.length === 0) {
|
|
864
1353
|
return;
|
|
@@ -870,10 +1359,118 @@ function assertPortCleanupReady(finalRecords, portStates) {
|
|
|
870
1359
|
.map(formatPortOwner);
|
|
871
1360
|
return `TCP ${port.Port}[available=${port.PortAvailable === true},bindable=${port.PortBindable === true},bind-error=${port.BindError || 'none'},owners=${owners.length ? owners.join('; ') : 'owner not visible'}`;
|
|
872
1361
|
}).join('], ');
|
|
873
|
-
throw new Error(`LiveDesk Hub startup cleanup could not release the configured port(s): ${summary}]. Unrelated processes are preserved; stop the owning process manually or choose an explicit --port/--remote-port value.`);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
async function
|
|
1362
|
+
throw new Error(`LiveDesk Hub startup cleanup could not release the configured port(s): ${summary}]. Unrelated processes are preserved; stop the owning process manually or choose an explicit --port/--remote-port value.`);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function collectWindowsPortRecords(ports) {
|
|
1366
|
+
const script = [
|
|
1367
|
+
'$ErrorActionPreference = "SilentlyContinue";',
|
|
1368
|
+
`$ports = @(${ports.join(',')});`,
|
|
1369
|
+
`$currentNodePid = ${process.pid};`,
|
|
1370
|
+
'function Get-ListenConnections($port) {',
|
|
1371
|
+
' $connections = @(Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue);',
|
|
1372
|
+
' if ($connections.Count -gt 0) { return $connections }',
|
|
1373
|
+
' $pattern = "^\\s*TCP\\s+\\S+:" + $port + "\\s+\\S+\\s+LISTENING\\s+(\\d+)\\s*$";',
|
|
1374
|
+
' foreach ($line in @(netstat -ano -p tcp)) {',
|
|
1375
|
+
' $match = [regex]::Match([string]$line, $pattern);',
|
|
1376
|
+
' if ($match.Success) {',
|
|
1377
|
+
' [pscustomobject]@{ LocalPort = [int]$port; OwningProcess = [int]$match.Groups[1].Value }',
|
|
1378
|
+
' }',
|
|
1379
|
+
' }',
|
|
1380
|
+
'}',
|
|
1381
|
+
'$records = @();',
|
|
1382
|
+
'foreach ($port in $ports) {',
|
|
1383
|
+
' $connections = @(Get-ListenConnections $port | Where-Object { $_.OwningProcess -and $_.OwningProcess -ne $currentNodePid -and $_.OwningProcess -ne $PID });',
|
|
1384
|
+
' foreach ($connection in $connections) {',
|
|
1385
|
+
' $owner = [int]$connection.OwningProcess;',
|
|
1386
|
+
' $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $owner" -Property ProcessId,Name,CommandLine,CreationDate -ErrorAction SilentlyContinue;',
|
|
1387
|
+
' $processName = if ($proc) { [string]$proc.Name } else { "" };',
|
|
1388
|
+
' $commandLine = if ($proc) { [string]$proc.CommandLine } else { "" };',
|
|
1389
|
+
' $creationUtcTicks = if ($proc -and $proc.CreationDate) { [string]$proc.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
1390
|
+
' $isNodeProcess = $processName -match "(?i)^node(?:\\.exe)?$";',
|
|
1391
|
+
' $hasKnownLiveDeskHubPath = $commandLine -match "(?i)(?:packages[\\\\/](?:livedesk[\\\\/])?hub|node_modules[\\\\/]@?livedesk[\\\\/]hub)[\\\\/]src[\\\\/]server\\.js";',
|
|
1392
|
+
' $hasUnifiedLauncherPath = $commandLine -match "(?i)(?:node_modules[\\\\/]livedesk|packages[\\\\/]livedesk)[\\\\/]bin[\\\\/]livedesk\\.js";',
|
|
1393
|
+
' $hasLegacyClientPath = $commandLine -match "(?i)(?:node_modules[\\\\/]@livedesk[\\\\/]client|packages[\\\\/]client|packages[\\\\/]livedesk[\\\\/]client)[\\\\/]bin[\\\\/]livedesk-client\\.js";',
|
|
1394
|
+
' $knownLiveDesk = [bool]($isNodeProcess -and ($hasKnownLiveDeskHubPath -or $hasUnifiedLauncherPath -or $hasLegacyClientPath));',
|
|
1395
|
+
' $records += [pscustomobject]@{ Port = [int]$port; Pid = $owner; ProcessName = $processName; CommandLine = $commandLine; CreationUtcTicks = $creationUtcTicks; KnownLiveDesk = $knownLiveDesk; Action = "preserved"; KillAttempted = $false };',
|
|
1396
|
+
' }',
|
|
1397
|
+
'}',
|
|
1398
|
+
'$records = @($records | Sort-Object Port,Pid -Unique);',
|
|
1399
|
+
'[pscustomobject]@{ Records = $records } | ConvertTo-Json -Compress -Depth 5'
|
|
1400
|
+
].join(' ');
|
|
1401
|
+
const result = await runQuiet(
|
|
1402
|
+
'powershell.exe',
|
|
1403
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
1404
|
+
{ timeout: 10000, killSignal: 'SIGKILL' }
|
|
1405
|
+
);
|
|
1406
|
+
if (!result.ok && !result.stdout) {
|
|
1407
|
+
throw new Error(
|
|
1408
|
+
`Could not inspect existing LiveDesk Windows ports: `
|
|
1409
|
+
+ `${result.stderr || result.error?.message || 'PowerShell failed'}`
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
if (!result.stdout) return [];
|
|
1413
|
+
let parsed;
|
|
1414
|
+
try {
|
|
1415
|
+
parsed = JSON.parse(result.stdout);
|
|
1416
|
+
} catch {
|
|
1417
|
+
throw new Error(`Could not parse existing LiveDesk Windows port state: ${result.stdout}`);
|
|
1418
|
+
}
|
|
1419
|
+
const records = parsed?.Records == null
|
|
1420
|
+
? []
|
|
1421
|
+
: Array.isArray(parsed.Records) ? parsed.Records : [parsed.Records];
|
|
1422
|
+
return records;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
async function stopWindowsProcessesOnPorts(ports) {
|
|
1426
|
+
const records = await collectWindowsPortRecords(ports);
|
|
1427
|
+
const stoppedPids = new Set();
|
|
1428
|
+
for (const record of records) {
|
|
1429
|
+
const pid = Number(record?.Pid || 0);
|
|
1430
|
+
if (record?.KnownLiveDesk !== true || stoppedPids.has(pid)) continue;
|
|
1431
|
+
const details = await getProcessDetails(pid);
|
|
1432
|
+
if (!details
|
|
1433
|
+
|| String(details.startOrder || '') !== String(record?.CreationUtcTicks || '')
|
|
1434
|
+
|| !isKnownLiveDeskProcess(details.processName, details.commandLine)) {
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
await terminateExactWindowsProcessTree(details, {
|
|
1438
|
+
label: `verified LiveDesk port owner pid=${pid}`
|
|
1439
|
+
});
|
|
1440
|
+
stoppedPids.add(pid);
|
|
1441
|
+
for (const matching of records.filter(candidate => Number(candidate?.Pid) === pid)) {
|
|
1442
|
+
matching.Action = 'stopped';
|
|
1443
|
+
matching.KillAttempted = true;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const probes = await Promise.all(ports.map(port => waitForPortBindable(port)));
|
|
1448
|
+
const finalRecords = await collectWindowsPortRecords(ports);
|
|
1449
|
+
const portStates = ports.map((port, index) => {
|
|
1450
|
+
const busy = finalRecords.some(record => Number(record?.Port) === Number(port));
|
|
1451
|
+
return {
|
|
1452
|
+
Port: Number(port),
|
|
1453
|
+
PortAvailable: !busy,
|
|
1454
|
+
PortBindable: probes[index].ok,
|
|
1455
|
+
BindError: probes[index].error || '',
|
|
1456
|
+
Ready: !busy && probes[index].ok
|
|
1457
|
+
};
|
|
1458
|
+
});
|
|
1459
|
+
const stopped = records.filter(record => record?.Action === 'stopped');
|
|
1460
|
+
if (stopped.length > 0) {
|
|
1461
|
+
const stoppedOwners = new Map();
|
|
1462
|
+
for (const record of stopped) {
|
|
1463
|
+
if (!stoppedOwners.has(String(record.Pid))) stoppedOwners.set(String(record.Pid), record);
|
|
1464
|
+
}
|
|
1465
|
+
const summary = [...stoppedOwners.values()]
|
|
1466
|
+
.map(record => `${record.Pid}:${record.ProcessName || 'node'}`)
|
|
1467
|
+
.join(', ');
|
|
1468
|
+
console.log(`Restarting LiveDesk Hub. Stopped stale LiveDesk owner(s) once: ${summary}`);
|
|
1469
|
+
}
|
|
1470
|
+
assertPortCleanupReady(finalRecords, portStates);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
async function collectUnixPortRecords(ports) {
|
|
877
1474
|
const records = [];
|
|
878
1475
|
for (const port of ports) {
|
|
879
1476
|
const lsof = await runQuiet('lsof', ['-nP', '-ti', `tcp:${port}`, '-sTCP:LISTEN']);
|
|
@@ -1003,7 +1600,13 @@ async function stopStaleClientAgents() {
|
|
|
1003
1600
|
);
|
|
1004
1601
|
}
|
|
1005
1602
|
const candidatePids = [];
|
|
1006
|
-
const patterns = [
|
|
1603
|
+
const patterns = [
|
|
1604
|
+
'livedesk-client-fast',
|
|
1605
|
+
'livedesk-client-node.js',
|
|
1606
|
+
'livedesk-sck-h264',
|
|
1607
|
+
'livedesk-sck-audio',
|
|
1608
|
+
'livedesk-raw-capture-helper.mjs'
|
|
1609
|
+
];
|
|
1007
1610
|
for (const line of result.stdout.split(/\r?\n/)) {
|
|
1008
1611
|
if (!patterns.some(pattern => line.includes(pattern))) continue;
|
|
1009
1612
|
const match = line.match(/^\s*(\d+)\s+/);
|
|
@@ -1141,121 +1744,12 @@ async function stopStaleClientAgents() {
|
|
|
1141
1744
|
}
|
|
1142
1745
|
};
|
|
1143
1746
|
|
|
1144
|
-
const
|
|
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) => {
|
|
1747
|
+
const terminateWindowsAgentTree = async record => {
|
|
1217
1748
|
const details = await getMatchingDetails(record);
|
|
1218
1749
|
if (!details) return;
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
}
|
|
1750
|
+
await terminateExactWindowsProcessTree(details, {
|
|
1751
|
+
label: `verified stale Windows Agent tree pid=${record.pid}`
|
|
1752
|
+
});
|
|
1259
1753
|
};
|
|
1260
1754
|
|
|
1261
1755
|
const stoppedProcesses = new Map();
|
|
@@ -1301,118 +1795,17 @@ async function stopStaleClientAgents() {
|
|
|
1301
1795
|
|
|
1302
1796
|
async function stopProcessesOnPorts(ports) {
|
|
1303
1797
|
const uniquePorts = [...new Set(ports.map(port => normalizePort(port, 0)).filter(Boolean))];
|
|
1304
|
-
if (uniquePorts.length === 0) {
|
|
1305
|
-
return;
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
if (os.platform() === 'win32') {
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
' if ($connections.Count -gt 0) { return $connections }',
|
|
1316
|
-
' $pattern = "^\\s*TCP\\s+\\S+:" + $port + "\\s+\\S+\\s+LISTENING\\s+(\\d+)\\s*$";',
|
|
1317
|
-
' foreach ($line in @(netstat -ano -p tcp)) {',
|
|
1318
|
-
' $match = [regex]::Match([string]$line, $pattern);',
|
|
1319
|
-
' if ($match.Success) {',
|
|
1320
|
-
' [pscustomobject]@{ LocalPort = [int]$port; OwningProcess = [int]$match.Groups[1].Value }',
|
|
1321
|
-
' }',
|
|
1322
|
-
' }',
|
|
1323
|
-
'}',
|
|
1324
|
-
'function Get-PortRecords($port) {',
|
|
1325
|
-
' $connections = @(Get-ListenConnections $port | Where-Object { $_.OwningProcess -and $_.OwningProcess -ne $currentNodePid -and $_.OwningProcess -ne $PID });',
|
|
1326
|
-
' foreach ($connection in $connections) {',
|
|
1327
|
-
' $owner = [int]$connection.OwningProcess;',
|
|
1328
|
-
' $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $owner" -ErrorAction SilentlyContinue;',
|
|
1329
|
-
' $processName = if ($proc) { [string]$proc.Name } else { "" };',
|
|
1330
|
-
' $commandLine = if ($proc) { [string]$proc.CommandLine } else { "" };',
|
|
1331
|
-
' $isNodeProcess = $processName -match "(?i)^node(?:\\.exe)?$";',
|
|
1332
|
-
' $hasKnownLiveDeskHubPath = $commandLine -match "(?i)(?:packages[\\\\/](?:livedesk[\\\\/])?hub|node_modules[\\\\/]@?livedesk[\\\\/]hub)[\\\\/]src[\\\\/]server\\.js";',
|
|
1333
|
-
' $hasUnifiedLauncherPath = $commandLine -match "(?i)(?:node_modules[\\\\/]livedesk|packages[\\\\/]livedesk)[\\\\/]bin[\\\\/]livedesk\\.js";',
|
|
1334
|
-
' $hasLegacyClientPath = $commandLine -match "(?i)(?:node_modules[\\\\/]@livedesk[\\\\/]client|packages[\\\\/]client|packages[\\\\/]livedesk[\\\\/]client)[\\\\/]bin[\\\\/]livedesk-client\\.js";',
|
|
1335
|
-
' $knownLiveDesk = [bool]($isNodeProcess -and ($hasKnownLiveDeskHubPath -or $hasUnifiedLauncherPath -or $hasLegacyClientPath));',
|
|
1336
|
-
' [pscustomobject]@{ Port = [int]$port; Pid = $owner; ProcessName = $processName; CommandLine = $commandLine; KnownLiveDesk = $knownLiveDesk; Action = "preserved"; KillAttempted = $false }',
|
|
1337
|
-
' }',
|
|
1338
|
-
'}',
|
|
1339
|
-
'$records = @($ports | ForEach-Object { Get-PortRecords $_ } | Sort-Object Port,Pid -Unique);',
|
|
1340
|
-
'$killableOwners = @($records | Where-Object { $_.KnownLiveDesk } | Group-Object Pid | ForEach-Object { @($_.Group)[0] });',
|
|
1341
|
-
'foreach ($owner in $killableOwners) {',
|
|
1342
|
-
' & taskkill.exe /PID $owner.Pid /T /F | Out-Null;',
|
|
1343
|
-
' Stop-Process -Id $owner.Pid -Force -ErrorAction SilentlyContinue;',
|
|
1344
|
-
' foreach ($record in @($records | Where-Object { [int]$_.Pid -eq [int]$owner.Pid })) {',
|
|
1345
|
-
' $record.Action = "stopped";',
|
|
1346
|
-
' $record.KillAttempted = $true;',
|
|
1347
|
-
' }',
|
|
1348
|
-
'}',
|
|
1349
|
-
`$deadline = (Get-Date).ToUniversalTime().AddMilliseconds(${PORT_CLEANUP_TIMEOUT_MS});`,
|
|
1350
|
-
'$finalRecords = @();',
|
|
1351
|
-
'$portStates = @();',
|
|
1352
|
-
'do {',
|
|
1353
|
-
' $finalRecords = @($ports | ForEach-Object { Get-PortRecords $_ });',
|
|
1354
|
-
' $busyPorts = @($finalRecords | Select-Object -ExpandProperty Port -Unique);',
|
|
1355
|
-
' $portStates = @($ports | ForEach-Object {',
|
|
1356
|
-
' $port = [int]$_;',
|
|
1357
|
-
' $busy = $busyPorts -contains $port;',
|
|
1358
|
-
' $bindable = [bool](-not $busy);',
|
|
1359
|
-
' [pscustomobject]@{ Port = $port; PortAvailable = [bool](-not $busy); PortBindable = $bindable; Ready = [bool](-not $busy) }',
|
|
1360
|
-
' });',
|
|
1361
|
-
' if ((@($portStates | Where-Object { -not $_.Ready }).Count -eq 0) -or (Get-Date).ToUniversalTime() -ge $deadline) { break }',
|
|
1362
|
-
` Start-Sleep -Milliseconds ${PORT_CLEANUP_POLL_MS};`,
|
|
1363
|
-
'} while ($true);',
|
|
1364
|
-
'[pscustomobject]@{ Records = @($records); FinalRecords = @($finalRecords); Ports = @($portStates) } | ConvertTo-Json -Compress -Depth 8'
|
|
1365
|
-
].join(' ');
|
|
1366
|
-
const result = await runQuiet('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script]);
|
|
1367
|
-
if (!result.ok && !result.stdout) {
|
|
1368
|
-
throw new Error(`Could not inspect existing LiveDesk Hub ports: ${result.stderr || result.error?.message || 'PowerShell failed'}`);
|
|
1369
|
-
}
|
|
1370
|
-
let payload = { Records: [], FinalRecords: [], Ports: [] };
|
|
1371
|
-
if (result.stdout) {
|
|
1372
|
-
try {
|
|
1373
|
-
const parsed = JSON.parse(result.stdout);
|
|
1374
|
-
payload = Array.isArray(parsed)
|
|
1375
|
-
? { Records: parsed, FinalRecords: parsed, Ports: [] }
|
|
1376
|
-
: parsed;
|
|
1377
|
-
} catch {
|
|
1378
|
-
throw new Error(`Could not parse existing LiveDesk Hub port state: ${result.stdout}`);
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
const records = Array.isArray(payload?.Records) ? payload.Records : [];
|
|
1382
|
-
const finalRecords = Array.isArray(payload?.FinalRecords) ? payload.FinalRecords : [];
|
|
1383
|
-
const rawPortStates = Array.isArray(payload?.Ports) ? payload.Ports : [];
|
|
1384
|
-
const rawStatesByPort = new Map(rawPortStates.map(state => [Number(state?.Port), state]));
|
|
1385
|
-
const portStates = [];
|
|
1386
|
-
for (const port of uniquePorts) {
|
|
1387
|
-
const rawState = rawStatesByPort.get(Number(port)) || {};
|
|
1388
|
-
const probe = rawState.PortAvailable === true
|
|
1389
|
-
? await waitForPortBindable(port)
|
|
1390
|
-
: { ok: false, error: 'port-in-use' };
|
|
1391
|
-
portStates.push({
|
|
1392
|
-
...rawState,
|
|
1393
|
-
Port: Number(port),
|
|
1394
|
-
PortBindable: probe.ok,
|
|
1395
|
-
BindError: probe.error || '',
|
|
1396
|
-
Ready: rawState.PortAvailable === true && probe.ok
|
|
1397
|
-
});
|
|
1398
|
-
}
|
|
1399
|
-
const stopped = records.filter(record => record?.Action === 'stopped');
|
|
1400
|
-
if (stopped.length > 0) {
|
|
1401
|
-
const stoppedOwners = new Map();
|
|
1402
|
-
for (const record of stopped) {
|
|
1403
|
-
if (!stoppedOwners.has(String(record.Pid))) {
|
|
1404
|
-
stoppedOwners.set(String(record.Pid), record);
|
|
1405
|
-
}
|
|
1406
|
-
}
|
|
1407
|
-
const summary = [...stoppedOwners.values()].map(record => `${record.Pid}:${record.ProcessName || 'node'}`).join(', ');
|
|
1408
|
-
console.log(`Restarting LiveDesk Hub. Stopped stale LiveDesk owner(s) once: ${summary}`);
|
|
1409
|
-
}
|
|
1410
|
-
assertPortCleanupReady(finalRecords, portStates);
|
|
1411
|
-
return;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
await stopUnixProcessesOnPorts(uniquePorts);
|
|
1415
|
-
}
|
|
1798
|
+
if (uniquePorts.length === 0) {
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
if (os.platform() === 'win32') {
|
|
1803
|
+
await stopWindowsProcessesOnPorts(uniquePorts);
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
await stopUnixProcessesOnPorts(uniquePorts);
|
|
1808
|
+
}
|
|
1416
1809
|
|
|
1417
1810
|
async function waitForManager(url = DEFAULT_MANAGER_URL, timeoutMs = 10000) {
|
|
1418
1811
|
const startedAt = Date.now();
|
|
@@ -1504,6 +1897,13 @@ function buildHubRestartBootstrapScript() {
|
|
|
1504
1897
|
'const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
|
|
1505
1898
|
'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
|
|
1506
1899
|
'const numberFromEnv = (name, fallback, minimum) => { const value = Number(process.env[name]); return Number.isFinite(value) && value >= minimum ? Math.round(value) : fallback; };',
|
|
1900
|
+
'const runWindowsPowerShell = script => new Promise((resolve, reject) => { execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, timeout: 15000, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { reject(new Error(String(stderr || error.message || "PowerShell failed").trim())); return; } resolve(String(stdout || "").trim()); }); });',
|
|
1901
|
+
'const queryWindowsProcessTable = async () => { if (process.platform !== "win32") return []; const stdout = await runWindowsPowerShell(`$ErrorActionPreference = "Stop"; $records = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object { $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" }; [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationUtcTicks = $ticks } }); [pscustomobject]@{ Records = $records } | ConvertTo-Json -Compress -Depth 4`); if (!stdout) throw new Error("Windows process snapshot returned no data"); const parsed = JSON.parse(stdout); const values = parsed?.Records == null ? [] : (Array.isArray(parsed.Records) ? parsed.Records : [parsed.Records]); return values.map(item => ({ pid: Number(item?.ProcessId || 0), parentPid: Number(item?.ParentProcessId || 0), startOrder: String(item?.CreationUtcTicks || "") })).filter(item => Number.isInteger(item.pid) && item.pid > 1 && /^\\d+$/.test(item.startOrder)); };',
|
|
1902
|
+
'const sameWindowsIdentity = (left, right) => Number(left?.pid || 0) === Number(right?.pid || 0) && String(left?.startOrder || "") === String(right?.startOrder || "");',
|
|
1903
|
+
'const captureWindowsIdentity = async pid => { const processId = Number(pid || 0); const deadline = Date.now() + 2500; let lastError = null; do { try { const snapshot = await queryWindowsProcessTable(); const identity = snapshot.find(item => item.pid === processId) || null; if (identity) return identity; } catch (error) { lastError = error; } if (Date.now() < deadline) await sleep(50); } while (Date.now() < deadline); throw new Error(`Could not capture immutable Windows CreationDate for pid=${processId}: ${lastError?.message || "process not visible"}`); };',
|
|
1904
|
+
'const captureExactWindowsTrees = (identities, snapshot) => { const currentByPid = new Map(snapshot.map(item => [item.pid, item])); const childrenByParent = new Map(); for (const item of snapshot) { if (!childrenByParent.has(item.parentPid)) childrenByParent.set(item.parentPid, []); childrenByParent.get(item.parentPid).push(item); } const tracked = new Map(); for (const root of identities) { const currentRoot = currentByPid.get(Number(root?.pid || 0)); if (!sameWindowsIdentity(currentRoot, root)) continue; const queue = [{ record: currentRoot, depth: 0 }]; const seen = new Set(); while (queue.length > 0) { const current = queue.shift(); if (seen.has(current.record.pid)) continue; seen.add(current.record.pid); const key = `${current.record.pid}:${current.record.startOrder}`; const previous = tracked.get(key); if (!previous || current.depth > previous.depth) tracked.set(key, { ...current.record, depth: current.depth }); for (const child of childrenByParent.get(current.record.pid) || []) { try { if (BigInt(child.startOrder) < BigInt(current.record.startOrder)) continue; } catch { continue; } queue.push({ record: child, depth: current.depth + 1 }); } } } return [...tracked.values()].sort((left, right) => Number(right.depth || 0) - Number(left.depth || 0)); };',
|
|
1905
|
+
'const stopExactWindowsRecords = async records => { if (!Array.isArray(records) || records.length === 0) return; const encoded = Buffer.from(JSON.stringify(records.map(record => ({ pid: record.pid, startOrder: record.startOrder }))), "utf8").toString("base64"); const script = `$ErrorActionPreference = "Stop"; $targetsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${encoded}")); $targets = ConvertFrom-Json -InputObject $targetsJson; $failures = [System.Collections.Generic.List[string]]::new(); foreach ($item in $targets) { $targetPid = [int]$item.pid; $expectedStartTicks = [string]$item.startOrder; try { $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1; if (-not $target) { continue }; $targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" }; if ($targetStartTicks -ne $expectedStartTicks) { continue }; Stop-Process -Id $targetPid -Force -ErrorAction Stop } catch { $failures.Add("pid=$targetPid $($_.Exception.Message)") } }; if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 }`; await runWindowsPowerShell(script); };',
|
|
1906
|
+
'const terminateExactWindowsOwnedTrees = async (identities, { skipStop = false } = {}) => { const roots = (Array.isArray(identities) ? identities : []).filter(identity => Number(identity?.pid || 0) > 1 && /^\\d+$/.test(String(identity?.startOrder || ""))); if (roots.length === 0) throw new Error("No immutable Windows replacement identity is available"); const snapshot = await queryWindowsProcessTable(); const targets = captureExactWindowsTrees(roots, snapshot); if (!skipStop) await stopExactWindowsRecords(targets); const finalSnapshot = await queryWindowsProcessTable(); const currentByPid = new Map(finalSnapshot.map(item => [item.pid, item])); const remaining = targets.filter(target => sameWindowsIdentity(currentByPid.get(target.pid), target)); if (remaining.length > 0) throw new Error(`${skipStop ? "simulated exact stop no-op; " : ""}exact pid(s) still alive: ${remaining.map(item => item.pid).join(", ")}`); };',
|
|
1507
1907
|
'const waitForExit = async (pid, timeoutMs) => { const deadline = Date.now() + timeoutMs; while (pid > 1 && Date.now() < deadline) { if (!isAlive(pid)) return; await sleep(250); } if (pid > 1 && isAlive(pid)) throw new Error(`Previous LiveDesk Hub launcher pid=${pid} did not exit within ${timeoutMs}ms.`); };',
|
|
1508
1908
|
'const decodeArgs = name => JSON.parse(Buffer.from(process.env[name] || "", "base64").toString("utf8"));',
|
|
1509
1909
|
'const primary = { command: process.env.LIVEDESK_RESTART_COMMAND || "", args: decodeArgs("LIVEDESK_RESTART_ARGS_BASE64") };',
|
|
@@ -1526,14 +1926,118 @@ function buildHubRestartBootstrapScript() {
|
|
|
1526
1926
|
'const probeRemotePort = () => new Promise((resolve, reject) => { const socket = net.createConnection({ host: remoteHost, port: remotePort }); const timer = setTimeout(() => socket.destroy(new Error("TCP probe timed out")), Math.min(1500, healthTimeoutMs)); timer.unref?.(); socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(); }); socket.once("error", error => { clearTimeout(timer); reject(error); }); });',
|
|
1527
1927
|
'const probeReplacement = async requiredVersion => { const [status, health] = await Promise.all([requestJson("/api/runtime/status"), requestJson("/api/health")]); const runtimePid = Number(status?.runtimePid || 0); if (status?.role !== "hub" || status?.runtimeStarted !== true || runtimePid <= 1) throw new Error("replacement runtime status is not ready"); if (previousRuntimePid > 1 && runtimePid === previousRuntimePid) throw new Error(`old runtime pid=${runtimePid} is still answering`); if (requiredVersion && String(status?.appVersion || "") !== requiredVersion) throw new Error(`version mismatch expected=${requiredVersion} actual=${status?.appVersion || "unknown"}`); if (health?.ok !== true || health?.product !== "LiveDesk") throw new Error("replacement health response is invalid"); await probeRemotePort(); return { runtimePid, appVersion: String(status?.appVersion || "") }; };',
|
|
1528
1928
|
'const terminationError = (pid, detail) => { const error = new Error(`Unverified replacement pid=${pid} could not be terminated${detail ? `: ${detail}` : ""}`); error.code = "LIVEDESK_HUB_RESTART_TERMINATION_FAILED"; return error; };',
|
|
1529
|
-
'const terminateTree = async (child, runtimePid = 0) => {
|
|
1530
|
-
'const
|
|
1929
|
+
'const terminateTree = async (child, runtimePid = 0) => {',
|
|
1930
|
+
' const pid = Number(child?.pid || 0);',
|
|
1931
|
+
' const observedRuntimePid = Number(runtimePid || 0);',
|
|
1932
|
+
' if (pid <= 1) return;',
|
|
1933
|
+
' log(`terminating unverified replacement pid=${pid} runtimePid=${observedRuntimePid || 0}`);',
|
|
1934
|
+
' if (process.platform === "win32") {',
|
|
1935
|
+
' const identities = [child?.livedeskWindowsLauncherIdentity, child?.livedeskWindowsRuntimeIdentity].filter(Boolean);',
|
|
1936
|
+
' if (!child?.livedeskWindowsLauncherIdentity) {',
|
|
1937
|
+
' if (observedRuntimePid <= 1 && (child?.livedeskObservedExit || child?.exitCode !== null || child?.signalCode !== null)) return;',
|
|
1938
|
+
' throw terminationError(pid, "immutable launcher CreationDate was not captured");',
|
|
1939
|
+
' }',
|
|
1940
|
+
' try {',
|
|
1941
|
+
' await terminateExactWindowsOwnedTrees(identities, { skipStop: terminationFailureOperationId === operationId });',
|
|
1942
|
+
' } catch (error) {',
|
|
1943
|
+
' throw terminationError(pid, error?.message || String(error));',
|
|
1944
|
+
' }',
|
|
1945
|
+
' return;',
|
|
1946
|
+
' }',
|
|
1947
|
+
' const rootsAlive = () => isAlive(pid) || (observedRuntimePid > 1 && isAlive(observedRuntimePid));',
|
|
1948
|
+
' if (terminationFailureOperationId !== operationId) {',
|
|
1949
|
+
' try { process.kill(-pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch {} }',
|
|
1950
|
+
' }',
|
|
1951
|
+
' const gracefulDeadline = Date.now() + 2000;',
|
|
1952
|
+
' while (Date.now() < gracefulDeadline && (isGroupAlive(pid) || rootsAlive())) await sleep(100);',
|
|
1953
|
+
' if (isGroupAlive(pid) || rootsAlive()) {',
|
|
1954
|
+
' if (terminationFailureOperationId !== operationId) {',
|
|
1955
|
+
' log(`unverified replacement pid=${pid} ignored SIGTERM; sending SIGKILL to process group`);',
|
|
1956
|
+
' try { process.kill(-pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch {} }',
|
|
1957
|
+
' }',
|
|
1958
|
+
' const hardDeadline = Date.now() + 1000;',
|
|
1959
|
+
' while (Date.now() < hardDeadline && (isGroupAlive(pid) || rootsAlive())) await sleep(100);',
|
|
1960
|
+
' }',
|
|
1961
|
+
' if (isGroupAlive(pid) || rootsAlive()) throw terminationError(pid, `launcher/process group/runtime still alive (${observedRuntimePid || "no runtime pid"})`);',
|
|
1962
|
+
'};',
|
|
1963
|
+
'const launchAndVerify = async (invocation, label, requiredVersion, attempt) => {',
|
|
1964
|
+
' if (!invocation.command || !Array.isArray(invocation.args)) throw new Error(`${label} restart command is unavailable`);',
|
|
1965
|
+
' const isFallback = label === "fallback";',
|
|
1966
|
+
' if (!isFallback) prepareNeutralCwd();',
|
|
1967
|
+
' const launchCwd = isFallback ? fallbackCwd : neutralCwd;',
|
|
1968
|
+
' if (!launchCwd) throw new Error(`${label} restart working directory is unavailable`);',
|
|
1969
|
+
' const launchEnv = isFallback ? { ...process.env } : buildLaunchEnv();',
|
|
1970
|
+
' assertResultOwnership();',
|
|
1971
|
+
' log(`${label} attempt=${attempt} stage=spawning command=${invocation.command} target=${requiredVersion || "current"} cwd=${launchCwd}`);',
|
|
1972
|
+
' let exit = null;',
|
|
1973
|
+
' let lastRuntimePid = 0;',
|
|
1974
|
+
' let stableRuntimePid = 0;',
|
|
1975
|
+
' let stableSince = 0;',
|
|
1976
|
+
' const child = spawn(invocation.command, invocation.args, { cwd: launchCwd, env: launchEnv, detached: true, stdio: "ignore", windowsHide: true });',
|
|
1977
|
+
' child.once("exit", (code, signal) => {',
|
|
1978
|
+
' exit = { code, signal };',
|
|
1979
|
+
' child.livedeskObservedExit = exit;',
|
|
1980
|
+
' log(`${label} attempt=${attempt} stage=exited code=${code ?? "none"} signal=${signal || "none"}`);',
|
|
1981
|
+
' });',
|
|
1982
|
+
' await new Promise((resolve, reject) => { child.once("error", reject); child.once("spawn", resolve); });',
|
|
1983
|
+
' if (process.platform === "win32") {',
|
|
1984
|
+
' try {',
|
|
1985
|
+
' child.livedeskWindowsLauncherIdentity = await captureWindowsIdentity(child.pid);',
|
|
1986
|
+
' } catch (error) {',
|
|
1987
|
+
' if (!(exit || child.exitCode !== null || child.signalCode !== null || !isAlive(Number(child.pid || 0)))) {',
|
|
1988
|
+
' try { child.kill("SIGKILL"); } catch {}',
|
|
1989
|
+
' throw terminationError(Number(child.pid || 0), `immutable launcher identity capture failed: ${error?.message || error}`);',
|
|
1990
|
+
' }',
|
|
1991
|
+
' log(`${label} attempt=${attempt} stage=exited-before-identity pid=${child.pid || 0}`);',
|
|
1992
|
+
' }',
|
|
1993
|
+
' }',
|
|
1994
|
+
' log(`${label} attempt=${attempt} stage=spawned pid=${child.pid || 0}`);',
|
|
1995
|
+
' const deadline = Date.now() + healthTimeoutMs;',
|
|
1996
|
+
' let lastError = null;',
|
|
1997
|
+
' while (Date.now() < deadline) {',
|
|
1998
|
+
' try {',
|
|
1999
|
+
' assertResultOwnership();',
|
|
2000
|
+
' if (exit || !isAlive(Number(child.pid || 0))) throw new Error(`launcher pid=${child.pid || 0} exited before stability proof`);',
|
|
2001
|
+
' const status = await probeReplacement(requiredVersion);',
|
|
2002
|
+
' lastRuntimePid = Number(status.runtimePid || 0);',
|
|
2003
|
+
' if (!isAlive(Number(child.pid || 0))) throw new Error(`launcher pid=${child.pid || 0} exited during stability proof`);',
|
|
2004
|
+
' if (!isAlive(lastRuntimePid)) throw new Error(`runtime pid=${lastRuntimePid || 0} exited during stability proof`);',
|
|
2005
|
+
' if (process.platform === "win32" && Number(child.livedeskWindowsRuntimeIdentity?.pid || 0) !== lastRuntimePid) {',
|
|
2006
|
+
' child.livedeskWindowsRuntimeIdentity = await captureWindowsIdentity(lastRuntimePid);',
|
|
2007
|
+
' }',
|
|
2008
|
+
' assertResultOwnership();',
|
|
2009
|
+
' if (stableRuntimePid !== lastRuntimePid) {',
|
|
2010
|
+
' stableRuntimePid = lastRuntimePid;',
|
|
2011
|
+
' stableSince = Date.now();',
|
|
2012
|
+
' log(`${label} attempt=${attempt} stage=stability-start launcherPid=${child.pid || 0} runtimePid=${lastRuntimePid} requiredMs=${stabilityMs}`);',
|
|
2013
|
+
' }',
|
|
2014
|
+
' if (Date.now() - stableSince >= stabilityMs) {',
|
|
2015
|
+
' log(`${label} attempt=${attempt} stage=verified launcherPid=${child.pid || 0} runtimePid=${status.runtimePid} version=${status.appVersion || "unknown"} stableMs=${Date.now() - stableSince} http=${healthBaseUrl} remote=${remoteHost}:${remotePort}`);',
|
|
2016
|
+
' child.unref();',
|
|
2017
|
+
' return { ...status, launcherPid: Number(child.pid || 0) };',
|
|
2018
|
+
' }',
|
|
2019
|
+
' } catch (error) {',
|
|
2020
|
+
' if (error?.code === "LIVEDESK_HUB_UPDATE_SUPERSEDED") {',
|
|
2021
|
+
' await terminateTree(child, lastRuntimePid);',
|
|
2022
|
+
' throw error;',
|
|
2023
|
+
' }',
|
|
2024
|
+
' lastError = error;',
|
|
2025
|
+
' stableRuntimePid = 0;',
|
|
2026
|
+
' stableSince = 0;',
|
|
2027
|
+
' }',
|
|
2028
|
+
' if (exit) break;',
|
|
2029
|
+
' await sleep(pollMs);',
|
|
2030
|
+
' }',
|
|
2031
|
+
' await terminateTree(child, lastRuntimePid);',
|
|
2032
|
+
' throw new Error(`${label} attempt=${attempt} did not remain healthy for ${stabilityMs}ms: ${lastError?.message || (exit ? `command exited code=${exit.code ?? "none"} signal=${exit.signal || "none"}` : "health deadline expired")}`);',
|
|
2033
|
+
'};',
|
|
1531
2034
|
'(async () => { const targetErrors = []; const fallbackErrors = []; if (exitBeforeHandshakeOperationId === operationId) { log("stage=test-exit-before-handshake"); process.exitCode = 86; return; } if (delayHandshakeOperationId === operationId) { const claimDelayMs = numberFromEnv("LIVEDESK_TEST_HUB_RESTART_HANDSHAKE_DELAY_MS", 2000, 1); log(`stage=test-delay-before-handshake delayMs=${claimDelayMs}`); await sleep(claimDelayMs); } writeResult("waiting-for-old-launcher", { outcome: "in-progress" }); log(`stage=waiting-for-old-launcher pid=${waitPid} supervisorPid=${process.pid}`); if (exitAfterHandshakeOperationId === operationId) { log("stage=test-exit-after-handshake"); process.exitCode = 87; return; } await waitForExit(waitPid, 60000); writeResult("old-launcher-exited", { outcome: "in-progress" }); log("stage=old-launcher-exited"); for (let attempt = 1; attempt <= attemptLimit; attempt += 1) { writeResult("target-attempt", { outcome: "in-progress", attempt }); try { const status = await launchAndVerify(primary, "target", expectedVersion, attempt); writeResult("updated", { outcome: "updated", attempt, runtimePid: status.runtimePid, launcherPid: status.launcherPid, appVersion: status.appVersion, completedAt: new Date().toISOString(), error: undefined }); log("stage=completed"); return; } catch (error) { if (["LIVEDESK_HUB_UPDATE_SUPERSEDED", "LIVEDESK_HUB_RESTART_TERMINATION_FAILED"].includes(error?.code)) throw error; targetErrors.push(error.message); writeResult("target-failed", { outcome: "in-progress", attempt, error: error.message }); log(`target attempt=${attempt} stage=failed error=${error.message}`); } } log(`stage=fallback-start target=${fallbackVersion || "current"}`); for (let attempt = 1; attempt <= 2; attempt += 1) { writeResult("fallback-attempt", { outcome: "in-progress", attempt, error: targetErrors.join(" | ") }); try { const status = await launchAndVerify(fallback, "fallback", fallbackVersion, attempt); writeResult("restored", { outcome: "restored", attempt, runtimePid: status.runtimePid, launcherPid: status.launcherPid, appVersion: status.appVersion, completedAt: new Date().toISOString(), error: `Target Hub ${expectedVersion} failed verification: ${targetErrors.join(" | ")}` }); log("stage=completed-with-fallback"); return; } catch (error) { if (["LIVEDESK_HUB_UPDATE_SUPERSEDED", "LIVEDESK_HUB_RESTART_TERMINATION_FAILED"].includes(error?.code)) throw error; fallbackErrors.push(error.message); writeResult("fallback-failed", { outcome: "in-progress", attempt, error: [...targetErrors, ...fallbackErrors].join(" | ") }); log(`fallback attempt=${attempt} stage=failed error=${error.message}`); } } throw new Error(`No verified LiveDesk Hub replacement could be started. Target failures: ${targetErrors.join(" | ") || "none"}. Fallback failures: ${fallbackErrors.join(" | ") || "none"}. Inspect ${logPath || "the Hub restart log"} and start the frozen local LiveDesk Hub manually.`); })().catch(error => { if (error?.code === "LIVEDESK_HUB_UPDATE_SUPERSEDED") { log("stage=bootstrap-superseded error=" + error.message); return; } try { writeResult("failed", { outcome: "failed", completedAt: new Date().toISOString(), error: error.message }); } catch (writeError) { log("stage=result-write-failed resultStage=failed error=" + writeError.message); } log("stage=bootstrap-failed error=" + error.message); process.exitCode = 1; });',
|
|
1532
2035
|
''
|
|
1533
2036
|
].join('\n');
|
|
1534
2037
|
}
|
|
1535
2038
|
|
|
1536
|
-
async function runManager(args, resolvedRole = null) {
|
|
2039
|
+
async function runManager(args, resolvedRole = null, runtimeLock = null) {
|
|
2040
|
+
const runtimeOwner = runtimeLock?.payload || null;
|
|
1537
2041
|
const options = parseManagerArgs(args);
|
|
1538
2042
|
const httpPort = normalizePort(
|
|
1539
2043
|
options.port || process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT,
|
|
@@ -1622,6 +2126,91 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1622
2126
|
let updatePollId = null;
|
|
1623
2127
|
let updateStopTimer = null;
|
|
1624
2128
|
let updateHardStopTimer = null;
|
|
2129
|
+
let launcherShutdownSignal = '';
|
|
2130
|
+
let launcherShutdownHardStopTimer = null;
|
|
2131
|
+
let runtimeShutdownWatcher = null;
|
|
2132
|
+
const launcherSignalHandlers = new Map();
|
|
2133
|
+
|
|
2134
|
+
const removeLauncherSignalHandlers = () => {
|
|
2135
|
+
for (const [signal, handler] of launcherSignalHandlers) {
|
|
2136
|
+
process.removeListener(signal, handler);
|
|
2137
|
+
}
|
|
2138
|
+
launcherSignalHandlers.clear();
|
|
2139
|
+
};
|
|
2140
|
+
|
|
2141
|
+
const requestLauncherShutdown = signal => {
|
|
2142
|
+
if (launcherShutdownSignal) return;
|
|
2143
|
+
launcherShutdownSignal = signal;
|
|
2144
|
+
restartRequest = null;
|
|
2145
|
+
if (updatePollId) {
|
|
2146
|
+
clearInterval(updatePollId);
|
|
2147
|
+
updatePollId = null;
|
|
2148
|
+
}
|
|
2149
|
+
if (updateStopTimer) {
|
|
2150
|
+
clearTimeout(updateStopTimer);
|
|
2151
|
+
updateStopTimer = null;
|
|
2152
|
+
}
|
|
2153
|
+
if (updateHardStopTimer) {
|
|
2154
|
+
clearTimeout(updateHardStopTimer);
|
|
2155
|
+
updateHardStopTimer = null;
|
|
2156
|
+
}
|
|
2157
|
+
const child = activeChild;
|
|
2158
|
+
if (!child || child.exitCode !== null || child.signalCode) {
|
|
2159
|
+
try { hubLogStream?.end(); } catch {}
|
|
2160
|
+
process.exit(0);
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
reportLauncherUpdate(
|
|
2164
|
+
`[LiveDesk Hub] Launcher received ${signal}; requesting graceful Hub shutdown for pid=${child.pid || 'unknown'}.`
|
|
2165
|
+
);
|
|
2166
|
+
void (async () => {
|
|
2167
|
+
const gracefulDeadline = Date.now() + 2_000;
|
|
2168
|
+
let lastError = null;
|
|
2169
|
+
while (child.exitCode === null
|
|
2170
|
+
&& !child.signalCode
|
|
2171
|
+
&& Date.now() < gracefulDeadline) {
|
|
2172
|
+
try {
|
|
2173
|
+
const response = await fetch(`${hubProbeBaseUrl}/api/runtime/shutdown`, {
|
|
2174
|
+
method: 'POST',
|
|
2175
|
+
signal: AbortSignal.timeout(750)
|
|
2176
|
+
});
|
|
2177
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2178
|
+
reportLauncherUpdate(
|
|
2179
|
+
`[LiveDesk Hub] Owned Hub pid=${child.pid || 'unknown'} acknowledged graceful shutdown.`
|
|
2180
|
+
);
|
|
2181
|
+
return;
|
|
2182
|
+
} catch (error) {
|
|
2183
|
+
lastError = error;
|
|
2184
|
+
await waitMilliseconds(100);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
if (child.exitCode !== null || child.signalCode) return;
|
|
2188
|
+
reportLauncherUpdate(
|
|
2189
|
+
`[LiveDesk Hub] Graceful shutdown API remained unavailable for pid=${child.pid || 'unknown'}`
|
|
2190
|
+
+ `${lastError?.message ? ` (${lastError.message})` : ''}; exact child fallback remains armed.`,
|
|
2191
|
+
true
|
|
2192
|
+
);
|
|
2193
|
+
if (process.platform !== 'win32') {
|
|
2194
|
+
try { child.kill('SIGTERM'); } catch { /* exact owned child already exited */ }
|
|
2195
|
+
}
|
|
2196
|
+
})();
|
|
2197
|
+
launcherShutdownHardStopTimer = setTimeout(() => {
|
|
2198
|
+
if (child.exitCode !== null || child.signalCode) return;
|
|
2199
|
+
reportLauncherUpdate(
|
|
2200
|
+
`[LiveDesk Hub] Owned Hub pid=${child.pid || 'unknown'} exceeded graceful shutdown; `
|
|
2201
|
+
+ 'force-stopping that exact child root.',
|
|
2202
|
+
true
|
|
2203
|
+
);
|
|
2204
|
+
try { child.kill('SIGKILL'); } catch { /* exact owned child already exited */ }
|
|
2205
|
+
}, 12_000);
|
|
2206
|
+
launcherShutdownHardStopTimer.unref?.();
|
|
2207
|
+
};
|
|
2208
|
+
|
|
2209
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
2210
|
+
const handler = () => requestLauncherShutdown(signal);
|
|
2211
|
+
launcherSignalHandlers.set(signal, handler);
|
|
2212
|
+
process.on(signal, handler);
|
|
2213
|
+
}
|
|
1625
2214
|
|
|
1626
2215
|
const restartWithLatest = async request => {
|
|
1627
2216
|
const requestedVersion = normalizeExactUpdateVersion(request?.latestVersion);
|
|
@@ -1817,13 +2406,16 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1817
2406
|
try { updateChild.kill('SIGTERM'); } catch { /* child already exited */ }
|
|
1818
2407
|
updateHardStopTimer = setTimeout(() => {
|
|
1819
2408
|
if (!updateChild || updateChild.exitCode !== null || updateChild.signalCode) return;
|
|
1820
|
-
reportLauncherUpdate(`[LiveDesk Hub] Update shutdown hard fallback: Hub pid=${requestPid} ignored graceful termination; force-stopping
|
|
2409
|
+
reportLauncherUpdate(`[LiveDesk Hub] Update shutdown hard fallback: Hub pid=${requestPid} ignored graceful termination; force-stopping the owned Hub process.`, true);
|
|
1821
2410
|
if (process.platform === 'win32') {
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
2411
|
+
// updateChild is the exact ChildProcess instance spawned by this
|
|
2412
|
+
// launcher. Stop only that owned Hub root; never let taskkill /T
|
|
2413
|
+
// follow unverified PPID descendants.
|
|
2414
|
+
try { updateChild.kill('SIGKILL'); } catch (error) {
|
|
2415
|
+
if (updateChild.exitCode === null && !updateChild.signalCode) {
|
|
2416
|
+
reportLauncherUpdate(`[LiveDesk Hub] hard-stop fallback failed for pid=${requestPid}: ${error.message}`, true);
|
|
1825
2417
|
}
|
|
1826
|
-
}
|
|
2418
|
+
}
|
|
1827
2419
|
} else {
|
|
1828
2420
|
try { updateChild.kill('SIGKILL'); } catch { /* child already exited */ }
|
|
1829
2421
|
}
|
|
@@ -1877,6 +2469,17 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1877
2469
|
});
|
|
1878
2470
|
child.once('exit', (code, signal) => {
|
|
1879
2471
|
const startupOutput = stderrChunks.join('');
|
|
2472
|
+
if (launcherShutdownSignal) {
|
|
2473
|
+
if (launcherShutdownHardStopTimer) {
|
|
2474
|
+
clearTimeout(launcherShutdownHardStopTimer);
|
|
2475
|
+
launcherShutdownHardStopTimer = null;
|
|
2476
|
+
}
|
|
2477
|
+
runtimeShutdownWatcher?.stop();
|
|
2478
|
+
removeLauncherSignalHandlers();
|
|
2479
|
+
try { hubLogStream?.end(); } catch {}
|
|
2480
|
+
process.exit(0);
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
1880
2483
|
if (restartRequest) {
|
|
1881
2484
|
const request = restartRequest;
|
|
1882
2485
|
restartRequest = null;
|
|
@@ -1927,23 +2530,38 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1927
2530
|
});
|
|
1928
2531
|
return;
|
|
1929
2532
|
}
|
|
1930
|
-
if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
|
|
2533
|
+
if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
|
|
1931
2534
|
if (updatePollId) {
|
|
1932
2535
|
clearInterval(updatePollId);
|
|
1933
2536
|
updatePollId = null;
|
|
1934
2537
|
}
|
|
1935
|
-
|
|
2538
|
+
try {
|
|
2539
|
+
runtimeLock?.updateRole?.('client');
|
|
2540
|
+
} catch (error) {
|
|
2541
|
+
console.error(
|
|
2542
|
+
`[LiveDesk Hub] The shared runtime lock could not transition to Client: ${error?.message || error}. `
|
|
2543
|
+
+ 'The launcher will exit so a clean invocation can recover.'
|
|
2544
|
+
);
|
|
2545
|
+
runtimeShutdownWatcher?.stop();
|
|
2546
|
+
removeLauncherSignalHandlers();
|
|
2547
|
+
try { hubLogStream?.end(); } catch {}
|
|
2548
|
+
process.exit(1);
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
writeRoleCache({
|
|
1936
2552
|
role: 'client',
|
|
1937
2553
|
deviceId: resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || '',
|
|
1938
2554
|
deviceName: resolvedRole?.deviceName || os.hostname(),
|
|
1939
2555
|
source: 'supabase',
|
|
1940
2556
|
isOfflineFallback: false
|
|
1941
|
-
}, { stateDir: MANAGER_STATE_DIR });
|
|
1942
|
-
console.log('[LiveDesk Hub] Role transition accepted. Starting the Client runtime.');
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
2557
|
+
}, { stateDir: MANAGER_STATE_DIR });
|
|
2558
|
+
console.log('[LiveDesk Hub] Role transition accepted. Starting the Client runtime.');
|
|
2559
|
+
runtimeShutdownWatcher?.stop();
|
|
2560
|
+
removeLauncherSignalHandlers();
|
|
2561
|
+
void runClient([], { ...resolvedRole, role: 'client', source: 'supabase', roleTransition: true }, runtimeLock).catch(error => {
|
|
2562
|
+
console.error(`Failed to start LiveDesk Client runtime: ${error?.message || error}`);
|
|
2563
|
+
process.exitCode = 1;
|
|
2564
|
+
});
|
|
1947
2565
|
return;
|
|
1948
2566
|
}
|
|
1949
2567
|
if (!signal
|
|
@@ -1972,8 +2590,19 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1972
2590
|
}
|
|
1973
2591
|
process.exit(code ?? 0);
|
|
1974
2592
|
});
|
|
1975
|
-
};
|
|
1976
|
-
|
|
2593
|
+
};
|
|
2594
|
+
|
|
2595
|
+
runtimeShutdownWatcher = startOwnedRuntimeShutdownWatcher(runtimeOwner, async request => {
|
|
2596
|
+
reportLauncherUpdate(
|
|
2597
|
+
`[LiveDesk Hub] Cooperative replacement request accepted `
|
|
2598
|
+
+ `request=${request.requestId} reason=${request.reason}.`
|
|
2599
|
+
);
|
|
2600
|
+
requestLauncherShutdown('COOPERATIVE_REPLACEMENT');
|
|
2601
|
+
// The launcher signal handler keeps ownership until the Hub child has
|
|
2602
|
+
// closed its sockets, host lease, and HTTP server. Process exit is the
|
|
2603
|
+
// requester's completion proof; do not publish a premature completed ACK.
|
|
2604
|
+
await new Promise(() => undefined);
|
|
2605
|
+
});
|
|
1977
2606
|
startHubProcess();
|
|
1978
2607
|
updatePollId = setInterval(pollUpdateRequest, HUB_UPDATE_POLL_MS);
|
|
1979
2608
|
|
|
@@ -2005,7 +2634,8 @@ async function runManager(args, resolvedRole = null) {
|
|
|
2005
2634
|
}
|
|
2006
2635
|
}
|
|
2007
2636
|
|
|
2008
|
-
async function runClient(args, resolvedRole = null) {
|
|
2637
|
+
async function runClient(args, resolvedRole = null, runtimeLock = null) {
|
|
2638
|
+
const runtimeOwner = runtimeLock?.payload || null;
|
|
2009
2639
|
const internalClientEntry = resolve(packageRoot, 'client', 'bin', 'livedesk-client.js');
|
|
2010
2640
|
if (!existsSync(internalClientEntry)) {
|
|
2011
2641
|
throw new Error('The published livedesk package is missing its internal Client runtime. Rebuild the package with npm run build.');
|
|
@@ -2030,14 +2660,35 @@ async function runClient(args, resolvedRole = null) {
|
|
|
2030
2660
|
? '1'
|
|
2031
2661
|
: String(process.env.LIVEDESK_SKIP_BROWSER_OPEN || ''),
|
|
2032
2662
|
LIVEDESK_DEVICE_ID: String(resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || ''),
|
|
2033
|
-
LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
|
|
2034
|
-
|
|
2663
|
+
LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || ''),
|
|
2664
|
+
LIVEDESK_RUNTIME_OWNER_PID: String(runtimeOwner?.pid || ''),
|
|
2665
|
+
LIVEDESK_RUNTIME_OWNER_TOKEN: String(runtimeOwner?.ownerToken || ''),
|
|
2666
|
+
LIVEDESK_RUNTIME_OWNER_INSTANCE_MARKER: String(runtimeOwner?.ownerInstanceMarker || ''),
|
|
2667
|
+
LIVEDESK_RUNTIME_OWNER_START_ORDER: String(runtimeOwner?.ownerStartOrder || '')
|
|
2668
|
+
});
|
|
2035
2669
|
const clientModule = await import(pathToFileURL(clientEntry).href);
|
|
2036
|
-
if (typeof clientModule.runClientRuntime !== 'function') {
|
|
2037
|
-
throw new Error('The unified Client runtime module does not expose runClientRuntime().');
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
|
|
2670
|
+
if (typeof clientModule.runClientRuntime !== 'function') {
|
|
2671
|
+
throw new Error('The unified Client runtime module does not expose runClientRuntime().');
|
|
2672
|
+
}
|
|
2673
|
+
const runtimeShutdownWatcher = startOwnedRuntimeShutdownWatcher(runtimeOwner, async request => {
|
|
2674
|
+
console.log(
|
|
2675
|
+
`[LiveDesk Client] Cooperative replacement request accepted `
|
|
2676
|
+
+ `request=${request.requestId} reason=${request.reason}. Draining the owned capture tree.`
|
|
2677
|
+
);
|
|
2678
|
+
if (typeof clientModule.requestClientRuntimeShutdown !== 'function') {
|
|
2679
|
+
throw new Error('The unified Client runtime does not expose direct graceful shutdown.');
|
|
2680
|
+
}
|
|
2681
|
+
clientModule.requestClientRuntimeShutdown('SIGTERM');
|
|
2682
|
+
// installAgentTerminationHandlers owns the terminal cleanup contract and
|
|
2683
|
+
// exits only after RemoteFast/Node plus SCK/FFmpeg descendants are gone.
|
|
2684
|
+
await new Promise(() => undefined);
|
|
2685
|
+
});
|
|
2686
|
+
try {
|
|
2687
|
+
await clientModule.runClientRuntime(clientArgs);
|
|
2688
|
+
} finally {
|
|
2689
|
+
runtimeShutdownWatcher?.stop();
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2041
2692
|
|
|
2042
2693
|
async function runDesktopRoleBootstrap() {
|
|
2043
2694
|
const bootstrap = createRoleBootstrapServer({
|
|
@@ -2131,6 +2782,9 @@ async function main() {
|
|
|
2131
2782
|
if (topLevel.noSingleInstance || process.env.LIVEDESK_DISABLE_SINGLE_INSTANCE === '1') {
|
|
2132
2783
|
console.warn('[LiveDesk] Single-instance lock disabled for this run.');
|
|
2133
2784
|
}
|
|
2785
|
+
const launcherProcessDetails = await getProcessDetails(process.pid);
|
|
2786
|
+
const launcherInstanceMarker = processInstanceMarker(launcherProcessDetails);
|
|
2787
|
+
const launcherStartOrder = String(launcherProcessDetails?.startOrder || '').trim();
|
|
2134
2788
|
const reportExistingRuntime = existing => {
|
|
2135
2789
|
const port = DEFAULT_MANAGER_HTTP_PORT;
|
|
2136
2790
|
openBrowser(`http://127.0.0.1:${port}`);
|
|
@@ -2141,16 +2795,22 @@ async function main() {
|
|
|
2141
2795
|
: acquireRuntimeLock({
|
|
2142
2796
|
stateDir: MANAGER_STATE_DIR,
|
|
2143
2797
|
role: resolvedRole.role,
|
|
2798
|
+
ownerInstanceMarker: launcherInstanceMarker,
|
|
2799
|
+
ownerStartOrder: launcherStartOrder,
|
|
2144
2800
|
openExisting: () => undefined
|
|
2145
2801
|
});
|
|
2802
|
+
let replacementCleanup = null;
|
|
2146
2803
|
|
|
2147
2804
|
const sameRoleRuntimeIsRunning = !lock.acquired
|
|
2148
2805
|
&& String(lock.existing?.role || '').trim().toLowerCase() === String(resolvedRole.role || '').trim().toLowerCase();
|
|
2149
2806
|
if (!lock.acquired && sameRoleRuntimeIsRunning && !topLevel.noRestartExisting) {
|
|
2150
|
-
await restartExistingRuntime(lock.existing, resolvedRole.role);
|
|
2807
|
+
replacementCleanup = await restartExistingRuntime(lock.existing, resolvedRole.role);
|
|
2151
2808
|
lock = acquireRuntimeLock({
|
|
2152
2809
|
stateDir: MANAGER_STATE_DIR,
|
|
2153
2810
|
role: resolvedRole.role,
|
|
2811
|
+
ownerInstanceMarker: launcherInstanceMarker,
|
|
2812
|
+
ownerStartOrder: launcherStartOrder,
|
|
2813
|
+
staleOwnerProof: replacementCleanup?.staleOwnerProof || null,
|
|
2154
2814
|
openExisting: () => undefined
|
|
2155
2815
|
});
|
|
2156
2816
|
}
|
|
@@ -2162,6 +2822,8 @@ async function main() {
|
|
|
2162
2822
|
lock = acquireRuntimeLock({
|
|
2163
2823
|
stateDir: MANAGER_STATE_DIR,
|
|
2164
2824
|
role: resolvedRole.role,
|
|
2825
|
+
ownerInstanceMarker: launcherInstanceMarker,
|
|
2826
|
+
ownerStartOrder: launcherStartOrder,
|
|
2165
2827
|
openExisting: () => undefined
|
|
2166
2828
|
});
|
|
2167
2829
|
}
|
|
@@ -2172,6 +2834,14 @@ async function main() {
|
|
|
2172
2834
|
}
|
|
2173
2835
|
process.once('exit', lock.release);
|
|
2174
2836
|
|
|
2837
|
+
// Only the contender that won the replacement lock may clean residual
|
|
2838
|
+
// listener ports. A simultaneous losing launcher must never kill the newly
|
|
2839
|
+
// acquired replacement generation.
|
|
2840
|
+
if (replacementCleanup && resolvedRole.role === 'hub') {
|
|
2841
|
+
const remotePort = normalizePort(process.env.REMOTE_HUB_PORT, DEFAULT_REMOTE_HUB_PORT);
|
|
2842
|
+
await stopProcessesOnPorts([replacementCleanup.runtimePort, remotePort]);
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2175
2845
|
if (resolvedRole.role === 'client') {
|
|
2176
2846
|
const clientRuntimePort = normalizePort(
|
|
2177
2847
|
process.env.LIVEDESK_CLIENT_RUNTIME_PORT || process.env.LIVEDESK_LOCAL_RUNTIME_PORT,
|
|
@@ -2192,7 +2862,7 @@ async function main() {
|
|
|
2192
2862
|
printClientHelp();
|
|
2193
2863
|
return;
|
|
2194
2864
|
}
|
|
2195
|
-
await runClient(runtimeArgs, resolvedRole);
|
|
2865
|
+
await runClient(runtimeArgs, resolvedRole, lock);
|
|
2196
2866
|
return;
|
|
2197
2867
|
}
|
|
2198
2868
|
if (command === 'hub' || command === 'manager') {
|
|
@@ -2200,14 +2870,14 @@ async function main() {
|
|
|
2200
2870
|
printHelp();
|
|
2201
2871
|
return;
|
|
2202
2872
|
}
|
|
2203
|
-
await runManager(runtimeArgs, resolvedRole);
|
|
2873
|
+
await runManager(runtimeArgs, resolvedRole, lock);
|
|
2204
2874
|
return;
|
|
2205
2875
|
}
|
|
2206
2876
|
if (resolvedRole.role === 'client') {
|
|
2207
|
-
await runClient(runtimeArgs, resolvedRole);
|
|
2877
|
+
await runClient(runtimeArgs, resolvedRole, lock);
|
|
2208
2878
|
return;
|
|
2209
2879
|
}
|
|
2210
|
-
await runManager(runtimeArgs, resolvedRole);
|
|
2880
|
+
await runManager(runtimeArgs, resolvedRole, lock);
|
|
2211
2881
|
}
|
|
2212
2882
|
|
|
2213
2883
|
main().catch(error => {
|