livedesk 0.1.446 → 0.1.448
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 +1032 -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/README.md +3 -3
- package/client/bin/livedesk-client-node.js +18 -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/agents/agent-manager.js +132 -175
- package/hub/src/agents/agent-settings.js +7 -68
- package/hub/src/agents/agent-tool-registry.js +1 -1
- package/hub/src/agents/codex-agent-runtime.js +2 -2
- package/hub/src/remote-hub.js +92 -124
- package/hub/src/server.js +50 -38
- 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/web/dist/assets/{icons-BIyRF-ou.js → icons-CTHUbfAT.js} +1 -1
- package/web/dist/assets/index-DKPdDVYW.js +25 -0
- package/web/dist/assets/{react-C7a9r614.js → react-BOnb-QRJ.js} +1 -1
- package/web/dist/index.html +3 -3
- package/hub/src/agents/opencode-go-provider.js +0 -260
- package/hub/src/agents/secret-store.js +0 -165
- package/web/dist/assets/index-5opXQY8X.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));
|
|
@@ -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 };
|
|
808
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
|
+
|
|
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,251 @@ 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 terminateExactWindowsPortOwners(records) {
|
|
1426
|
+
const roots = [];
|
|
1427
|
+
const seenRoots = new Set();
|
|
1428
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
1429
|
+
const pid = Number(record?.Pid || 0);
|
|
1430
|
+
const startOrder = String(record?.CreationUtcTicks || '').trim();
|
|
1431
|
+
if (record?.KnownLiveDesk !== true
|
|
1432
|
+
|| !Number.isInteger(pid)
|
|
1433
|
+
|| pid <= 1
|
|
1434
|
+
|| !/^\d+$/.test(startOrder)
|
|
1435
|
+
|| seenRoots.has(`${pid}:${startOrder}`)) {
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
seenRoots.add(`${pid}:${startOrder}`);
|
|
1439
|
+
roots.push({ pid, startOrder });
|
|
1440
|
+
}
|
|
1441
|
+
if (roots.length === 0) return;
|
|
1442
|
+
|
|
1443
|
+
const encodedRoots = Buffer.from(JSON.stringify(roots), 'utf8').toString('base64');
|
|
1444
|
+
const script = [
|
|
1445
|
+
'$ErrorActionPreference = "Stop";',
|
|
1446
|
+
`$rootsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${encodedRoots}"));`,
|
|
1447
|
+
'$requestedRoots = @(ConvertFrom-Json -InputObject $rootsJson);',
|
|
1448
|
+
'$validatedRoots = [System.Collections.Generic.List[object]]::new();',
|
|
1449
|
+
'foreach ($item in $requestedRoots) {',
|
|
1450
|
+
' $targetPid = [int]$item.pid;',
|
|
1451
|
+
' $expectedStartTicks = [string]$item.startOrder;',
|
|
1452
|
+
' $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,ParentProcessId,Name,CommandLine,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
1453
|
+
' if (-not $proc) { continue };',
|
|
1454
|
+
' $actualStartTicks = if ($proc.CreationDate) { [string]$proc.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
1455
|
+
' $processName = [string]$proc.Name;',
|
|
1456
|
+
' $commandLine = [string]$proc.CommandLine;',
|
|
1457
|
+
' $isNodeProcess = $processName -match "(?i)^node(?:\\.exe)?$";',
|
|
1458
|
+
' $hasKnownLiveDeskHubPath = $commandLine -match "(?i)(?:packages[\\\\/](?:livedesk[\\\\/])?hub|node_modules[\\\\/]@?livedesk[\\\\/]hub)[\\\\/]src[\\\\/]server\\.js";',
|
|
1459
|
+
' $hasUnifiedLauncherPath = $commandLine -match "(?i)(?:node_modules[\\\\/]livedesk|packages[\\\\/]livedesk)[\\\\/]bin[\\\\/]livedesk\\.js";',
|
|
1460
|
+
' $hasLegacyClientPath = $commandLine -match "(?i)(?:node_modules[\\\\/]@livedesk[\\\\/]client|packages[\\\\/]client|packages[\\\\/]livedesk[\\\\/]client)[\\\\/]bin[\\\\/]livedesk-client\\.js";',
|
|
1461
|
+
' if ($actualStartTicks -ne $expectedStartTicks -or -not $isNodeProcess -or -not ($hasKnownLiveDeskHubPath -or $hasUnifiedLauncherPath -or $hasLegacyClientPath)) { continue };',
|
|
1462
|
+
' $validatedRoots.Add([pscustomobject]@{ ProcessId = [int]$proc.ProcessId; ParentProcessId = [int]$proc.ParentProcessId; CreationUtcTicks = $actualStartTicks; Depth = 0 });',
|
|
1463
|
+
'}',
|
|
1464
|
+
'if ($validatedRoots.Count -eq 0) { exit 0 };',
|
|
1465
|
+
'$snapshot = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object {',
|
|
1466
|
+
' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
1467
|
+
' if ($ticks) { [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationUtcTicks = $ticks } }',
|
|
1468
|
+
'});',
|
|
1469
|
+
'$targets = @{};',
|
|
1470
|
+
'foreach ($root in $validatedRoots) {',
|
|
1471
|
+
' $currentRoot = @($snapshot | Where-Object { [int]$_.ProcessId -eq [int]$root.ProcessId }) | Select-Object -First 1;',
|
|
1472
|
+
' if (-not $currentRoot -or [string]$currentRoot.CreationUtcTicks -ne [string]$root.CreationUtcTicks) { continue };',
|
|
1473
|
+
' $queue = [System.Collections.Generic.Queue[object]]::new();',
|
|
1474
|
+
' $queue.Enqueue([pscustomobject]@{ Record = $currentRoot; Depth = 0 });',
|
|
1475
|
+
' $seen = [System.Collections.Generic.HashSet[int]]::new();',
|
|
1476
|
+
' while ($queue.Count -gt 0) {',
|
|
1477
|
+
' $current = $queue.Dequeue();',
|
|
1478
|
+
' $currentRecord = $current.Record;',
|
|
1479
|
+
' $currentPid = [int]$currentRecord.ProcessId;',
|
|
1480
|
+
' if (-not $seen.Add($currentPid)) { continue };',
|
|
1481
|
+
' $key = "$currentPid`:$([string]$currentRecord.CreationUtcTicks)";',
|
|
1482
|
+
' $targets[$key] = [pscustomobject]@{ ProcessId = $currentPid; CreationUtcTicks = [string]$currentRecord.CreationUtcTicks; Depth = [int]$current.Depth };',
|
|
1483
|
+
' foreach ($child in @($snapshot | Where-Object { [int]$_.ParentProcessId -eq $currentPid })) {',
|
|
1484
|
+
' try { if ([System.Numerics.BigInteger]::Parse([string]$child.CreationUtcTicks) -lt [System.Numerics.BigInteger]::Parse([string]$currentRecord.CreationUtcTicks)) { continue } } catch { continue };',
|
|
1485
|
+
' $queue.Enqueue([pscustomobject]@{ Record = $child; Depth = [int]$current.Depth + 1 });',
|
|
1486
|
+
' }',
|
|
1487
|
+
' }',
|
|
1488
|
+
'}',
|
|
1489
|
+
'$failures = [System.Collections.Generic.List[string]]::new();',
|
|
1490
|
+
'$exitTicks = @{};',
|
|
1491
|
+
'$orderedTargets = @($targets.Values | Sort-Object @{ Expression = { -[int]$_.Depth } });',
|
|
1492
|
+
'foreach ($targetRecord in $orderedTargets) {',
|
|
1493
|
+
' $targetPid = [int]$targetRecord.ProcessId;',
|
|
1494
|
+
' $expectedStartTicks = [string]$targetRecord.CreationUtcTicks;',
|
|
1495
|
+
' $targetKey = "$targetPid`:$expectedStartTicks";',
|
|
1496
|
+
' try {',
|
|
1497
|
+
' $current = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
1498
|
+
' if (-not $current) { continue };',
|
|
1499
|
+
' $currentStartTicks = if ($current.CreationDate) { [string]$current.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
1500
|
+
' if ($currentStartTicks -ne $expectedStartTicks) { continue };',
|
|
1501
|
+
' Stop-Process -Id $targetPid -Force -ErrorAction Stop;',
|
|
1502
|
+
' $exitTicks[$targetKey] = [string][DateTime]::UtcNow.Ticks;',
|
|
1503
|
+
' } catch {',
|
|
1504
|
+
' $failures.Add("pid=$targetPid $($_.Exception.Message)");',
|
|
1505
|
+
' }',
|
|
1506
|
+
'}',
|
|
1507
|
+
'Start-Sleep -Milliseconds 75;',
|
|
1508
|
+
'$lateSnapshot = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object {',
|
|
1509
|
+
' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
1510
|
+
' if ($ticks) { [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationUtcTicks = $ticks } }',
|
|
1511
|
+
'});',
|
|
1512
|
+
'$lateTargets = @{};',
|
|
1513
|
+
'$lateQueue = [System.Collections.Generic.Queue[object]]::new();',
|
|
1514
|
+
'foreach ($tracked in $targets.Values) {',
|
|
1515
|
+
' $trackedKey = "$([int]$tracked.ProcessId)`:$([string]$tracked.CreationUtcTicks)";',
|
|
1516
|
+
' if (-not $exitTicks.ContainsKey($trackedKey)) { $exitTicks[$trackedKey] = [string][DateTime]::UtcNow.Ticks };',
|
|
1517
|
+
' $lateQueue.Enqueue([pscustomobject]@{ Record = $tracked; Depth = [int]$tracked.Depth; ExitTicks = [string]$exitTicks[$trackedKey] });',
|
|
1518
|
+
'}',
|
|
1519
|
+
'$lateSeen = [System.Collections.Generic.HashSet[string]]::new();',
|
|
1520
|
+
'while ($lateQueue.Count -gt 0) {',
|
|
1521
|
+
' $parent = $lateQueue.Dequeue();',
|
|
1522
|
+
' $parentRecord = $parent.Record;',
|
|
1523
|
+
' $parentKey = "$([int]$parentRecord.ProcessId)`:$([string]$parentRecord.CreationUtcTicks)";',
|
|
1524
|
+
' if (-not $lateSeen.Add($parentKey)) { continue };',
|
|
1525
|
+
' foreach ($child in @($lateSnapshot | Where-Object { [int]$_.ParentProcessId -eq [int]$parentRecord.ProcessId })) {',
|
|
1526
|
+
' try {',
|
|
1527
|
+
' $childStart = [System.Numerics.BigInteger]::Parse([string]$child.CreationUtcTicks);',
|
|
1528
|
+
' if ($childStart -lt [System.Numerics.BigInteger]::Parse([string]$parentRecord.CreationUtcTicks)) { continue };',
|
|
1529
|
+
' if ($parent.ExitTicks -and $childStart -gt [System.Numerics.BigInteger]::Parse([string]$parent.ExitTicks)) { continue };',
|
|
1530
|
+
' } catch { continue };',
|
|
1531
|
+
' $childKey = "$([int]$child.ProcessId)`:$([string]$child.CreationUtcTicks)";',
|
|
1532
|
+
' $childDepth = [int]$parent.Depth + 1;',
|
|
1533
|
+
' $lateTargets[$childKey] = [pscustomobject]@{ ProcessId = [int]$child.ProcessId; CreationUtcTicks = [string]$child.CreationUtcTicks; Depth = $childDepth };',
|
|
1534
|
+
' $lateQueue.Enqueue([pscustomobject]@{ Record = $child; Depth = $childDepth; ExitTicks = "" });',
|
|
1535
|
+
' }',
|
|
1536
|
+
'}',
|
|
1537
|
+
'$orderedLateTargets = @($lateTargets.Values | Sort-Object @{ Expression = { -[int]$_.Depth } });',
|
|
1538
|
+
'foreach ($targetRecord in $orderedLateTargets) {',
|
|
1539
|
+
' $targetPid = [int]$targetRecord.ProcessId;',
|
|
1540
|
+
' $expectedStartTicks = [string]$targetRecord.CreationUtcTicks;',
|
|
1541
|
+
' try {',
|
|
1542
|
+
' $current = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
1543
|
+
' if (-not $current) { continue };',
|
|
1544
|
+
' $currentStartTicks = if ($current.CreationDate) { [string]$current.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
1545
|
+
' if ($currentStartTicks -ne $expectedStartTicks) { continue };',
|
|
1546
|
+
' Stop-Process -Id $targetPid -Force -ErrorAction Stop;',
|
|
1547
|
+
' } catch {',
|
|
1548
|
+
' $failures.Add("late pid=$targetPid $($_.Exception.Message)");',
|
|
1549
|
+
' }',
|
|
1550
|
+
'}',
|
|
1551
|
+
'if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 };',
|
|
1552
|
+
'exit 0;'
|
|
1553
|
+
].join(' ');
|
|
1554
|
+
const result = await runQuiet(
|
|
1555
|
+
'powershell.exe',
|
|
1556
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
1557
|
+
{ timeout: 15000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024 }
|
|
1558
|
+
);
|
|
1559
|
+
if (!result.ok) {
|
|
1560
|
+
throw new Error(
|
|
1561
|
+
`Could not stop exact LiveDesk Windows port owner tree: `
|
|
1562
|
+
+ `${result.stderr || result.error?.message || 'PowerShell failed'}`
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
async function stopWindowsProcessesOnPorts(ports) {
|
|
1568
|
+
const records = await collectWindowsPortRecords(ports);
|
|
1569
|
+
const killable = records.filter(record => record?.KnownLiveDesk === true);
|
|
1570
|
+
if (killable.length > 0) {
|
|
1571
|
+
await terminateExactWindowsPortOwners(killable);
|
|
1572
|
+
for (const record of killable) {
|
|
1573
|
+
record.Action = 'stopped';
|
|
1574
|
+
record.KillAttempted = true;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const probes = await Promise.all(ports.map(port => waitForPortBindable(port)));
|
|
1579
|
+
const finalRecords = probes.every(probe => probe.ok)
|
|
1580
|
+
? []
|
|
1581
|
+
: await collectWindowsPortRecords(ports);
|
|
1582
|
+
const portStates = ports.map((port, index) => {
|
|
1583
|
+
const busy = finalRecords.some(record => Number(record?.Port) === Number(port));
|
|
1584
|
+
return {
|
|
1585
|
+
Port: Number(port),
|
|
1586
|
+
PortAvailable: !busy,
|
|
1587
|
+
PortBindable: probes[index].ok,
|
|
1588
|
+
BindError: probes[index].error || '',
|
|
1589
|
+
Ready: !busy && probes[index].ok
|
|
1590
|
+
};
|
|
1591
|
+
});
|
|
1592
|
+
const stopped = records.filter(record => record?.Action === 'stopped');
|
|
1593
|
+
if (stopped.length > 0) {
|
|
1594
|
+
const stoppedOwners = new Map();
|
|
1595
|
+
for (const record of stopped) {
|
|
1596
|
+
if (!stoppedOwners.has(String(record.Pid))) stoppedOwners.set(String(record.Pid), record);
|
|
1597
|
+
}
|
|
1598
|
+
const summary = [...stoppedOwners.values()]
|
|
1599
|
+
.map(record => `${record.Pid}:${record.ProcessName || 'node'}`)
|
|
1600
|
+
.join(', ');
|
|
1601
|
+
console.log(`Restarting LiveDesk Hub. Stopped stale LiveDesk owner(s) once: ${summary}`);
|
|
1602
|
+
}
|
|
1603
|
+
assertPortCleanupReady(finalRecords, portStates);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
async function collectUnixPortRecords(ports) {
|
|
946
1607
|
const records = [];
|
|
947
1608
|
for (const port of ports) {
|
|
948
1609
|
const lsof = await runQuiet('lsof', ['-nP', '-ti', `tcp:${port}`, '-sTCP:LISTEN']);
|
|
@@ -1216,121 +1877,12 @@ async function stopStaleClientAgents() {
|
|
|
1216
1877
|
}
|
|
1217
1878
|
};
|
|
1218
1879
|
|
|
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) => {
|
|
1880
|
+
const terminateWindowsAgentTree = async record => {
|
|
1292
1881
|
const details = await getMatchingDetails(record);
|
|
1293
1882
|
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
|
-
}
|
|
1883
|
+
await terminateExactWindowsProcessTree(details, {
|
|
1884
|
+
label: `verified stale Windows Agent tree pid=${record.pid}`
|
|
1885
|
+
});
|
|
1334
1886
|
};
|
|
1335
1887
|
|
|
1336
1888
|
const stoppedProcesses = new Map();
|
|
@@ -1376,118 +1928,17 @@ async function stopStaleClientAgents() {
|
|
|
1376
1928
|
|
|
1377
1929
|
async function stopProcessesOnPorts(ports) {
|
|
1378
1930
|
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
|
-
}
|
|
1931
|
+
if (uniquePorts.length === 0) {
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (os.platform() === 'win32') {
|
|
1936
|
+
await stopWindowsProcessesOnPorts(uniquePorts);
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
await stopUnixProcessesOnPorts(uniquePorts);
|
|
1941
|
+
}
|
|
1491
1942
|
|
|
1492
1943
|
async function waitForManager(url = DEFAULT_MANAGER_URL, timeoutMs = 10000) {
|
|
1493
1944
|
const startedAt = Date.now();
|
|
@@ -1579,6 +2030,13 @@ function buildHubRestartBootstrapScript() {
|
|
|
1579
2030
|
'const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
|
|
1580
2031
|
'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
|
|
1581
2032
|
'const numberFromEnv = (name, fallback, minimum) => { const value = Number(process.env[name]); return Number.isFinite(value) && value >= minimum ? Math.round(value) : fallback; };',
|
|
2033
|
+
'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()); }); });',
|
|
2034
|
+
'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)); };',
|
|
2035
|
+
'const sameWindowsIdentity = (left, right) => Number(left?.pid || 0) === Number(right?.pid || 0) && String(left?.startOrder || "") === String(right?.startOrder || "");',
|
|
2036
|
+
'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"}`); };',
|
|
2037
|
+
'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)); };',
|
|
2038
|
+
'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); };',
|
|
2039
|
+
'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
2040
|
'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
2041
|
'const decodeArgs = name => JSON.parse(Buffer.from(process.env[name] || "", "base64").toString("utf8"));',
|
|
1584
2042
|
'const primary = { command: process.env.LIVEDESK_RESTART_COMMAND || "", args: decodeArgs("LIVEDESK_RESTART_ARGS_BASE64") };',
|
|
@@ -1601,14 +2059,118 @@ function buildHubRestartBootstrapScript() {
|
|
|
1601
2059
|
'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
2060
|
'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
2061
|
'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
|
|
2062
|
+
'const terminateTree = async (child, runtimePid = 0) => {',
|
|
2063
|
+
' const pid = Number(child?.pid || 0);',
|
|
2064
|
+
' const observedRuntimePid = Number(runtimePid || 0);',
|
|
2065
|
+
' if (pid <= 1) return;',
|
|
2066
|
+
' log(`terminating unverified replacement pid=${pid} runtimePid=${observedRuntimePid || 0}`);',
|
|
2067
|
+
' if (process.platform === "win32") {',
|
|
2068
|
+
' const identities = [child?.livedeskWindowsLauncherIdentity, child?.livedeskWindowsRuntimeIdentity].filter(Boolean);',
|
|
2069
|
+
' if (!child?.livedeskWindowsLauncherIdentity) {',
|
|
2070
|
+
' if (observedRuntimePid <= 1 && (child?.livedeskObservedExit || child?.exitCode !== null || child?.signalCode !== null)) return;',
|
|
2071
|
+
' throw terminationError(pid, "immutable launcher CreationDate was not captured");',
|
|
2072
|
+
' }',
|
|
2073
|
+
' try {',
|
|
2074
|
+
' await terminateExactWindowsOwnedTrees(identities, { skipStop: terminationFailureOperationId === operationId });',
|
|
2075
|
+
' } catch (error) {',
|
|
2076
|
+
' throw terminationError(pid, error?.message || String(error));',
|
|
2077
|
+
' }',
|
|
2078
|
+
' return;',
|
|
2079
|
+
' }',
|
|
2080
|
+
' const rootsAlive = () => isAlive(pid) || (observedRuntimePid > 1 && isAlive(observedRuntimePid));',
|
|
2081
|
+
' if (terminationFailureOperationId !== operationId) {',
|
|
2082
|
+
' try { process.kill(-pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch {} }',
|
|
2083
|
+
' }',
|
|
2084
|
+
' const gracefulDeadline = Date.now() + 2000;',
|
|
2085
|
+
' while (Date.now() < gracefulDeadline && (isGroupAlive(pid) || rootsAlive())) await sleep(100);',
|
|
2086
|
+
' if (isGroupAlive(pid) || rootsAlive()) {',
|
|
2087
|
+
' if (terminationFailureOperationId !== operationId) {',
|
|
2088
|
+
' log(`unverified replacement pid=${pid} ignored SIGTERM; sending SIGKILL to process group`);',
|
|
2089
|
+
' try { process.kill(-pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch {} }',
|
|
2090
|
+
' }',
|
|
2091
|
+
' const hardDeadline = Date.now() + 1000;',
|
|
2092
|
+
' while (Date.now() < hardDeadline && (isGroupAlive(pid) || rootsAlive())) await sleep(100);',
|
|
2093
|
+
' }',
|
|
2094
|
+
' if (isGroupAlive(pid) || rootsAlive()) throw terminationError(pid, `launcher/process group/runtime still alive (${observedRuntimePid || "no runtime pid"})`);',
|
|
2095
|
+
'};',
|
|
2096
|
+
'const launchAndVerify = async (invocation, label, requiredVersion, attempt) => {',
|
|
2097
|
+
' if (!invocation.command || !Array.isArray(invocation.args)) throw new Error(`${label} restart command is unavailable`);',
|
|
2098
|
+
' const isFallback = label === "fallback";',
|
|
2099
|
+
' if (!isFallback) prepareNeutralCwd();',
|
|
2100
|
+
' const launchCwd = isFallback ? fallbackCwd : neutralCwd;',
|
|
2101
|
+
' if (!launchCwd) throw new Error(`${label} restart working directory is unavailable`);',
|
|
2102
|
+
' const launchEnv = isFallback ? { ...process.env } : buildLaunchEnv();',
|
|
2103
|
+
' assertResultOwnership();',
|
|
2104
|
+
' log(`${label} attempt=${attempt} stage=spawning command=${invocation.command} target=${requiredVersion || "current"} cwd=${launchCwd}`);',
|
|
2105
|
+
' let exit = null;',
|
|
2106
|
+
' let lastRuntimePid = 0;',
|
|
2107
|
+
' let stableRuntimePid = 0;',
|
|
2108
|
+
' let stableSince = 0;',
|
|
2109
|
+
' const child = spawn(invocation.command, invocation.args, { cwd: launchCwd, env: launchEnv, detached: true, stdio: "ignore", windowsHide: true });',
|
|
2110
|
+
' child.once("exit", (code, signal) => {',
|
|
2111
|
+
' exit = { code, signal };',
|
|
2112
|
+
' child.livedeskObservedExit = exit;',
|
|
2113
|
+
' log(`${label} attempt=${attempt} stage=exited code=${code ?? "none"} signal=${signal || "none"}`);',
|
|
2114
|
+
' });',
|
|
2115
|
+
' await new Promise((resolve, reject) => { child.once("error", reject); child.once("spawn", resolve); });',
|
|
2116
|
+
' if (process.platform === "win32") {',
|
|
2117
|
+
' try {',
|
|
2118
|
+
' child.livedeskWindowsLauncherIdentity = await captureWindowsIdentity(child.pid);',
|
|
2119
|
+
' } catch (error) {',
|
|
2120
|
+
' if (!(exit || child.exitCode !== null || child.signalCode !== null || !isAlive(Number(child.pid || 0)))) {',
|
|
2121
|
+
' try { child.kill("SIGKILL"); } catch {}',
|
|
2122
|
+
' throw terminationError(Number(child.pid || 0), `immutable launcher identity capture failed: ${error?.message || error}`);',
|
|
2123
|
+
' }',
|
|
2124
|
+
' log(`${label} attempt=${attempt} stage=exited-before-identity pid=${child.pid || 0}`);',
|
|
2125
|
+
' }',
|
|
2126
|
+
' }',
|
|
2127
|
+
' log(`${label} attempt=${attempt} stage=spawned pid=${child.pid || 0}`);',
|
|
2128
|
+
' const deadline = Date.now() + healthTimeoutMs;',
|
|
2129
|
+
' let lastError = null;',
|
|
2130
|
+
' while (Date.now() < deadline) {',
|
|
2131
|
+
' try {',
|
|
2132
|
+
' assertResultOwnership();',
|
|
2133
|
+
' if (exit || !isAlive(Number(child.pid || 0))) throw new Error(`launcher pid=${child.pid || 0} exited before stability proof`);',
|
|
2134
|
+
' const status = await probeReplacement(requiredVersion);',
|
|
2135
|
+
' lastRuntimePid = Number(status.runtimePid || 0);',
|
|
2136
|
+
' if (!isAlive(Number(child.pid || 0))) throw new Error(`launcher pid=${child.pid || 0} exited during stability proof`);',
|
|
2137
|
+
' if (!isAlive(lastRuntimePid)) throw new Error(`runtime pid=${lastRuntimePid || 0} exited during stability proof`);',
|
|
2138
|
+
' if (process.platform === "win32" && Number(child.livedeskWindowsRuntimeIdentity?.pid || 0) !== lastRuntimePid) {',
|
|
2139
|
+
' child.livedeskWindowsRuntimeIdentity = await captureWindowsIdentity(lastRuntimePid);',
|
|
2140
|
+
' }',
|
|
2141
|
+
' assertResultOwnership();',
|
|
2142
|
+
' if (stableRuntimePid !== lastRuntimePid) {',
|
|
2143
|
+
' stableRuntimePid = lastRuntimePid;',
|
|
2144
|
+
' stableSince = Date.now();',
|
|
2145
|
+
' log(`${label} attempt=${attempt} stage=stability-start launcherPid=${child.pid || 0} runtimePid=${lastRuntimePid} requiredMs=${stabilityMs}`);',
|
|
2146
|
+
' }',
|
|
2147
|
+
' if (Date.now() - stableSince >= stabilityMs) {',
|
|
2148
|
+
' 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}`);',
|
|
2149
|
+
' child.unref();',
|
|
2150
|
+
' return { ...status, launcherPid: Number(child.pid || 0) };',
|
|
2151
|
+
' }',
|
|
2152
|
+
' } catch (error) {',
|
|
2153
|
+
' if (error?.code === "LIVEDESK_HUB_UPDATE_SUPERSEDED") {',
|
|
2154
|
+
' await terminateTree(child, lastRuntimePid);',
|
|
2155
|
+
' throw error;',
|
|
2156
|
+
' }',
|
|
2157
|
+
' lastError = error;',
|
|
2158
|
+
' stableRuntimePid = 0;',
|
|
2159
|
+
' stableSince = 0;',
|
|
2160
|
+
' }',
|
|
2161
|
+
' if (exit) break;',
|
|
2162
|
+
' await sleep(pollMs);',
|
|
2163
|
+
' }',
|
|
2164
|
+
' await terminateTree(child, lastRuntimePid);',
|
|
2165
|
+
' 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")}`);',
|
|
2166
|
+
'};',
|
|
1606
2167
|
'(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
2168
|
''
|
|
1608
2169
|
].join('\n');
|
|
1609
2170
|
}
|
|
1610
2171
|
|
|
1611
|
-
async function runManager(args, resolvedRole = null) {
|
|
2172
|
+
async function runManager(args, resolvedRole = null, runtimeLock = null) {
|
|
2173
|
+
const runtimeOwner = runtimeLock?.payload || null;
|
|
1612
2174
|
const options = parseManagerArgs(args);
|
|
1613
2175
|
const httpPort = normalizePort(
|
|
1614
2176
|
options.port || process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT,
|
|
@@ -1697,6 +2259,91 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1697
2259
|
let updatePollId = null;
|
|
1698
2260
|
let updateStopTimer = null;
|
|
1699
2261
|
let updateHardStopTimer = null;
|
|
2262
|
+
let launcherShutdownSignal = '';
|
|
2263
|
+
let launcherShutdownHardStopTimer = null;
|
|
2264
|
+
let runtimeShutdownWatcher = null;
|
|
2265
|
+
const launcherSignalHandlers = new Map();
|
|
2266
|
+
|
|
2267
|
+
const removeLauncherSignalHandlers = () => {
|
|
2268
|
+
for (const [signal, handler] of launcherSignalHandlers) {
|
|
2269
|
+
process.removeListener(signal, handler);
|
|
2270
|
+
}
|
|
2271
|
+
launcherSignalHandlers.clear();
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
const requestLauncherShutdown = signal => {
|
|
2275
|
+
if (launcherShutdownSignal) return;
|
|
2276
|
+
launcherShutdownSignal = signal;
|
|
2277
|
+
restartRequest = null;
|
|
2278
|
+
if (updatePollId) {
|
|
2279
|
+
clearInterval(updatePollId);
|
|
2280
|
+
updatePollId = null;
|
|
2281
|
+
}
|
|
2282
|
+
if (updateStopTimer) {
|
|
2283
|
+
clearTimeout(updateStopTimer);
|
|
2284
|
+
updateStopTimer = null;
|
|
2285
|
+
}
|
|
2286
|
+
if (updateHardStopTimer) {
|
|
2287
|
+
clearTimeout(updateHardStopTimer);
|
|
2288
|
+
updateHardStopTimer = null;
|
|
2289
|
+
}
|
|
2290
|
+
const child = activeChild;
|
|
2291
|
+
if (!child || child.exitCode !== null || child.signalCode) {
|
|
2292
|
+
try { hubLogStream?.end(); } catch {}
|
|
2293
|
+
process.exit(0);
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
reportLauncherUpdate(
|
|
2297
|
+
`[LiveDesk Hub] Launcher received ${signal}; requesting graceful Hub shutdown for pid=${child.pid || 'unknown'}.`
|
|
2298
|
+
);
|
|
2299
|
+
void (async () => {
|
|
2300
|
+
const gracefulDeadline = Date.now() + 2_000;
|
|
2301
|
+
let lastError = null;
|
|
2302
|
+
while (child.exitCode === null
|
|
2303
|
+
&& !child.signalCode
|
|
2304
|
+
&& Date.now() < gracefulDeadline) {
|
|
2305
|
+
try {
|
|
2306
|
+
const response = await fetch(`${hubProbeBaseUrl}/api/runtime/shutdown`, {
|
|
2307
|
+
method: 'POST',
|
|
2308
|
+
signal: AbortSignal.timeout(750)
|
|
2309
|
+
});
|
|
2310
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2311
|
+
reportLauncherUpdate(
|
|
2312
|
+
`[LiveDesk Hub] Owned Hub pid=${child.pid || 'unknown'} acknowledged graceful shutdown.`
|
|
2313
|
+
);
|
|
2314
|
+
return;
|
|
2315
|
+
} catch (error) {
|
|
2316
|
+
lastError = error;
|
|
2317
|
+
await waitMilliseconds(100);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
if (child.exitCode !== null || child.signalCode) return;
|
|
2321
|
+
reportLauncherUpdate(
|
|
2322
|
+
`[LiveDesk Hub] Graceful shutdown API remained unavailable for pid=${child.pid || 'unknown'}`
|
|
2323
|
+
+ `${lastError?.message ? ` (${lastError.message})` : ''}; exact child fallback remains armed.`,
|
|
2324
|
+
true
|
|
2325
|
+
);
|
|
2326
|
+
if (process.platform !== 'win32') {
|
|
2327
|
+
try { child.kill('SIGTERM'); } catch { /* exact owned child already exited */ }
|
|
2328
|
+
}
|
|
2329
|
+
})();
|
|
2330
|
+
launcherShutdownHardStopTimer = setTimeout(() => {
|
|
2331
|
+
if (child.exitCode !== null || child.signalCode) return;
|
|
2332
|
+
reportLauncherUpdate(
|
|
2333
|
+
`[LiveDesk Hub] Owned Hub pid=${child.pid || 'unknown'} exceeded graceful shutdown; `
|
|
2334
|
+
+ 'force-stopping that exact child root.',
|
|
2335
|
+
true
|
|
2336
|
+
);
|
|
2337
|
+
try { child.kill('SIGKILL'); } catch { /* exact owned child already exited */ }
|
|
2338
|
+
}, 12_000);
|
|
2339
|
+
launcherShutdownHardStopTimer.unref?.();
|
|
2340
|
+
};
|
|
2341
|
+
|
|
2342
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
2343
|
+
const handler = () => requestLauncherShutdown(signal);
|
|
2344
|
+
launcherSignalHandlers.set(signal, handler);
|
|
2345
|
+
process.on(signal, handler);
|
|
2346
|
+
}
|
|
1700
2347
|
|
|
1701
2348
|
const restartWithLatest = async request => {
|
|
1702
2349
|
const requestedVersion = normalizeExactUpdateVersion(request?.latestVersion);
|
|
@@ -1892,13 +2539,16 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1892
2539
|
try { updateChild.kill('SIGTERM'); } catch { /* child already exited */ }
|
|
1893
2540
|
updateHardStopTimer = setTimeout(() => {
|
|
1894
2541
|
if (!updateChild || updateChild.exitCode !== null || updateChild.signalCode) return;
|
|
1895
|
-
reportLauncherUpdate(`[LiveDesk Hub] Update shutdown hard fallback: Hub pid=${requestPid} ignored graceful termination; force-stopping
|
|
2542
|
+
reportLauncherUpdate(`[LiveDesk Hub] Update shutdown hard fallback: Hub pid=${requestPid} ignored graceful termination; force-stopping the owned Hub process.`, true);
|
|
1896
2543
|
if (process.platform === 'win32') {
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2544
|
+
// updateChild is the exact ChildProcess instance spawned by this
|
|
2545
|
+
// launcher. Stop only that owned Hub root; never let taskkill /T
|
|
2546
|
+
// follow unverified PPID descendants.
|
|
2547
|
+
try { updateChild.kill('SIGKILL'); } catch (error) {
|
|
2548
|
+
if (updateChild.exitCode === null && !updateChild.signalCode) {
|
|
2549
|
+
reportLauncherUpdate(`[LiveDesk Hub] hard-stop fallback failed for pid=${requestPid}: ${error.message}`, true);
|
|
1900
2550
|
}
|
|
1901
|
-
}
|
|
2551
|
+
}
|
|
1902
2552
|
} else {
|
|
1903
2553
|
try { updateChild.kill('SIGKILL'); } catch { /* child already exited */ }
|
|
1904
2554
|
}
|
|
@@ -1952,6 +2602,17 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1952
2602
|
});
|
|
1953
2603
|
child.once('exit', (code, signal) => {
|
|
1954
2604
|
const startupOutput = stderrChunks.join('');
|
|
2605
|
+
if (launcherShutdownSignal) {
|
|
2606
|
+
if (launcherShutdownHardStopTimer) {
|
|
2607
|
+
clearTimeout(launcherShutdownHardStopTimer);
|
|
2608
|
+
launcherShutdownHardStopTimer = null;
|
|
2609
|
+
}
|
|
2610
|
+
runtimeShutdownWatcher?.stop();
|
|
2611
|
+
removeLauncherSignalHandlers();
|
|
2612
|
+
try { hubLogStream?.end(); } catch {}
|
|
2613
|
+
process.exit(0);
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
1955
2616
|
if (restartRequest) {
|
|
1956
2617
|
const request = restartRequest;
|
|
1957
2618
|
restartRequest = null;
|
|
@@ -2002,23 +2663,38 @@ async function runManager(args, resolvedRole = null) {
|
|
|
2002
2663
|
});
|
|
2003
2664
|
return;
|
|
2004
2665
|
}
|
|
2005
|
-
if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
|
|
2666
|
+
if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
|
|
2006
2667
|
if (updatePollId) {
|
|
2007
2668
|
clearInterval(updatePollId);
|
|
2008
2669
|
updatePollId = null;
|
|
2009
2670
|
}
|
|
2010
|
-
|
|
2671
|
+
try {
|
|
2672
|
+
runtimeLock?.updateRole?.('client');
|
|
2673
|
+
} catch (error) {
|
|
2674
|
+
console.error(
|
|
2675
|
+
`[LiveDesk Hub] The shared runtime lock could not transition to Client: ${error?.message || error}. `
|
|
2676
|
+
+ 'The launcher will exit so a clean invocation can recover.'
|
|
2677
|
+
);
|
|
2678
|
+
runtimeShutdownWatcher?.stop();
|
|
2679
|
+
removeLauncherSignalHandlers();
|
|
2680
|
+
try { hubLogStream?.end(); } catch {}
|
|
2681
|
+
process.exit(1);
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
writeRoleCache({
|
|
2011
2685
|
role: 'client',
|
|
2012
2686
|
deviceId: resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || '',
|
|
2013
2687
|
deviceName: resolvedRole?.deviceName || os.hostname(),
|
|
2014
2688
|
source: 'supabase',
|
|
2015
2689
|
isOfflineFallback: false
|
|
2016
|
-
}, { stateDir: MANAGER_STATE_DIR });
|
|
2017
|
-
console.log('[LiveDesk Hub] Role transition accepted. Starting the Client runtime.');
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2690
|
+
}, { stateDir: MANAGER_STATE_DIR });
|
|
2691
|
+
console.log('[LiveDesk Hub] Role transition accepted. Starting the Client runtime.');
|
|
2692
|
+
runtimeShutdownWatcher?.stop();
|
|
2693
|
+
removeLauncherSignalHandlers();
|
|
2694
|
+
void runClient([], { ...resolvedRole, role: 'client', source: 'supabase', roleTransition: true }, runtimeLock).catch(error => {
|
|
2695
|
+
console.error(`Failed to start LiveDesk Client runtime: ${error?.message || error}`);
|
|
2696
|
+
process.exitCode = 1;
|
|
2697
|
+
});
|
|
2022
2698
|
return;
|
|
2023
2699
|
}
|
|
2024
2700
|
if (!signal
|
|
@@ -2047,8 +2723,19 @@ async function runManager(args, resolvedRole = null) {
|
|
|
2047
2723
|
}
|
|
2048
2724
|
process.exit(code ?? 0);
|
|
2049
2725
|
});
|
|
2050
|
-
};
|
|
2051
|
-
|
|
2726
|
+
};
|
|
2727
|
+
|
|
2728
|
+
runtimeShutdownWatcher = startOwnedRuntimeShutdownWatcher(runtimeOwner, async request => {
|
|
2729
|
+
reportLauncherUpdate(
|
|
2730
|
+
`[LiveDesk Hub] Cooperative replacement request accepted `
|
|
2731
|
+
+ `request=${request.requestId} reason=${request.reason}.`
|
|
2732
|
+
);
|
|
2733
|
+
requestLauncherShutdown('COOPERATIVE_REPLACEMENT');
|
|
2734
|
+
// The launcher signal handler keeps ownership until the Hub child has
|
|
2735
|
+
// closed its sockets, host lease, and HTTP server. Process exit is the
|
|
2736
|
+
// requester's completion proof; do not publish a premature completed ACK.
|
|
2737
|
+
await new Promise(() => undefined);
|
|
2738
|
+
});
|
|
2052
2739
|
startHubProcess();
|
|
2053
2740
|
updatePollId = setInterval(pollUpdateRequest, HUB_UPDATE_POLL_MS);
|
|
2054
2741
|
|
|
@@ -2080,7 +2767,8 @@ async function runManager(args, resolvedRole = null) {
|
|
|
2080
2767
|
}
|
|
2081
2768
|
}
|
|
2082
2769
|
|
|
2083
|
-
async function runClient(args, resolvedRole = null) {
|
|
2770
|
+
async function runClient(args, resolvedRole = null, runtimeLock = null) {
|
|
2771
|
+
const runtimeOwner = runtimeLock?.payload || null;
|
|
2084
2772
|
const internalClientEntry = resolve(packageRoot, 'client', 'bin', 'livedesk-client.js');
|
|
2085
2773
|
if (!existsSync(internalClientEntry)) {
|
|
2086
2774
|
throw new Error('The published livedesk package is missing its internal Client runtime. Rebuild the package with npm run build.');
|
|
@@ -2105,14 +2793,35 @@ async function runClient(args, resolvedRole = null) {
|
|
|
2105
2793
|
? '1'
|
|
2106
2794
|
: String(process.env.LIVEDESK_SKIP_BROWSER_OPEN || ''),
|
|
2107
2795
|
LIVEDESK_DEVICE_ID: String(resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || ''),
|
|
2108
|
-
LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
|
|
2109
|
-
|
|
2796
|
+
LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || ''),
|
|
2797
|
+
LIVEDESK_RUNTIME_OWNER_PID: String(runtimeOwner?.pid || ''),
|
|
2798
|
+
LIVEDESK_RUNTIME_OWNER_TOKEN: String(runtimeOwner?.ownerToken || ''),
|
|
2799
|
+
LIVEDESK_RUNTIME_OWNER_INSTANCE_MARKER: String(runtimeOwner?.ownerInstanceMarker || ''),
|
|
2800
|
+
LIVEDESK_RUNTIME_OWNER_START_ORDER: String(runtimeOwner?.ownerStartOrder || '')
|
|
2801
|
+
});
|
|
2110
2802
|
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
|
-
|
|
2803
|
+
if (typeof clientModule.runClientRuntime !== 'function') {
|
|
2804
|
+
throw new Error('The unified Client runtime module does not expose runClientRuntime().');
|
|
2805
|
+
}
|
|
2806
|
+
const runtimeShutdownWatcher = startOwnedRuntimeShutdownWatcher(runtimeOwner, async request => {
|
|
2807
|
+
console.log(
|
|
2808
|
+
`[LiveDesk Client] Cooperative replacement request accepted `
|
|
2809
|
+
+ `request=${request.requestId} reason=${request.reason}. Draining the owned capture tree.`
|
|
2810
|
+
);
|
|
2811
|
+
if (typeof clientModule.requestClientRuntimeShutdown !== 'function') {
|
|
2812
|
+
throw new Error('The unified Client runtime does not expose direct graceful shutdown.');
|
|
2813
|
+
}
|
|
2814
|
+
clientModule.requestClientRuntimeShutdown('SIGTERM');
|
|
2815
|
+
// installAgentTerminationHandlers owns the terminal cleanup contract and
|
|
2816
|
+
// exits only after RemoteFast/Node plus SCK/FFmpeg descendants are gone.
|
|
2817
|
+
await new Promise(() => undefined);
|
|
2818
|
+
});
|
|
2819
|
+
try {
|
|
2820
|
+
await clientModule.runClientRuntime(clientArgs);
|
|
2821
|
+
} finally {
|
|
2822
|
+
runtimeShutdownWatcher?.stop();
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2116
2825
|
|
|
2117
2826
|
async function runDesktopRoleBootstrap() {
|
|
2118
2827
|
const bootstrap = createRoleBootstrapServer({
|
|
@@ -2208,6 +2917,7 @@ async function main() {
|
|
|
2208
2917
|
}
|
|
2209
2918
|
const launcherProcessDetails = await getProcessDetails(process.pid);
|
|
2210
2919
|
const launcherInstanceMarker = processInstanceMarker(launcherProcessDetails);
|
|
2920
|
+
const launcherStartOrder = String(launcherProcessDetails?.startOrder || '').trim();
|
|
2211
2921
|
const reportExistingRuntime = existing => {
|
|
2212
2922
|
const port = DEFAULT_MANAGER_HTTP_PORT;
|
|
2213
2923
|
openBrowser(`http://127.0.0.1:${port}`);
|
|
@@ -2219,17 +2929,21 @@ async function main() {
|
|
|
2219
2929
|
stateDir: MANAGER_STATE_DIR,
|
|
2220
2930
|
role: resolvedRole.role,
|
|
2221
2931
|
ownerInstanceMarker: launcherInstanceMarker,
|
|
2932
|
+
ownerStartOrder: launcherStartOrder,
|
|
2222
2933
|
openExisting: () => undefined
|
|
2223
2934
|
});
|
|
2935
|
+
let replacementCleanup = null;
|
|
2224
2936
|
|
|
2225
2937
|
const sameRoleRuntimeIsRunning = !lock.acquired
|
|
2226
2938
|
&& String(lock.existing?.role || '').trim().toLowerCase() === String(resolvedRole.role || '').trim().toLowerCase();
|
|
2227
2939
|
if (!lock.acquired && sameRoleRuntimeIsRunning && !topLevel.noRestartExisting) {
|
|
2228
|
-
await restartExistingRuntime(lock.existing, resolvedRole.role);
|
|
2940
|
+
replacementCleanup = await restartExistingRuntime(lock.existing, resolvedRole.role);
|
|
2229
2941
|
lock = acquireRuntimeLock({
|
|
2230
2942
|
stateDir: MANAGER_STATE_DIR,
|
|
2231
2943
|
role: resolvedRole.role,
|
|
2232
2944
|
ownerInstanceMarker: launcherInstanceMarker,
|
|
2945
|
+
ownerStartOrder: launcherStartOrder,
|
|
2946
|
+
staleOwnerProof: replacementCleanup?.staleOwnerProof || null,
|
|
2233
2947
|
openExisting: () => undefined
|
|
2234
2948
|
});
|
|
2235
2949
|
}
|
|
@@ -2242,6 +2956,7 @@ async function main() {
|
|
|
2242
2956
|
stateDir: MANAGER_STATE_DIR,
|
|
2243
2957
|
role: resolvedRole.role,
|
|
2244
2958
|
ownerInstanceMarker: launcherInstanceMarker,
|
|
2959
|
+
ownerStartOrder: launcherStartOrder,
|
|
2245
2960
|
openExisting: () => undefined
|
|
2246
2961
|
});
|
|
2247
2962
|
}
|
|
@@ -2252,6 +2967,14 @@ async function main() {
|
|
|
2252
2967
|
}
|
|
2253
2968
|
process.once('exit', lock.release);
|
|
2254
2969
|
|
|
2970
|
+
// Only the contender that won the replacement lock may clean residual
|
|
2971
|
+
// listener ports. A simultaneous losing launcher must never kill the newly
|
|
2972
|
+
// acquired replacement generation.
|
|
2973
|
+
if (replacementCleanup && resolvedRole.role === 'hub') {
|
|
2974
|
+
const remotePort = normalizePort(process.env.REMOTE_HUB_PORT, DEFAULT_REMOTE_HUB_PORT);
|
|
2975
|
+
await stopProcessesOnPorts([replacementCleanup.runtimePort, remotePort]);
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2255
2978
|
if (resolvedRole.role === 'client') {
|
|
2256
2979
|
const clientRuntimePort = normalizePort(
|
|
2257
2980
|
process.env.LIVEDESK_CLIENT_RUNTIME_PORT || process.env.LIVEDESK_LOCAL_RUNTIME_PORT,
|
|
@@ -2272,7 +2995,7 @@ async function main() {
|
|
|
2272
2995
|
printClientHelp();
|
|
2273
2996
|
return;
|
|
2274
2997
|
}
|
|
2275
|
-
await runClient(runtimeArgs, resolvedRole);
|
|
2998
|
+
await runClient(runtimeArgs, resolvedRole, lock);
|
|
2276
2999
|
return;
|
|
2277
3000
|
}
|
|
2278
3001
|
if (command === 'hub' || command === 'manager') {
|
|
@@ -2280,14 +3003,14 @@ async function main() {
|
|
|
2280
3003
|
printHelp();
|
|
2281
3004
|
return;
|
|
2282
3005
|
}
|
|
2283
|
-
await runManager(runtimeArgs, resolvedRole);
|
|
3006
|
+
await runManager(runtimeArgs, resolvedRole, lock);
|
|
2284
3007
|
return;
|
|
2285
3008
|
}
|
|
2286
3009
|
if (resolvedRole.role === 'client') {
|
|
2287
|
-
await runClient(runtimeArgs, resolvedRole);
|
|
3010
|
+
await runClient(runtimeArgs, resolvedRole, lock);
|
|
2288
3011
|
return;
|
|
2289
3012
|
}
|
|
2290
|
-
await runManager(runtimeArgs, resolvedRole);
|
|
3013
|
+
await runManager(runtimeArgs, resolvedRole, lock);
|
|
2291
3014
|
}
|
|
2292
3015
|
|
|
2293
3016
|
main().catch(error => {
|