livedesk 0.1.445 → 0.1.447

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