livedesk 0.1.446 → 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 +899 -309
- package/bootstrap/legacy-client-update.js +22 -20
- package/bootstrap/runtime-lock.js +233 -28
- package/bootstrap/runtime-shutdown.js +414 -0
- package/client/bin/livedesk-client-node.js +17 -4
- package/client/bin/livedesk-client.js +70 -4
- package/client/package.json +5 -5
- package/client/src/runtime/agent-process-lifecycle.js +113 -40
- 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/transport/relay-hub-control.js +48 -7
- 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/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));
|
|
@@ -710,6 +715,7 @@ async function getProcessDetails(pid) {
|
|
|
710
715
|
executablePath = '';
|
|
711
716
|
}
|
|
712
717
|
}
|
|
718
|
+
const nativeStartOrder = readLinuxProcessStartMarker(processId) || startResult.stdout;
|
|
713
719
|
return {
|
|
714
720
|
processId,
|
|
715
721
|
parentProcessId: Number(parentResult.stdout || 0),
|
|
@@ -717,7 +723,8 @@ async function getProcessDetails(pid) {
|
|
|
717
723
|
commandLine: commandResult.stdout,
|
|
718
724
|
executablePath,
|
|
719
725
|
processGroupId: Number(groupResult.stdout || 0),
|
|
720
|
-
startMarker:
|
|
726
|
+
startMarker: nativeStartOrder,
|
|
727
|
+
startOrder: nativeStartOrder
|
|
721
728
|
};
|
|
722
729
|
}
|
|
723
730
|
|
|
@@ -796,77 +803,490 @@ async function waitForProcessExit(pid, timeoutMs = EXISTING_RUNTIME_STOP_TIMEOUT
|
|
|
796
803
|
throw new Error(`Existing LiveDesk runtime ${processId} did not exit before the restart deadline.`);
|
|
797
804
|
}
|
|
798
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
|
+
|
|
799
1041
|
async function stopExistingRuntime(existing) {
|
|
800
1042
|
const processId = Number(existing?.pid);
|
|
801
|
-
if (!Number.isInteger(processId) || processId <= 1
|
|
1043
|
+
if (!Number.isInteger(processId) || processId <= 1) {
|
|
802
1044
|
throw new Error('Existing LiveDesk runtime has an invalid process id; it was preserved.');
|
|
803
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
|
+
}
|
|
804
1066
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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 };
|
|
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
|
+
);
|
|
808
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
|
+
|
|
809
1140
|
const commandLineConfirmed = isKnownLiveDeskLauncherProcess(details.processName, details.commandLine);
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
&&
|
|
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'
|
|
815
1149
|
&& await hasKnownLiveDeskClientAgentDescendant(processId);
|
|
816
1150
|
const runtimeProof = commandLineConfirmed || lockInstanceConfirmed || legacyClientTreeConfirmed
|
|
817
1151
|
? null
|
|
818
1152
|
: await probeExistingRuntimeIdentity(existing);
|
|
819
|
-
|
|
1153
|
+
const cooperativeOwnerConfirmed = cooperativeStop.acknowledged === true;
|
|
1154
|
+
if (!commandLineConfirmed
|
|
1155
|
+
&& !lockInstanceConfirmed
|
|
1156
|
+
&& !legacyClientTreeConfirmed
|
|
1157
|
+
&& !runtimeProof
|
|
1158
|
+
&& !cooperativeOwnerConfirmed) {
|
|
820
1159
|
throw new Error(`Existing runtime pid ${processId} is not a confirmed LiveDesk launcher; it was preserved.`);
|
|
821
1160
|
}
|
|
822
1161
|
|
|
823
|
-
const identityProof =
|
|
824
|
-
?
|
|
825
|
-
:
|
|
826
|
-
? '
|
|
827
|
-
:
|
|
828
|
-
? '
|
|
829
|
-
:
|
|
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
|
+
}
|
|
830
1224
|
console.log(`[LiveDesk] Stopping existing ${existing?.role || 'runtime'} launcher pid ${processId} before starting a fresh runtime (identity=${identityProof}).`);
|
|
831
1225
|
if (os.platform() === 'win32') {
|
|
832
|
-
await
|
|
1226
|
+
await terminateExactWindowsProcessTree(details, {
|
|
1227
|
+
label: `existing LiveDesk ${existing?.role || 'runtime'} launcher pid=${processId}`,
|
|
1228
|
+
additionalRecords: inheritedWindowsProcessRecords
|
|
1229
|
+
});
|
|
833
1230
|
} else {
|
|
834
1231
|
try { process.kill(processId, 'SIGTERM'); } catch (error) {
|
|
835
1232
|
if (error?.code !== 'ESRCH') throw error;
|
|
836
1233
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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);
|
|
848
1245
|
}
|
|
849
|
-
await waitForProcessExit(processId, 2000);
|
|
850
1246
|
}
|
|
851
|
-
return runtimeProof;
|
|
1247
|
+
return { runtimeProof, staleOwnerProof: null };
|
|
852
1248
|
}
|
|
853
1249
|
|
|
854
1250
|
async function restartExistingRuntime(existing, role) {
|
|
855
|
-
const
|
|
1251
|
+
const stopResult = await stopExistingRuntime(existing);
|
|
1252
|
+
const runtimeProof = stopResult?.runtimeProof || null;
|
|
856
1253
|
const runtimePort = Number(runtimeProof?.port || existingRuntimeProbePorts()[0] || DEFAULT_MANAGER_HTTP_PORT);
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
1254
|
+
return {
|
|
1255
|
+
runtimePort,
|
|
1256
|
+
staleOwnerProof: stopResult?.staleOwnerProof || null,
|
|
1257
|
+
role: role || existing?.role || ''
|
|
1258
|
+
};
|
|
863
1259
|
}
|
|
864
1260
|
|
|
865
|
-
function waitMilliseconds(milliseconds) {
|
|
866
|
-
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
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') {
|
|
870
1290
|
return new Promise(resolve => {
|
|
871
1291
|
const server = net.createServer();
|
|
872
1292
|
let settled = false;
|
|
@@ -913,7 +1333,7 @@ function isKnownLiveDeskProcess(processName, commandLine) {
|
|
|
913
1333
|
return false;
|
|
914
1334
|
}
|
|
915
1335
|
|
|
916
|
-
const isKnownHubServer = /(?:
|
|
1336
|
+
const isKnownHubServer = /(?:packages\/(?:livedesk\/)?hub|node_modules\/@?livedesk\/hub)\/src\/server\.js/i.test(normalizedCommandLine);
|
|
917
1337
|
const isUnifiedLauncher = /(?:node_modules\/livedesk|packages\/livedesk)\/bin\/livedesk\.js/i.test(normalizedCommandLine);
|
|
918
1338
|
const isLegacyClient = /(?:node_modules\/@livedesk\/client|packages\/client|packages\/livedesk\/client)\/bin\/livedesk-client\.js/i.test(normalizedCommandLine);
|
|
919
1339
|
return isKnownHubServer || isUnifiedLauncher || isLegacyClient;
|
|
@@ -927,7 +1347,7 @@ function formatPortOwner(record) {
|
|
|
927
1347
|
return `${record?.Pid || 'unknown'}:${record?.ProcessName || 'unknown'} cmd=${String(record?.CommandLine || 'unavailable').replace(/\s+/g, ' ').slice(0, 240)}`;
|
|
928
1348
|
}
|
|
929
1349
|
|
|
930
|
-
function assertPortCleanupReady(finalRecords, portStates) {
|
|
1350
|
+
function assertPortCleanupReady(finalRecords, portStates) {
|
|
931
1351
|
const blockedPorts = portStates.filter(port => port?.Ready !== true);
|
|
932
1352
|
if (blockedPorts.length === 0) {
|
|
933
1353
|
return;
|
|
@@ -939,10 +1359,118 @@ function assertPortCleanupReady(finalRecords, portStates) {
|
|
|
939
1359
|
.map(formatPortOwner);
|
|
940
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'}`;
|
|
941
1361
|
}).join('], ');
|
|
942
|
-
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.`);
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
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) {
|
|
946
1474
|
const records = [];
|
|
947
1475
|
for (const port of ports) {
|
|
948
1476
|
const lsof = await runQuiet('lsof', ['-nP', '-ti', `tcp:${port}`, '-sTCP:LISTEN']);
|
|
@@ -1216,121 +1744,12 @@ async function stopStaleClientAgents() {
|
|
|
1216
1744
|
}
|
|
1217
1745
|
};
|
|
1218
1746
|
|
|
1219
|
-
const
|
|
1220
|
-
const script = '$ErrorActionPreference = "Stop"; @(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId } }) | ConvertTo-Json -Compress';
|
|
1221
|
-
const result = await runQuiet(
|
|
1222
|
-
'powershell.exe',
|
|
1223
|
-
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
1224
|
-
{ timeout: 10000, killSignal: 'SIGKILL' }
|
|
1225
|
-
);
|
|
1226
|
-
if (!result.ok || !result.stdout) {
|
|
1227
|
-
throw new Error(
|
|
1228
|
-
`LiveDesk Client lifecycle audit could not snapshot the Windows process tree: `
|
|
1229
|
-
+ `${result.stderr || result.error?.message || 'CIM query failed'}`
|
|
1230
|
-
);
|
|
1231
|
-
}
|
|
1232
|
-
const parsed = JSON.parse(result.stdout);
|
|
1233
|
-
return (Array.isArray(parsed) ? parsed : [parsed]).map(item => ({
|
|
1234
|
-
pid: Number(item?.ProcessId || 0),
|
|
1235
|
-
parentPid: Number(item?.ParentProcessId || 0)
|
|
1236
|
-
}));
|
|
1237
|
-
};
|
|
1238
|
-
|
|
1239
|
-
const captureWindowsTreeRecords = async rootRecord => {
|
|
1240
|
-
const pairs = await enumerateWindowsParentPairs();
|
|
1241
|
-
const descendantsByParent = new Map();
|
|
1242
|
-
for (const pair of pairs) {
|
|
1243
|
-
if (!descendantsByParent.has(pair.parentPid)) descendantsByParent.set(pair.parentPid, []);
|
|
1244
|
-
descendantsByParent.get(pair.parentPid).push(pair.pid);
|
|
1245
|
-
}
|
|
1246
|
-
const processIds = [];
|
|
1247
|
-
const queue = [rootRecord.pid];
|
|
1248
|
-
const seen = new Set();
|
|
1249
|
-
while (queue.length > 0) {
|
|
1250
|
-
const pid = queue.shift();
|
|
1251
|
-
if (!Number.isInteger(pid) || pid <= 1 || seen.has(pid)) continue;
|
|
1252
|
-
seen.add(pid);
|
|
1253
|
-
processIds.push(pid);
|
|
1254
|
-
queue.push(...(descendantsByParent.get(pid) || []));
|
|
1255
|
-
}
|
|
1256
|
-
const records = [];
|
|
1257
|
-
for (const pid of processIds) {
|
|
1258
|
-
const details = await getProcessDetails(pid);
|
|
1259
|
-
if (!details) continue;
|
|
1260
|
-
const instanceMarker = processInstanceMarker(details);
|
|
1261
|
-
if (!instanceMarker) {
|
|
1262
|
-
throw new Error(`Windows process-tree verification could not establish a start marker for pid=${pid}.`);
|
|
1263
|
-
}
|
|
1264
|
-
if (pid !== rootRecord.pid && rootRecord.startOrder && details.startOrder) {
|
|
1265
|
-
const rootStart = BigInt(rootRecord.startOrder);
|
|
1266
|
-
const descendantStart = BigInt(details.startOrder);
|
|
1267
|
-
if (descendantStart < rootStart) {
|
|
1268
|
-
throw new Error(
|
|
1269
|
-
`Windows process-tree verification found pid=${pid} with reused parent pid=${rootRecord.pid}; `
|
|
1270
|
-
+ 'the older process was preserved instead of passing an ambiguous tree to taskkill.'
|
|
1271
|
-
);
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
records.push(pid === rootRecord.pid
|
|
1275
|
-
? rootRecord
|
|
1276
|
-
: {
|
|
1277
|
-
pid,
|
|
1278
|
-
type: 'descendant',
|
|
1279
|
-
instanceMarker,
|
|
1280
|
-
startOrder: String(details.startOrder || '')
|
|
1281
|
-
});
|
|
1282
|
-
}
|
|
1283
|
-
if (!records.some(record => (
|
|
1284
|
-
record.pid === rootRecord.pid && record.instanceMarker === rootRecord.instanceMarker
|
|
1285
|
-
))) {
|
|
1286
|
-
return [];
|
|
1287
|
-
}
|
|
1288
|
-
return records;
|
|
1289
|
-
};
|
|
1290
|
-
|
|
1291
|
-
const taskkillExactWindowsRecord = async (record, includeTree) => {
|
|
1747
|
+
const terminateWindowsAgentTree = async record => {
|
|
1292
1748
|
const details = await getMatchingDetails(record);
|
|
1293
1749
|
if (!details) return;
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
'$ErrorActionPreference = "Stop";',
|
|
1298
|
-
`$targetPid = ${record.pid};`,
|
|
1299
|
-
`$expectedStart = '${expectedStart}';`,
|
|
1300
|
-
'$target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
1301
|
-
'if (-not $target) { exit 0 };',
|
|
1302
|
-
'if ([string]$target.CreationDate -ne $expectedStart) { exit 3 };',
|
|
1303
|
-
`$arguments = @("/PID", [string]$targetPid${treeArgument}, "/F");`,
|
|
1304
|
-
'$taskkill = Start-Process -FilePath "taskkill.exe" -ArgumentList $arguments -NoNewWindow -Wait -PassThru;',
|
|
1305
|
-
'exit $taskkill.ExitCode'
|
|
1306
|
-
].join(' ');
|
|
1307
|
-
await runQuiet(
|
|
1308
|
-
'powershell.exe',
|
|
1309
|
-
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
1310
|
-
{ timeout: 15000, killSignal: 'SIGKILL' }
|
|
1311
|
-
);
|
|
1312
|
-
};
|
|
1313
|
-
|
|
1314
|
-
const terminateWindowsAgentTree = async record => {
|
|
1315
|
-
if (!await getMatchingDetails(record)) return;
|
|
1316
|
-
const treeRecords = await captureWindowsTreeRecords(record);
|
|
1317
|
-
if (treeRecords.length === 0) return;
|
|
1318
|
-
await taskkillExactWindowsRecord(record, true);
|
|
1319
|
-
let remaining = await waitForExactRecordsExit(treeRecords, 4000);
|
|
1320
|
-
if (remaining.length > 0) {
|
|
1321
|
-
// These are immutable pre-taskkill descendants of the exact Agent tree,
|
|
1322
|
-
// not globally matched ffmpeg processes.
|
|
1323
|
-
for (const descendant of [...remaining].reverse()) {
|
|
1324
|
-
await taskkillExactWindowsRecord(descendant, true);
|
|
1325
|
-
}
|
|
1326
|
-
remaining = await waitForExactRecordsExit(remaining, 4000);
|
|
1327
|
-
}
|
|
1328
|
-
if (remaining.length > 0) {
|
|
1329
|
-
throw new Error(
|
|
1330
|
-
`verified stale Windows Agent tree pid=${record.pid} retained exact descendant(s): `
|
|
1331
|
-
+ remaining.map(item => item.pid).join(', ')
|
|
1332
|
-
);
|
|
1333
|
-
}
|
|
1750
|
+
await terminateExactWindowsProcessTree(details, {
|
|
1751
|
+
label: `verified stale Windows Agent tree pid=${record.pid}`
|
|
1752
|
+
});
|
|
1334
1753
|
};
|
|
1335
1754
|
|
|
1336
1755
|
const stoppedProcesses = new Map();
|
|
@@ -1376,118 +1795,17 @@ async function stopStaleClientAgents() {
|
|
|
1376
1795
|
|
|
1377
1796
|
async function stopProcessesOnPorts(ports) {
|
|
1378
1797
|
const uniquePorts = [...new Set(ports.map(port => normalizePort(port, 0)).filter(Boolean))];
|
|
1379
|
-
if (uniquePorts.length === 0) {
|
|
1380
|
-
return;
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
if (os.platform() === 'win32') {
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
' if ($connections.Count -gt 0) { return $connections }',
|
|
1391
|
-
' $pattern = "^\\s*TCP\\s+\\S+:" + $port + "\\s+\\S+\\s+LISTENING\\s+(\\d+)\\s*$";',
|
|
1392
|
-
' foreach ($line in @(netstat -ano -p tcp)) {',
|
|
1393
|
-
' $match = [regex]::Match([string]$line, $pattern);',
|
|
1394
|
-
' if ($match.Success) {',
|
|
1395
|
-
' [pscustomobject]@{ LocalPort = [int]$port; OwningProcess = [int]$match.Groups[1].Value }',
|
|
1396
|
-
' }',
|
|
1397
|
-
' }',
|
|
1398
|
-
'}',
|
|
1399
|
-
'function Get-PortRecords($port) {',
|
|
1400
|
-
' $connections = @(Get-ListenConnections $port | Where-Object { $_.OwningProcess -and $_.OwningProcess -ne $currentNodePid -and $_.OwningProcess -ne $PID });',
|
|
1401
|
-
' foreach ($connection in $connections) {',
|
|
1402
|
-
' $owner = [int]$connection.OwningProcess;',
|
|
1403
|
-
' $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $owner" -ErrorAction SilentlyContinue;',
|
|
1404
|
-
' $processName = if ($proc) { [string]$proc.Name } else { "" };',
|
|
1405
|
-
' $commandLine = if ($proc) { [string]$proc.CommandLine } else { "" };',
|
|
1406
|
-
' $isNodeProcess = $processName -match "(?i)^node(?:\\.exe)?$";',
|
|
1407
|
-
' $hasKnownLiveDeskHubPath = $commandLine -match "(?i)(?:packages[\\\\/](?:livedesk[\\\\/])?hub|node_modules[\\\\/]@?livedesk[\\\\/]hub)[\\\\/]src[\\\\/]server\\.js";',
|
|
1408
|
-
' $hasUnifiedLauncherPath = $commandLine -match "(?i)(?:node_modules[\\\\/]livedesk|packages[\\\\/]livedesk)[\\\\/]bin[\\\\/]livedesk\\.js";',
|
|
1409
|
-
' $hasLegacyClientPath = $commandLine -match "(?i)(?:node_modules[\\\\/]@livedesk[\\\\/]client|packages[\\\\/]client|packages[\\\\/]livedesk[\\\\/]client)[\\\\/]bin[\\\\/]livedesk-client\\.js";',
|
|
1410
|
-
' $knownLiveDesk = [bool]($isNodeProcess -and ($hasKnownLiveDeskHubPath -or $hasUnifiedLauncherPath -or $hasLegacyClientPath));',
|
|
1411
|
-
' [pscustomobject]@{ Port = [int]$port; Pid = $owner; ProcessName = $processName; CommandLine = $commandLine; KnownLiveDesk = $knownLiveDesk; Action = "preserved"; KillAttempted = $false }',
|
|
1412
|
-
' }',
|
|
1413
|
-
'}',
|
|
1414
|
-
'$records = @($ports | ForEach-Object { Get-PortRecords $_ } | Sort-Object Port,Pid -Unique);',
|
|
1415
|
-
'$killableOwners = @($records | Where-Object { $_.KnownLiveDesk } | Group-Object Pid | ForEach-Object { @($_.Group)[0] });',
|
|
1416
|
-
'foreach ($owner in $killableOwners) {',
|
|
1417
|
-
' & taskkill.exe /PID $owner.Pid /T /F | Out-Null;',
|
|
1418
|
-
' Stop-Process -Id $owner.Pid -Force -ErrorAction SilentlyContinue;',
|
|
1419
|
-
' foreach ($record in @($records | Where-Object { [int]$_.Pid -eq [int]$owner.Pid })) {',
|
|
1420
|
-
' $record.Action = "stopped";',
|
|
1421
|
-
' $record.KillAttempted = $true;',
|
|
1422
|
-
' }',
|
|
1423
|
-
'}',
|
|
1424
|
-
`$deadline = (Get-Date).ToUniversalTime().AddMilliseconds(${PORT_CLEANUP_TIMEOUT_MS});`,
|
|
1425
|
-
'$finalRecords = @();',
|
|
1426
|
-
'$portStates = @();',
|
|
1427
|
-
'do {',
|
|
1428
|
-
' $finalRecords = @($ports | ForEach-Object { Get-PortRecords $_ });',
|
|
1429
|
-
' $busyPorts = @($finalRecords | Select-Object -ExpandProperty Port -Unique);',
|
|
1430
|
-
' $portStates = @($ports | ForEach-Object {',
|
|
1431
|
-
' $port = [int]$_;',
|
|
1432
|
-
' $busy = $busyPorts -contains $port;',
|
|
1433
|
-
' $bindable = [bool](-not $busy);',
|
|
1434
|
-
' [pscustomobject]@{ Port = $port; PortAvailable = [bool](-not $busy); PortBindable = $bindable; Ready = [bool](-not $busy) }',
|
|
1435
|
-
' });',
|
|
1436
|
-
' if ((@($portStates | Where-Object { -not $_.Ready }).Count -eq 0) -or (Get-Date).ToUniversalTime() -ge $deadline) { break }',
|
|
1437
|
-
` Start-Sleep -Milliseconds ${PORT_CLEANUP_POLL_MS};`,
|
|
1438
|
-
'} while ($true);',
|
|
1439
|
-
'[pscustomobject]@{ Records = @($records); FinalRecords = @($finalRecords); Ports = @($portStates) } | ConvertTo-Json -Compress -Depth 8'
|
|
1440
|
-
].join(' ');
|
|
1441
|
-
const result = await runQuiet('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script]);
|
|
1442
|
-
if (!result.ok && !result.stdout) {
|
|
1443
|
-
throw new Error(`Could not inspect existing LiveDesk Hub ports: ${result.stderr || result.error?.message || 'PowerShell failed'}`);
|
|
1444
|
-
}
|
|
1445
|
-
let payload = { Records: [], FinalRecords: [], Ports: [] };
|
|
1446
|
-
if (result.stdout) {
|
|
1447
|
-
try {
|
|
1448
|
-
const parsed = JSON.parse(result.stdout);
|
|
1449
|
-
payload = Array.isArray(parsed)
|
|
1450
|
-
? { Records: parsed, FinalRecords: parsed, Ports: [] }
|
|
1451
|
-
: parsed;
|
|
1452
|
-
} catch {
|
|
1453
|
-
throw new Error(`Could not parse existing LiveDesk Hub port state: ${result.stdout}`);
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
const records = Array.isArray(payload?.Records) ? payload.Records : [];
|
|
1457
|
-
const finalRecords = Array.isArray(payload?.FinalRecords) ? payload.FinalRecords : [];
|
|
1458
|
-
const rawPortStates = Array.isArray(payload?.Ports) ? payload.Ports : [];
|
|
1459
|
-
const rawStatesByPort = new Map(rawPortStates.map(state => [Number(state?.Port), state]));
|
|
1460
|
-
const portStates = [];
|
|
1461
|
-
for (const port of uniquePorts) {
|
|
1462
|
-
const rawState = rawStatesByPort.get(Number(port)) || {};
|
|
1463
|
-
const probe = rawState.PortAvailable === true
|
|
1464
|
-
? await waitForPortBindable(port)
|
|
1465
|
-
: { ok: false, error: 'port-in-use' };
|
|
1466
|
-
portStates.push({
|
|
1467
|
-
...rawState,
|
|
1468
|
-
Port: Number(port),
|
|
1469
|
-
PortBindable: probe.ok,
|
|
1470
|
-
BindError: probe.error || '',
|
|
1471
|
-
Ready: rawState.PortAvailable === true && probe.ok
|
|
1472
|
-
});
|
|
1473
|
-
}
|
|
1474
|
-
const stopped = records.filter(record => record?.Action === 'stopped');
|
|
1475
|
-
if (stopped.length > 0) {
|
|
1476
|
-
const stoppedOwners = new Map();
|
|
1477
|
-
for (const record of stopped) {
|
|
1478
|
-
if (!stoppedOwners.has(String(record.Pid))) {
|
|
1479
|
-
stoppedOwners.set(String(record.Pid), record);
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
const summary = [...stoppedOwners.values()].map(record => `${record.Pid}:${record.ProcessName || 'node'}`).join(', ');
|
|
1483
|
-
console.log(`Restarting LiveDesk Hub. Stopped stale LiveDesk owner(s) once: ${summary}`);
|
|
1484
|
-
}
|
|
1485
|
-
assertPortCleanupReady(finalRecords, portStates);
|
|
1486
|
-
return;
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
await stopUnixProcessesOnPorts(uniquePorts);
|
|
1490
|
-
}
|
|
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
|
+
}
|
|
1491
1809
|
|
|
1492
1810
|
async function waitForManager(url = DEFAULT_MANAGER_URL, timeoutMs = 10000) {
|
|
1493
1811
|
const startedAt = Date.now();
|
|
@@ -1579,6 +1897,13 @@ function buildHubRestartBootstrapScript() {
|
|
|
1579
1897
|
'const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
|
|
1580
1898
|
'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
|
|
1581
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(", ")}`); };',
|
|
1582
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.`); };',
|
|
1583
1908
|
'const decodeArgs = name => JSON.parse(Buffer.from(process.env[name] || "", "base64").toString("utf8"));',
|
|
1584
1909
|
'const primary = { command: process.env.LIVEDESK_RESTART_COMMAND || "", args: decodeArgs("LIVEDESK_RESTART_ARGS_BASE64") };',
|
|
@@ -1601,14 +1926,118 @@ function buildHubRestartBootstrapScript() {
|
|
|
1601
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); }); });',
|
|
1602
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 || "") }; };',
|
|
1603
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; };',
|
|
1604
|
-
'const terminateTree = async (child, runtimePid = 0) => {
|
|
1605
|
-
'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
|
+
'};',
|
|
1606
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; });',
|
|
1607
2035
|
''
|
|
1608
2036
|
].join('\n');
|
|
1609
2037
|
}
|
|
1610
2038
|
|
|
1611
|
-
async function runManager(args, resolvedRole = null) {
|
|
2039
|
+
async function runManager(args, resolvedRole = null, runtimeLock = null) {
|
|
2040
|
+
const runtimeOwner = runtimeLock?.payload || null;
|
|
1612
2041
|
const options = parseManagerArgs(args);
|
|
1613
2042
|
const httpPort = normalizePort(
|
|
1614
2043
|
options.port || process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT,
|
|
@@ -1697,6 +2126,91 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1697
2126
|
let updatePollId = null;
|
|
1698
2127
|
let updateStopTimer = null;
|
|
1699
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
|
+
}
|
|
1700
2214
|
|
|
1701
2215
|
const restartWithLatest = async request => {
|
|
1702
2216
|
const requestedVersion = normalizeExactUpdateVersion(request?.latestVersion);
|
|
@@ -1892,13 +2406,16 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1892
2406
|
try { updateChild.kill('SIGTERM'); } catch { /* child already exited */ }
|
|
1893
2407
|
updateHardStopTimer = setTimeout(() => {
|
|
1894
2408
|
if (!updateChild || updateChild.exitCode !== null || updateChild.signalCode) return;
|
|
1895
|
-
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);
|
|
1896
2410
|
if (process.platform === 'win32') {
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
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);
|
|
1900
2417
|
}
|
|
1901
|
-
}
|
|
2418
|
+
}
|
|
1902
2419
|
} else {
|
|
1903
2420
|
try { updateChild.kill('SIGKILL'); } catch { /* child already exited */ }
|
|
1904
2421
|
}
|
|
@@ -1952,6 +2469,17 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1952
2469
|
});
|
|
1953
2470
|
child.once('exit', (code, signal) => {
|
|
1954
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
|
+
}
|
|
1955
2483
|
if (restartRequest) {
|
|
1956
2484
|
const request = restartRequest;
|
|
1957
2485
|
restartRequest = null;
|
|
@@ -2002,23 +2530,38 @@ async function runManager(args, resolvedRole = null) {
|
|
|
2002
2530
|
});
|
|
2003
2531
|
return;
|
|
2004
2532
|
}
|
|
2005
|
-
if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
|
|
2533
|
+
if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
|
|
2006
2534
|
if (updatePollId) {
|
|
2007
2535
|
clearInterval(updatePollId);
|
|
2008
2536
|
updatePollId = null;
|
|
2009
2537
|
}
|
|
2010
|
-
|
|
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({
|
|
2011
2552
|
role: 'client',
|
|
2012
2553
|
deviceId: resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || '',
|
|
2013
2554
|
deviceName: resolvedRole?.deviceName || os.hostname(),
|
|
2014
2555
|
source: 'supabase',
|
|
2015
2556
|
isOfflineFallback: false
|
|
2016
|
-
}, { stateDir: MANAGER_STATE_DIR });
|
|
2017
|
-
console.log('[LiveDesk Hub] Role transition accepted. Starting the Client runtime.');
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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
|
+
});
|
|
2022
2565
|
return;
|
|
2023
2566
|
}
|
|
2024
2567
|
if (!signal
|
|
@@ -2047,8 +2590,19 @@ async function runManager(args, resolvedRole = null) {
|
|
|
2047
2590
|
}
|
|
2048
2591
|
process.exit(code ?? 0);
|
|
2049
2592
|
});
|
|
2050
|
-
};
|
|
2051
|
-
|
|
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
|
+
});
|
|
2052
2606
|
startHubProcess();
|
|
2053
2607
|
updatePollId = setInterval(pollUpdateRequest, HUB_UPDATE_POLL_MS);
|
|
2054
2608
|
|
|
@@ -2080,7 +2634,8 @@ async function runManager(args, resolvedRole = null) {
|
|
|
2080
2634
|
}
|
|
2081
2635
|
}
|
|
2082
2636
|
|
|
2083
|
-
async function runClient(args, resolvedRole = null) {
|
|
2637
|
+
async function runClient(args, resolvedRole = null, runtimeLock = null) {
|
|
2638
|
+
const runtimeOwner = runtimeLock?.payload || null;
|
|
2084
2639
|
const internalClientEntry = resolve(packageRoot, 'client', 'bin', 'livedesk-client.js');
|
|
2085
2640
|
if (!existsSync(internalClientEntry)) {
|
|
2086
2641
|
throw new Error('The published livedesk package is missing its internal Client runtime. Rebuild the package with npm run build.');
|
|
@@ -2105,14 +2660,35 @@ async function runClient(args, resolvedRole = null) {
|
|
|
2105
2660
|
? '1'
|
|
2106
2661
|
: String(process.env.LIVEDESK_SKIP_BROWSER_OPEN || ''),
|
|
2107
2662
|
LIVEDESK_DEVICE_ID: String(resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || ''),
|
|
2108
|
-
LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
|
|
2109
|
-
|
|
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
|
+
});
|
|
2110
2669
|
const clientModule = await import(pathToFileURL(clientEntry).href);
|
|
2111
|
-
if (typeof clientModule.runClientRuntime !== 'function') {
|
|
2112
|
-
throw new Error('The unified Client runtime module does not expose runClientRuntime().');
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
|
|
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
|
+
}
|
|
2116
2692
|
|
|
2117
2693
|
async function runDesktopRoleBootstrap() {
|
|
2118
2694
|
const bootstrap = createRoleBootstrapServer({
|
|
@@ -2208,6 +2784,7 @@ async function main() {
|
|
|
2208
2784
|
}
|
|
2209
2785
|
const launcherProcessDetails = await getProcessDetails(process.pid);
|
|
2210
2786
|
const launcherInstanceMarker = processInstanceMarker(launcherProcessDetails);
|
|
2787
|
+
const launcherStartOrder = String(launcherProcessDetails?.startOrder || '').trim();
|
|
2211
2788
|
const reportExistingRuntime = existing => {
|
|
2212
2789
|
const port = DEFAULT_MANAGER_HTTP_PORT;
|
|
2213
2790
|
openBrowser(`http://127.0.0.1:${port}`);
|
|
@@ -2219,17 +2796,21 @@ async function main() {
|
|
|
2219
2796
|
stateDir: MANAGER_STATE_DIR,
|
|
2220
2797
|
role: resolvedRole.role,
|
|
2221
2798
|
ownerInstanceMarker: launcherInstanceMarker,
|
|
2799
|
+
ownerStartOrder: launcherStartOrder,
|
|
2222
2800
|
openExisting: () => undefined
|
|
2223
2801
|
});
|
|
2802
|
+
let replacementCleanup = null;
|
|
2224
2803
|
|
|
2225
2804
|
const sameRoleRuntimeIsRunning = !lock.acquired
|
|
2226
2805
|
&& String(lock.existing?.role || '').trim().toLowerCase() === String(resolvedRole.role || '').trim().toLowerCase();
|
|
2227
2806
|
if (!lock.acquired && sameRoleRuntimeIsRunning && !topLevel.noRestartExisting) {
|
|
2228
|
-
await restartExistingRuntime(lock.existing, resolvedRole.role);
|
|
2807
|
+
replacementCleanup = await restartExistingRuntime(lock.existing, resolvedRole.role);
|
|
2229
2808
|
lock = acquireRuntimeLock({
|
|
2230
2809
|
stateDir: MANAGER_STATE_DIR,
|
|
2231
2810
|
role: resolvedRole.role,
|
|
2232
2811
|
ownerInstanceMarker: launcherInstanceMarker,
|
|
2812
|
+
ownerStartOrder: launcherStartOrder,
|
|
2813
|
+
staleOwnerProof: replacementCleanup?.staleOwnerProof || null,
|
|
2233
2814
|
openExisting: () => undefined
|
|
2234
2815
|
});
|
|
2235
2816
|
}
|
|
@@ -2242,6 +2823,7 @@ async function main() {
|
|
|
2242
2823
|
stateDir: MANAGER_STATE_DIR,
|
|
2243
2824
|
role: resolvedRole.role,
|
|
2244
2825
|
ownerInstanceMarker: launcherInstanceMarker,
|
|
2826
|
+
ownerStartOrder: launcherStartOrder,
|
|
2245
2827
|
openExisting: () => undefined
|
|
2246
2828
|
});
|
|
2247
2829
|
}
|
|
@@ -2252,6 +2834,14 @@ async function main() {
|
|
|
2252
2834
|
}
|
|
2253
2835
|
process.once('exit', lock.release);
|
|
2254
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
|
+
|
|
2255
2845
|
if (resolvedRole.role === 'client') {
|
|
2256
2846
|
const clientRuntimePort = normalizePort(
|
|
2257
2847
|
process.env.LIVEDESK_CLIENT_RUNTIME_PORT || process.env.LIVEDESK_LOCAL_RUNTIME_PORT,
|
|
@@ -2272,7 +2862,7 @@ async function main() {
|
|
|
2272
2862
|
printClientHelp();
|
|
2273
2863
|
return;
|
|
2274
2864
|
}
|
|
2275
|
-
await runClient(runtimeArgs, resolvedRole);
|
|
2865
|
+
await runClient(runtimeArgs, resolvedRole, lock);
|
|
2276
2866
|
return;
|
|
2277
2867
|
}
|
|
2278
2868
|
if (command === 'hub' || command === 'manager') {
|
|
@@ -2280,14 +2870,14 @@ async function main() {
|
|
|
2280
2870
|
printHelp();
|
|
2281
2871
|
return;
|
|
2282
2872
|
}
|
|
2283
|
-
await runManager(runtimeArgs, resolvedRole);
|
|
2873
|
+
await runManager(runtimeArgs, resolvedRole, lock);
|
|
2284
2874
|
return;
|
|
2285
2875
|
}
|
|
2286
2876
|
if (resolvedRole.role === 'client') {
|
|
2287
|
-
await runClient(runtimeArgs, resolvedRole);
|
|
2877
|
+
await runClient(runtimeArgs, resolvedRole, lock);
|
|
2288
2878
|
return;
|
|
2289
2879
|
}
|
|
2290
|
-
await runManager(runtimeArgs, resolvedRole);
|
|
2880
|
+
await runManager(runtimeArgs, resolvedRole, lock);
|
|
2291
2881
|
}
|
|
2292
2882
|
|
|
2293
2883
|
main().catch(error => {
|