livedesk 0.1.446 → 0.1.447

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/livedesk.js 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));
@@ -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: readLinuxProcessStartMarker(processId) || startResult.stdout
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 || processId === process.pid) {
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
- const details = await getProcessDetails(processId);
806
- if (!details) {
807
- 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 };
1103
+ }
1104
+ let details = await detailsPromise;
1105
+
1106
+ const fallbackDetails = await getProcessDetails(processId);
1107
+ if (!fallbackDetails) {
1108
+ if (!isPidAlive(processId)) {
1109
+ return { runtimeProof: null, staleOwnerProof: null };
1110
+ }
1111
+ throw new Error(
1112
+ `Existing runtime pid ${processId} remained alive but its process identity could not be revalidated; `
1113
+ + 'it was preserved.'
1114
+ );
808
1115
  }
1116
+ if (details
1117
+ && (processInstanceMarker(fallbackDetails) !== processInstanceMarker(details)
1118
+ || String(fallbackDetails.startOrder || '') !== String(details.startOrder || ''))) {
1119
+ console.warn(
1120
+ `[LiveDesk] Launcher pid ${processId} changed identity while cooperative cleanup was pending; `
1121
+ + 'the reused process was preserved.'
1122
+ );
1123
+ if (exactStaleOwnerProof) {
1124
+ return { runtimeProof: null, staleOwnerProof: exactStaleOwnerProof };
1125
+ }
1126
+ throw new Error(`Existing runtime pid ${processId} was reused during shutdown; the current process was preserved.`);
1127
+ }
1128
+ details = fallbackDetails;
1129
+ const currentOwnerStartOrder = String(details?.startOrder || '').trim();
1130
+ if (hasCompleteStoredOwner
1131
+ && currentOwnerStartOrder
1132
+ && storedOwnerStartOrder !== currentOwnerStartOrder) {
1133
+ console.warn(
1134
+ `[LiveDesk] Runtime lock pid ${processId} was reused by a different process instance; `
1135
+ + 'the current process was preserved and only the exact stale lock may be reclaimed.'
1136
+ );
1137
+ return { runtimeProof: null, staleOwnerProof: exactStaleOwnerProof };
1138
+ }
1139
+
809
1140
  const commandLineConfirmed = isKnownLiveDeskLauncherProcess(details.processName, details.commandLine);
810
- const lockInstanceConfirmed = String(existing?.ownerToken || '').trim().length >= 16
811
- && String(existing?.ownerInstanceMarker || '').trim()
812
- === processInstanceMarker(details);
813
- const legacyClientTreeConfirmed = !String(existing?.ownerToken || '').trim()
814
- && String(existing?.role || '').trim().toLowerCase() === 'client'
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
- if (!commandLineConfirmed && !lockInstanceConfirmed && !legacyClientTreeConfirmed && !runtimeProof) {
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 = commandLineConfirmed
824
- ? 'command-line'
825
- : lockInstanceConfirmed
826
- ? 'lock-instance'
827
- : legacyClientTreeConfirmed
828
- ? 'legacy-lock-exact-client-descendant'
829
- : `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
+ }
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 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
+ });
833
1230
  } else {
834
1231
  try { process.kill(processId, 'SIGTERM'); } catch (error) {
835
1232
  if (error?.code !== 'ESRCH') throw error;
836
1233
  }
837
- }
838
- try {
839
- await waitForProcessExit(processId);
840
- } catch (error) {
841
- if (os.platform() === 'win32') throw error;
842
- console.warn(
843
- `[LiveDesk] Confirmed launcher pid ${processId} did not finish graceful Client cleanup; `
844
- + 'force-stopping that launcher only. Verified orphan agents/helpers will be audited before restart.'
845
- );
846
- try { process.kill(processId, 'SIGKILL'); } catch (killError) {
847
- 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);
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 runtimeProof = await stopExistingRuntime(existing);
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
- if (role === 'hub' || String(existing?.role || '').trim().toLowerCase() === 'hub') {
858
- const remotePort = normalizePort(process.env.REMOTE_HUB_PORT, DEFAULT_REMOTE_HUB_PORT);
859
- await stopProcessesOnPorts([runtimePort, remotePort]);
860
- } else {
861
- await stopProcessesOnPorts([runtimePort]);
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 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') {
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 = /(?: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);
917
1337
  const isUnifiedLauncher = /(?:node_modules\/livedesk|packages\/livedesk)\/bin\/livedesk\.js/i.test(normalizedCommandLine);
918
1338
  const isLegacyClient = /(?:node_modules\/@livedesk\/client|packages\/client|packages\/livedesk\/client)\/bin\/livedesk-client\.js/i.test(normalizedCommandLine);
919
1339
  return isKnownHubServer || isUnifiedLauncher || isLegacyClient;
@@ -927,7 +1347,7 @@ function formatPortOwner(record) {
927
1347
  return `${record?.Pid || 'unknown'}:${record?.ProcessName || 'unknown'} cmd=${String(record?.CommandLine || 'unavailable').replace(/\s+/g, ' ').slice(0, 240)}`;
928
1348
  }
929
1349
 
930
- function assertPortCleanupReady(finalRecords, portStates) {
1350
+ function assertPortCleanupReady(finalRecords, portStates) {
931
1351
  const blockedPorts = portStates.filter(port => port?.Ready !== true);
932
1352
  if (blockedPorts.length === 0) {
933
1353
  return;
@@ -939,10 +1359,118 @@ function assertPortCleanupReady(finalRecords, portStates) {
939
1359
  .map(formatPortOwner);
940
1360
  return `TCP ${port.Port}[available=${port.PortAvailable === true},bindable=${port.PortBindable === true},bind-error=${port.BindError || 'none'},owners=${owners.length ? owners.join('; ') : 'owner not visible'}`;
941
1361
  }).join('], ');
942
- throw new Error(`LiveDesk Hub startup cleanup could not release the configured port(s): ${summary}]. Unrelated processes are preserved; stop the owning process manually or choose an explicit --port/--remote-port value.`);
943
- }
944
-
945
- async function 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) {
946
1474
  const records = [];
947
1475
  for (const port of ports) {
948
1476
  const lsof = await runQuiet('lsof', ['-nP', '-ti', `tcp:${port}`, '-sTCP:LISTEN']);
@@ -1216,121 +1744,12 @@ async function stopStaleClientAgents() {
1216
1744
  }
1217
1745
  };
1218
1746
 
1219
- const enumerateWindowsParentPairs = async () => {
1220
- const script = '$ErrorActionPreference = "Stop"; @(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId } }) | ConvertTo-Json -Compress';
1221
- const result = await runQuiet(
1222
- 'powershell.exe',
1223
- ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1224
- { timeout: 10000, killSignal: 'SIGKILL' }
1225
- );
1226
- if (!result.ok || !result.stdout) {
1227
- throw new Error(
1228
- `LiveDesk Client lifecycle audit could not snapshot the Windows process tree: `
1229
- + `${result.stderr || result.error?.message || 'CIM query failed'}`
1230
- );
1231
- }
1232
- const parsed = JSON.parse(result.stdout);
1233
- return (Array.isArray(parsed) ? parsed : [parsed]).map(item => ({
1234
- pid: Number(item?.ProcessId || 0),
1235
- parentPid: Number(item?.ParentProcessId || 0)
1236
- }));
1237
- };
1238
-
1239
- const captureWindowsTreeRecords = async rootRecord => {
1240
- const pairs = await enumerateWindowsParentPairs();
1241
- const descendantsByParent = new Map();
1242
- for (const pair of pairs) {
1243
- if (!descendantsByParent.has(pair.parentPid)) descendantsByParent.set(pair.parentPid, []);
1244
- descendantsByParent.get(pair.parentPid).push(pair.pid);
1245
- }
1246
- const processIds = [];
1247
- const queue = [rootRecord.pid];
1248
- const seen = new Set();
1249
- while (queue.length > 0) {
1250
- const pid = queue.shift();
1251
- if (!Number.isInteger(pid) || pid <= 1 || seen.has(pid)) continue;
1252
- seen.add(pid);
1253
- processIds.push(pid);
1254
- queue.push(...(descendantsByParent.get(pid) || []));
1255
- }
1256
- const records = [];
1257
- for (const pid of processIds) {
1258
- const details = await getProcessDetails(pid);
1259
- if (!details) continue;
1260
- const instanceMarker = processInstanceMarker(details);
1261
- if (!instanceMarker) {
1262
- throw new Error(`Windows process-tree verification could not establish a start marker for pid=${pid}.`);
1263
- }
1264
- if (pid !== rootRecord.pid && rootRecord.startOrder && details.startOrder) {
1265
- const rootStart = BigInt(rootRecord.startOrder);
1266
- const descendantStart = BigInt(details.startOrder);
1267
- if (descendantStart < rootStart) {
1268
- throw new Error(
1269
- `Windows process-tree verification found pid=${pid} with reused parent pid=${rootRecord.pid}; `
1270
- + 'the older process was preserved instead of passing an ambiguous tree to taskkill.'
1271
- );
1272
- }
1273
- }
1274
- records.push(pid === rootRecord.pid
1275
- ? rootRecord
1276
- : {
1277
- pid,
1278
- type: 'descendant',
1279
- instanceMarker,
1280
- startOrder: String(details.startOrder || '')
1281
- });
1282
- }
1283
- if (!records.some(record => (
1284
- record.pid === rootRecord.pid && record.instanceMarker === rootRecord.instanceMarker
1285
- ))) {
1286
- return [];
1287
- }
1288
- return records;
1289
- };
1290
-
1291
- const taskkillExactWindowsRecord = async (record, includeTree) => {
1747
+ const terminateWindowsAgentTree = async record => {
1292
1748
  const details = await getMatchingDetails(record);
1293
1749
  if (!details) return;
1294
- const expectedStart = String(details.startMarker || '').replaceAll("'", "''");
1295
- const treeArgument = includeTree ? ', "/T"' : '';
1296
- const script = [
1297
- '$ErrorActionPreference = "Stop";',
1298
- `$targetPid = ${record.pid};`,
1299
- `$expectedStart = '${expectedStart}';`,
1300
- '$target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -ErrorAction SilentlyContinue | Select-Object -First 1;',
1301
- 'if (-not $target) { exit 0 };',
1302
- 'if ([string]$target.CreationDate -ne $expectedStart) { exit 3 };',
1303
- `$arguments = @("/PID", [string]$targetPid${treeArgument}, "/F");`,
1304
- '$taskkill = Start-Process -FilePath "taskkill.exe" -ArgumentList $arguments -NoNewWindow -Wait -PassThru;',
1305
- 'exit $taskkill.ExitCode'
1306
- ].join(' ');
1307
- await runQuiet(
1308
- 'powershell.exe',
1309
- ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1310
- { timeout: 15000, killSignal: 'SIGKILL' }
1311
- );
1312
- };
1313
-
1314
- const terminateWindowsAgentTree = async record => {
1315
- if (!await getMatchingDetails(record)) return;
1316
- const treeRecords = await captureWindowsTreeRecords(record);
1317
- if (treeRecords.length === 0) return;
1318
- await taskkillExactWindowsRecord(record, true);
1319
- let remaining = await waitForExactRecordsExit(treeRecords, 4000);
1320
- if (remaining.length > 0) {
1321
- // These are immutable pre-taskkill descendants of the exact Agent tree,
1322
- // not globally matched ffmpeg processes.
1323
- for (const descendant of [...remaining].reverse()) {
1324
- await taskkillExactWindowsRecord(descendant, true);
1325
- }
1326
- remaining = await waitForExactRecordsExit(remaining, 4000);
1327
- }
1328
- if (remaining.length > 0) {
1329
- throw new Error(
1330
- `verified stale Windows Agent tree pid=${record.pid} retained exact descendant(s): `
1331
- + remaining.map(item => item.pid).join(', ')
1332
- );
1333
- }
1750
+ await terminateExactWindowsProcessTree(details, {
1751
+ label: `verified stale Windows Agent tree pid=${record.pid}`
1752
+ });
1334
1753
  };
1335
1754
 
1336
1755
  const stoppedProcesses = new Map();
@@ -1376,118 +1795,17 @@ async function stopStaleClientAgents() {
1376
1795
 
1377
1796
  async function stopProcessesOnPorts(ports) {
1378
1797
  const uniquePorts = [...new Set(ports.map(port => normalizePort(port, 0)).filter(Boolean))];
1379
- if (uniquePorts.length === 0) {
1380
- return;
1381
- }
1382
-
1383
- if (os.platform() === 'win32') {
1384
- const script = [
1385
- '$ErrorActionPreference = "SilentlyContinue";',
1386
- `$ports = @(${uniquePorts.join(',')});`,
1387
- `$currentNodePid = ${process.pid};`,
1388
- 'function Get-ListenConnections($port) {',
1389
- ' $connections = @(Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue);',
1390
- ' if ($connections.Count -gt 0) { return $connections }',
1391
- ' $pattern = "^\\s*TCP\\s+\\S+:" + $port + "\\s+\\S+\\s+LISTENING\\s+(\\d+)\\s*$";',
1392
- ' foreach ($line in @(netstat -ano -p tcp)) {',
1393
- ' $match = [regex]::Match([string]$line, $pattern);',
1394
- ' if ($match.Success) {',
1395
- ' [pscustomobject]@{ LocalPort = [int]$port; OwningProcess = [int]$match.Groups[1].Value }',
1396
- ' }',
1397
- ' }',
1398
- '}',
1399
- 'function Get-PortRecords($port) {',
1400
- ' $connections = @(Get-ListenConnections $port | Where-Object { $_.OwningProcess -and $_.OwningProcess -ne $currentNodePid -and $_.OwningProcess -ne $PID });',
1401
- ' foreach ($connection in $connections) {',
1402
- ' $owner = [int]$connection.OwningProcess;',
1403
- ' $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $owner" -ErrorAction SilentlyContinue;',
1404
- ' $processName = if ($proc) { [string]$proc.Name } else { "" };',
1405
- ' $commandLine = if ($proc) { [string]$proc.CommandLine } else { "" };',
1406
- ' $isNodeProcess = $processName -match "(?i)^node(?:\\.exe)?$";',
1407
- ' $hasKnownLiveDeskHubPath = $commandLine -match "(?i)(?:packages[\\\\/](?:livedesk[\\\\/])?hub|node_modules[\\\\/]@?livedesk[\\\\/]hub)[\\\\/]src[\\\\/]server\\.js";',
1408
- ' $hasUnifiedLauncherPath = $commandLine -match "(?i)(?:node_modules[\\\\/]livedesk|packages[\\\\/]livedesk)[\\\\/]bin[\\\\/]livedesk\\.js";',
1409
- ' $hasLegacyClientPath = $commandLine -match "(?i)(?:node_modules[\\\\/]@livedesk[\\\\/]client|packages[\\\\/]client|packages[\\\\/]livedesk[\\\\/]client)[\\\\/]bin[\\\\/]livedesk-client\\.js";',
1410
- ' $knownLiveDesk = [bool]($isNodeProcess -and ($hasKnownLiveDeskHubPath -or $hasUnifiedLauncherPath -or $hasLegacyClientPath));',
1411
- ' [pscustomobject]@{ Port = [int]$port; Pid = $owner; ProcessName = $processName; CommandLine = $commandLine; KnownLiveDesk = $knownLiveDesk; Action = "preserved"; KillAttempted = $false }',
1412
- ' }',
1413
- '}',
1414
- '$records = @($ports | ForEach-Object { Get-PortRecords $_ } | Sort-Object Port,Pid -Unique);',
1415
- '$killableOwners = @($records | Where-Object { $_.KnownLiveDesk } | Group-Object Pid | ForEach-Object { @($_.Group)[0] });',
1416
- 'foreach ($owner in $killableOwners) {',
1417
- ' & taskkill.exe /PID $owner.Pid /T /F | Out-Null;',
1418
- ' Stop-Process -Id $owner.Pid -Force -ErrorAction SilentlyContinue;',
1419
- ' foreach ($record in @($records | Where-Object { [int]$_.Pid -eq [int]$owner.Pid })) {',
1420
- ' $record.Action = "stopped";',
1421
- ' $record.KillAttempted = $true;',
1422
- ' }',
1423
- '}',
1424
- `$deadline = (Get-Date).ToUniversalTime().AddMilliseconds(${PORT_CLEANUP_TIMEOUT_MS});`,
1425
- '$finalRecords = @();',
1426
- '$portStates = @();',
1427
- 'do {',
1428
- ' $finalRecords = @($ports | ForEach-Object { Get-PortRecords $_ });',
1429
- ' $busyPorts = @($finalRecords | Select-Object -ExpandProperty Port -Unique);',
1430
- ' $portStates = @($ports | ForEach-Object {',
1431
- ' $port = [int]$_;',
1432
- ' $busy = $busyPorts -contains $port;',
1433
- ' $bindable = [bool](-not $busy);',
1434
- ' [pscustomobject]@{ Port = $port; PortAvailable = [bool](-not $busy); PortBindable = $bindable; Ready = [bool](-not $busy) }',
1435
- ' });',
1436
- ' if ((@($portStates | Where-Object { -not $_.Ready }).Count -eq 0) -or (Get-Date).ToUniversalTime() -ge $deadline) { break }',
1437
- ` Start-Sleep -Milliseconds ${PORT_CLEANUP_POLL_MS};`,
1438
- '} while ($true);',
1439
- '[pscustomobject]@{ Records = @($records); FinalRecords = @($finalRecords); Ports = @($portStates) } | ConvertTo-Json -Compress -Depth 8'
1440
- ].join(' ');
1441
- const result = await runQuiet('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script]);
1442
- if (!result.ok && !result.stdout) {
1443
- throw new Error(`Could not inspect existing LiveDesk Hub ports: ${result.stderr || result.error?.message || 'PowerShell failed'}`);
1444
- }
1445
- let payload = { Records: [], FinalRecords: [], Ports: [] };
1446
- if (result.stdout) {
1447
- try {
1448
- const parsed = JSON.parse(result.stdout);
1449
- payload = Array.isArray(parsed)
1450
- ? { Records: parsed, FinalRecords: parsed, Ports: [] }
1451
- : parsed;
1452
- } catch {
1453
- throw new Error(`Could not parse existing LiveDesk Hub port state: ${result.stdout}`);
1454
- }
1455
- }
1456
- const records = Array.isArray(payload?.Records) ? payload.Records : [];
1457
- const finalRecords = Array.isArray(payload?.FinalRecords) ? payload.FinalRecords : [];
1458
- const rawPortStates = Array.isArray(payload?.Ports) ? payload.Ports : [];
1459
- const rawStatesByPort = new Map(rawPortStates.map(state => [Number(state?.Port), state]));
1460
- const portStates = [];
1461
- for (const port of uniquePorts) {
1462
- const rawState = rawStatesByPort.get(Number(port)) || {};
1463
- const probe = rawState.PortAvailable === true
1464
- ? await waitForPortBindable(port)
1465
- : { ok: false, error: 'port-in-use' };
1466
- portStates.push({
1467
- ...rawState,
1468
- Port: Number(port),
1469
- PortBindable: probe.ok,
1470
- BindError: probe.error || '',
1471
- Ready: rawState.PortAvailable === true && probe.ok
1472
- });
1473
- }
1474
- const stopped = records.filter(record => record?.Action === 'stopped');
1475
- if (stopped.length > 0) {
1476
- const stoppedOwners = new Map();
1477
- for (const record of stopped) {
1478
- if (!stoppedOwners.has(String(record.Pid))) {
1479
- stoppedOwners.set(String(record.Pid), record);
1480
- }
1481
- }
1482
- const summary = [...stoppedOwners.values()].map(record => `${record.Pid}:${record.ProcessName || 'node'}`).join(', ');
1483
- console.log(`Restarting LiveDesk Hub. Stopped stale LiveDesk owner(s) once: ${summary}`);
1484
- }
1485
- assertPortCleanupReady(finalRecords, portStates);
1486
- return;
1487
- }
1488
-
1489
- await stopUnixProcessesOnPorts(uniquePorts);
1490
- }
1798
+ if (uniquePorts.length === 0) {
1799
+ return;
1800
+ }
1801
+
1802
+ if (os.platform() === 'win32') {
1803
+ await stopWindowsProcessesOnPorts(uniquePorts);
1804
+ return;
1805
+ }
1806
+
1807
+ await stopUnixProcessesOnPorts(uniquePorts);
1808
+ }
1491
1809
 
1492
1810
  async function waitForManager(url = DEFAULT_MANAGER_URL, timeoutMs = 10000) {
1493
1811
  const startedAt = Date.now();
@@ -1579,6 +1897,13 @@ function buildHubRestartBootstrapScript() {
1579
1897
  'const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
1580
1898
  'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
1581
1899
  'const numberFromEnv = (name, fallback, minimum) => { const value = Number(process.env[name]); return Number.isFinite(value) && value >= minimum ? Math.round(value) : fallback; };',
1900
+ 'const runWindowsPowerShell = script => new Promise((resolve, reject) => { execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, timeout: 15000, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { reject(new Error(String(stderr || error.message || "PowerShell failed").trim())); return; } resolve(String(stdout || "").trim()); }); });',
1901
+ 'const queryWindowsProcessTable = async () => { if (process.platform !== "win32") return []; const stdout = await runWindowsPowerShell(`$ErrorActionPreference = "Stop"; $records = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object { $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" }; [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationUtcTicks = $ticks } }); [pscustomobject]@{ Records = $records } | ConvertTo-Json -Compress -Depth 4`); if (!stdout) throw new Error("Windows process snapshot returned no data"); const parsed = JSON.parse(stdout); const values = parsed?.Records == null ? [] : (Array.isArray(parsed.Records) ? parsed.Records : [parsed.Records]); return values.map(item => ({ pid: Number(item?.ProcessId || 0), parentPid: Number(item?.ParentProcessId || 0), startOrder: String(item?.CreationUtcTicks || "") })).filter(item => Number.isInteger(item.pid) && item.pid > 1 && /^\\d+$/.test(item.startOrder)); };',
1902
+ 'const sameWindowsIdentity = (left, right) => Number(left?.pid || 0) === Number(right?.pid || 0) && String(left?.startOrder || "") === String(right?.startOrder || "");',
1903
+ 'const captureWindowsIdentity = async pid => { const processId = Number(pid || 0); const deadline = Date.now() + 2500; let lastError = null; do { try { const snapshot = await queryWindowsProcessTable(); const identity = snapshot.find(item => item.pid === processId) || null; if (identity) return identity; } catch (error) { lastError = error; } if (Date.now() < deadline) await sleep(50); } while (Date.now() < deadline); throw new Error(`Could not capture immutable Windows CreationDate for pid=${processId}: ${lastError?.message || "process not visible"}`); };',
1904
+ 'const captureExactWindowsTrees = (identities, snapshot) => { const currentByPid = new Map(snapshot.map(item => [item.pid, item])); const childrenByParent = new Map(); for (const item of snapshot) { if (!childrenByParent.has(item.parentPid)) childrenByParent.set(item.parentPid, []); childrenByParent.get(item.parentPid).push(item); } const tracked = new Map(); for (const root of identities) { const currentRoot = currentByPid.get(Number(root?.pid || 0)); if (!sameWindowsIdentity(currentRoot, root)) continue; const queue = [{ record: currentRoot, depth: 0 }]; const seen = new Set(); while (queue.length > 0) { const current = queue.shift(); if (seen.has(current.record.pid)) continue; seen.add(current.record.pid); const key = `${current.record.pid}:${current.record.startOrder}`; const previous = tracked.get(key); if (!previous || current.depth > previous.depth) tracked.set(key, { ...current.record, depth: current.depth }); for (const child of childrenByParent.get(current.record.pid) || []) { try { if (BigInt(child.startOrder) < BigInt(current.record.startOrder)) continue; } catch { continue; } queue.push({ record: child, depth: current.depth + 1 }); } } } return [...tracked.values()].sort((left, right) => Number(right.depth || 0) - Number(left.depth || 0)); };',
1905
+ 'const stopExactWindowsRecords = async records => { if (!Array.isArray(records) || records.length === 0) return; const encoded = Buffer.from(JSON.stringify(records.map(record => ({ pid: record.pid, startOrder: record.startOrder }))), "utf8").toString("base64"); const script = `$ErrorActionPreference = "Stop"; $targetsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${encoded}")); $targets = ConvertFrom-Json -InputObject $targetsJson; $failures = [System.Collections.Generic.List[string]]::new(); foreach ($item in $targets) { $targetPid = [int]$item.pid; $expectedStartTicks = [string]$item.startOrder; try { $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1; if (-not $target) { continue }; $targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" }; if ($targetStartTicks -ne $expectedStartTicks) { continue }; Stop-Process -Id $targetPid -Force -ErrorAction Stop } catch { $failures.Add("pid=$targetPid $($_.Exception.Message)") } }; if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 }`; await runWindowsPowerShell(script); };',
1906
+ 'const terminateExactWindowsOwnedTrees = async (identities, { skipStop = false } = {}) => { const roots = (Array.isArray(identities) ? identities : []).filter(identity => Number(identity?.pid || 0) > 1 && /^\\d+$/.test(String(identity?.startOrder || ""))); if (roots.length === 0) throw new Error("No immutable Windows replacement identity is available"); const snapshot = await queryWindowsProcessTable(); const targets = captureExactWindowsTrees(roots, snapshot); if (!skipStop) await stopExactWindowsRecords(targets); const finalSnapshot = await queryWindowsProcessTable(); const currentByPid = new Map(finalSnapshot.map(item => [item.pid, item])); const remaining = targets.filter(target => sameWindowsIdentity(currentByPid.get(target.pid), target)); if (remaining.length > 0) throw new Error(`${skipStop ? "simulated exact stop no-op; " : ""}exact pid(s) still alive: ${remaining.map(item => item.pid).join(", ")}`); };',
1582
1907
  'const waitForExit = async (pid, timeoutMs) => { const deadline = Date.now() + timeoutMs; while (pid > 1 && Date.now() < deadline) { if (!isAlive(pid)) return; await sleep(250); } if (pid > 1 && isAlive(pid)) throw new Error(`Previous LiveDesk Hub launcher pid=${pid} did not exit within ${timeoutMs}ms.`); };',
1583
1908
  'const decodeArgs = name => JSON.parse(Buffer.from(process.env[name] || "", "base64").toString("utf8"));',
1584
1909
  'const primary = { command: process.env.LIVEDESK_RESTART_COMMAND || "", args: decodeArgs("LIVEDESK_RESTART_ARGS_BASE64") };',
@@ -1601,14 +1926,118 @@ function buildHubRestartBootstrapScript() {
1601
1926
  'const probeRemotePort = () => new Promise((resolve, reject) => { const socket = net.createConnection({ host: remoteHost, port: remotePort }); const timer = setTimeout(() => socket.destroy(new Error("TCP probe timed out")), Math.min(1500, healthTimeoutMs)); timer.unref?.(); socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(); }); socket.once("error", error => { clearTimeout(timer); reject(error); }); });',
1602
1927
  'const probeReplacement = async requiredVersion => { const [status, health] = await Promise.all([requestJson("/api/runtime/status"), requestJson("/api/health")]); const runtimePid = Number(status?.runtimePid || 0); if (status?.role !== "hub" || status?.runtimeStarted !== true || runtimePid <= 1) throw new Error("replacement runtime status is not ready"); if (previousRuntimePid > 1 && runtimePid === previousRuntimePid) throw new Error(`old runtime pid=${runtimePid} is still answering`); if (requiredVersion && String(status?.appVersion || "") !== requiredVersion) throw new Error(`version mismatch expected=${requiredVersion} actual=${status?.appVersion || "unknown"}`); if (health?.ok !== true || health?.product !== "LiveDesk") throw new Error("replacement health response is invalid"); await probeRemotePort(); return { runtimePid, appVersion: String(status?.appVersion || "") }; };',
1603
1928
  'const terminationError = (pid, detail) => { const error = new Error(`Unverified replacement pid=${pid} could not be terminated${detail ? `: ${detail}` : ""}`); error.code = "LIVEDESK_HUB_RESTART_TERMINATION_FAILED"; return error; };',
1604
- 'const terminateTree = async (child, runtimePid = 0) => { 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"})`); };',
1605
- '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
+ '};',
1606
2034
  '(async () => { const targetErrors = []; const fallbackErrors = []; if (exitBeforeHandshakeOperationId === operationId) { log("stage=test-exit-before-handshake"); process.exitCode = 86; return; } if (delayHandshakeOperationId === operationId) { const claimDelayMs = numberFromEnv("LIVEDESK_TEST_HUB_RESTART_HANDSHAKE_DELAY_MS", 2000, 1); log(`stage=test-delay-before-handshake delayMs=${claimDelayMs}`); await sleep(claimDelayMs); } writeResult("waiting-for-old-launcher", { outcome: "in-progress" }); log(`stage=waiting-for-old-launcher pid=${waitPid} supervisorPid=${process.pid}`); if (exitAfterHandshakeOperationId === operationId) { log("stage=test-exit-after-handshake"); process.exitCode = 87; return; } await waitForExit(waitPid, 60000); writeResult("old-launcher-exited", { outcome: "in-progress" }); log("stage=old-launcher-exited"); for (let attempt = 1; attempt <= attemptLimit; attempt += 1) { writeResult("target-attempt", { outcome: "in-progress", attempt }); try { const status = await launchAndVerify(primary, "target", expectedVersion, attempt); writeResult("updated", { outcome: "updated", attempt, runtimePid: status.runtimePid, launcherPid: status.launcherPid, appVersion: status.appVersion, completedAt: new Date().toISOString(), error: undefined }); log("stage=completed"); return; } catch (error) { if (["LIVEDESK_HUB_UPDATE_SUPERSEDED", "LIVEDESK_HUB_RESTART_TERMINATION_FAILED"].includes(error?.code)) throw error; targetErrors.push(error.message); writeResult("target-failed", { outcome: "in-progress", attempt, error: error.message }); log(`target attempt=${attempt} stage=failed error=${error.message}`); } } log(`stage=fallback-start target=${fallbackVersion || "current"}`); for (let attempt = 1; attempt <= 2; attempt += 1) { writeResult("fallback-attempt", { outcome: "in-progress", attempt, error: targetErrors.join(" | ") }); try { const status = await launchAndVerify(fallback, "fallback", fallbackVersion, attempt); writeResult("restored", { outcome: "restored", attempt, runtimePid: status.runtimePid, launcherPid: status.launcherPid, appVersion: status.appVersion, completedAt: new Date().toISOString(), error: `Target Hub ${expectedVersion} failed verification: ${targetErrors.join(" | ")}` }); log("stage=completed-with-fallback"); return; } catch (error) { if (["LIVEDESK_HUB_UPDATE_SUPERSEDED", "LIVEDESK_HUB_RESTART_TERMINATION_FAILED"].includes(error?.code)) throw error; fallbackErrors.push(error.message); writeResult("fallback-failed", { outcome: "in-progress", attempt, error: [...targetErrors, ...fallbackErrors].join(" | ") }); log(`fallback attempt=${attempt} stage=failed error=${error.message}`); } } throw new Error(`No verified LiveDesk Hub replacement could be started. Target failures: ${targetErrors.join(" | ") || "none"}. Fallback failures: ${fallbackErrors.join(" | ") || "none"}. Inspect ${logPath || "the Hub restart log"} and start the frozen local LiveDesk Hub manually.`); })().catch(error => { if (error?.code === "LIVEDESK_HUB_UPDATE_SUPERSEDED") { log("stage=bootstrap-superseded error=" + error.message); return; } try { writeResult("failed", { outcome: "failed", completedAt: new Date().toISOString(), error: error.message }); } catch (writeError) { log("stage=result-write-failed resultStage=failed error=" + writeError.message); } log("stage=bootstrap-failed error=" + error.message); process.exitCode = 1; });',
1607
2035
  ''
1608
2036
  ].join('\n');
1609
2037
  }
1610
2038
 
1611
- async function runManager(args, resolvedRole = null) {
2039
+ async function runManager(args, resolvedRole = null, runtimeLock = null) {
2040
+ const runtimeOwner = runtimeLock?.payload || null;
1612
2041
  const options = parseManagerArgs(args);
1613
2042
  const httpPort = normalizePort(
1614
2043
  options.port || process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT,
@@ -1697,6 +2126,91 @@ async function runManager(args, resolvedRole = null) {
1697
2126
  let updatePollId = null;
1698
2127
  let updateStopTimer = null;
1699
2128
  let updateHardStopTimer = null;
2129
+ let launcherShutdownSignal = '';
2130
+ let launcherShutdownHardStopTimer = null;
2131
+ let runtimeShutdownWatcher = null;
2132
+ const launcherSignalHandlers = new Map();
2133
+
2134
+ const removeLauncherSignalHandlers = () => {
2135
+ for (const [signal, handler] of launcherSignalHandlers) {
2136
+ process.removeListener(signal, handler);
2137
+ }
2138
+ launcherSignalHandlers.clear();
2139
+ };
2140
+
2141
+ const requestLauncherShutdown = signal => {
2142
+ if (launcherShutdownSignal) return;
2143
+ launcherShutdownSignal = signal;
2144
+ restartRequest = null;
2145
+ if (updatePollId) {
2146
+ clearInterval(updatePollId);
2147
+ updatePollId = null;
2148
+ }
2149
+ if (updateStopTimer) {
2150
+ clearTimeout(updateStopTimer);
2151
+ updateStopTimer = null;
2152
+ }
2153
+ if (updateHardStopTimer) {
2154
+ clearTimeout(updateHardStopTimer);
2155
+ updateHardStopTimer = null;
2156
+ }
2157
+ const child = activeChild;
2158
+ if (!child || child.exitCode !== null || child.signalCode) {
2159
+ try { hubLogStream?.end(); } catch {}
2160
+ process.exit(0);
2161
+ return;
2162
+ }
2163
+ reportLauncherUpdate(
2164
+ `[LiveDesk Hub] Launcher received ${signal}; requesting graceful Hub shutdown for pid=${child.pid || 'unknown'}.`
2165
+ );
2166
+ void (async () => {
2167
+ const gracefulDeadline = Date.now() + 2_000;
2168
+ let lastError = null;
2169
+ while (child.exitCode === null
2170
+ && !child.signalCode
2171
+ && Date.now() < gracefulDeadline) {
2172
+ try {
2173
+ const response = await fetch(`${hubProbeBaseUrl}/api/runtime/shutdown`, {
2174
+ method: 'POST',
2175
+ signal: AbortSignal.timeout(750)
2176
+ });
2177
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2178
+ reportLauncherUpdate(
2179
+ `[LiveDesk Hub] Owned Hub pid=${child.pid || 'unknown'} acknowledged graceful shutdown.`
2180
+ );
2181
+ return;
2182
+ } catch (error) {
2183
+ lastError = error;
2184
+ await waitMilliseconds(100);
2185
+ }
2186
+ }
2187
+ if (child.exitCode !== null || child.signalCode) return;
2188
+ reportLauncherUpdate(
2189
+ `[LiveDesk Hub] Graceful shutdown API remained unavailable for pid=${child.pid || 'unknown'}`
2190
+ + `${lastError?.message ? ` (${lastError.message})` : ''}; exact child fallback remains armed.`,
2191
+ true
2192
+ );
2193
+ if (process.platform !== 'win32') {
2194
+ try { child.kill('SIGTERM'); } catch { /* exact owned child already exited */ }
2195
+ }
2196
+ })();
2197
+ launcherShutdownHardStopTimer = setTimeout(() => {
2198
+ if (child.exitCode !== null || child.signalCode) return;
2199
+ reportLauncherUpdate(
2200
+ `[LiveDesk Hub] Owned Hub pid=${child.pid || 'unknown'} exceeded graceful shutdown; `
2201
+ + 'force-stopping that exact child root.',
2202
+ true
2203
+ );
2204
+ try { child.kill('SIGKILL'); } catch { /* exact owned child already exited */ }
2205
+ }, 12_000);
2206
+ launcherShutdownHardStopTimer.unref?.();
2207
+ };
2208
+
2209
+ for (const signal of ['SIGINT', 'SIGTERM']) {
2210
+ const handler = () => requestLauncherShutdown(signal);
2211
+ launcherSignalHandlers.set(signal, handler);
2212
+ process.on(signal, handler);
2213
+ }
1700
2214
 
1701
2215
  const restartWithLatest = async request => {
1702
2216
  const requestedVersion = normalizeExactUpdateVersion(request?.latestVersion);
@@ -1892,13 +2406,16 @@ async function runManager(args, resolvedRole = null) {
1892
2406
  try { updateChild.kill('SIGTERM'); } catch { /* child already exited */ }
1893
2407
  updateHardStopTimer = setTimeout(() => {
1894
2408
  if (!updateChild || updateChild.exitCode !== null || updateChild.signalCode) return;
1895
- reportLauncherUpdate(`[LiveDesk Hub] Update shutdown hard fallback: Hub pid=${requestPid} ignored graceful termination; force-stopping 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);
1896
2410
  if (process.platform === 'win32') {
1897
- execFile('taskkill.exe', ['/PID', String(requestPid), '/T', '/F'], { windowsHide: true }, error => {
1898
- if (error && updateChild.exitCode === null && !updateChild.signalCode) {
1899
- 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);
1900
2417
  }
1901
- });
2418
+ }
1902
2419
  } else {
1903
2420
  try { updateChild.kill('SIGKILL'); } catch { /* child already exited */ }
1904
2421
  }
@@ -1952,6 +2469,17 @@ async function runManager(args, resolvedRole = null) {
1952
2469
  });
1953
2470
  child.once('exit', (code, signal) => {
1954
2471
  const startupOutput = stderrChunks.join('');
2472
+ if (launcherShutdownSignal) {
2473
+ if (launcherShutdownHardStopTimer) {
2474
+ clearTimeout(launcherShutdownHardStopTimer);
2475
+ launcherShutdownHardStopTimer = null;
2476
+ }
2477
+ runtimeShutdownWatcher?.stop();
2478
+ removeLauncherSignalHandlers();
2479
+ try { hubLogStream?.end(); } catch {}
2480
+ process.exit(0);
2481
+ return;
2482
+ }
1955
2483
  if (restartRequest) {
1956
2484
  const request = restartRequest;
1957
2485
  restartRequest = null;
@@ -2002,23 +2530,38 @@ async function runManager(args, resolvedRole = null) {
2002
2530
  });
2003
2531
  return;
2004
2532
  }
2005
- if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
2533
+ if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
2006
2534
  if (updatePollId) {
2007
2535
  clearInterval(updatePollId);
2008
2536
  updatePollId = null;
2009
2537
  }
2010
- 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({
2011
2552
  role: 'client',
2012
2553
  deviceId: resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || '',
2013
2554
  deviceName: resolvedRole?.deviceName || os.hostname(),
2014
2555
  source: 'supabase',
2015
2556
  isOfflineFallback: false
2016
- }, { stateDir: MANAGER_STATE_DIR });
2017
- console.log('[LiveDesk Hub] Role transition accepted. Starting the Client runtime.');
2018
- void runClient([], { ...resolvedRole, role: 'client', source: 'supabase', roleTransition: true }).catch(error => {
2019
- console.error(`Failed to start LiveDesk Client runtime: ${error?.message || error}`);
2020
- process.exitCode = 1;
2021
- });
2557
+ }, { stateDir: MANAGER_STATE_DIR });
2558
+ console.log('[LiveDesk Hub] Role transition accepted. Starting the Client runtime.');
2559
+ runtimeShutdownWatcher?.stop();
2560
+ removeLauncherSignalHandlers();
2561
+ void runClient([], { ...resolvedRole, role: 'client', source: 'supabase', roleTransition: true }, runtimeLock).catch(error => {
2562
+ console.error(`Failed to start LiveDesk Client runtime: ${error?.message || error}`);
2563
+ process.exitCode = 1;
2564
+ });
2022
2565
  return;
2023
2566
  }
2024
2567
  if (!signal
@@ -2047,8 +2590,19 @@ async function runManager(args, resolvedRole = null) {
2047
2590
  }
2048
2591
  process.exit(code ?? 0);
2049
2592
  });
2050
- };
2051
-
2593
+ };
2594
+
2595
+ runtimeShutdownWatcher = startOwnedRuntimeShutdownWatcher(runtimeOwner, async request => {
2596
+ reportLauncherUpdate(
2597
+ `[LiveDesk Hub] Cooperative replacement request accepted `
2598
+ + `request=${request.requestId} reason=${request.reason}.`
2599
+ );
2600
+ requestLauncherShutdown('COOPERATIVE_REPLACEMENT');
2601
+ // The launcher signal handler keeps ownership until the Hub child has
2602
+ // closed its sockets, host lease, and HTTP server. Process exit is the
2603
+ // requester's completion proof; do not publish a premature completed ACK.
2604
+ await new Promise(() => undefined);
2605
+ });
2052
2606
  startHubProcess();
2053
2607
  updatePollId = setInterval(pollUpdateRequest, HUB_UPDATE_POLL_MS);
2054
2608
 
@@ -2080,7 +2634,8 @@ async function runManager(args, resolvedRole = null) {
2080
2634
  }
2081
2635
  }
2082
2636
 
2083
- async function runClient(args, resolvedRole = null) {
2637
+ async function runClient(args, resolvedRole = null, runtimeLock = null) {
2638
+ const runtimeOwner = runtimeLock?.payload || null;
2084
2639
  const internalClientEntry = resolve(packageRoot, 'client', 'bin', 'livedesk-client.js');
2085
2640
  if (!existsSync(internalClientEntry)) {
2086
2641
  throw new Error('The published livedesk package is missing its internal Client runtime. Rebuild the package with npm run build.');
@@ -2105,14 +2660,35 @@ async function runClient(args, resolvedRole = null) {
2105
2660
  ? '1'
2106
2661
  : String(process.env.LIVEDESK_SKIP_BROWSER_OPEN || ''),
2107
2662
  LIVEDESK_DEVICE_ID: String(resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || ''),
2108
- LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
2109
- });
2663
+ LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || ''),
2664
+ LIVEDESK_RUNTIME_OWNER_PID: String(runtimeOwner?.pid || ''),
2665
+ LIVEDESK_RUNTIME_OWNER_TOKEN: String(runtimeOwner?.ownerToken || ''),
2666
+ LIVEDESK_RUNTIME_OWNER_INSTANCE_MARKER: String(runtimeOwner?.ownerInstanceMarker || ''),
2667
+ LIVEDESK_RUNTIME_OWNER_START_ORDER: String(runtimeOwner?.ownerStartOrder || '')
2668
+ });
2110
2669
  const clientModule = await import(pathToFileURL(clientEntry).href);
2111
- if (typeof clientModule.runClientRuntime !== 'function') {
2112
- throw new Error('The unified Client runtime module does not expose runClientRuntime().');
2113
- }
2114
- await clientModule.runClientRuntime(clientArgs);
2115
- }
2670
+ if (typeof clientModule.runClientRuntime !== 'function') {
2671
+ throw new Error('The unified Client runtime module does not expose runClientRuntime().');
2672
+ }
2673
+ const runtimeShutdownWatcher = startOwnedRuntimeShutdownWatcher(runtimeOwner, async request => {
2674
+ console.log(
2675
+ `[LiveDesk Client] Cooperative replacement request accepted `
2676
+ + `request=${request.requestId} reason=${request.reason}. Draining the owned capture tree.`
2677
+ );
2678
+ if (typeof clientModule.requestClientRuntimeShutdown !== 'function') {
2679
+ throw new Error('The unified Client runtime does not expose direct graceful shutdown.');
2680
+ }
2681
+ clientModule.requestClientRuntimeShutdown('SIGTERM');
2682
+ // installAgentTerminationHandlers owns the terminal cleanup contract and
2683
+ // exits only after RemoteFast/Node plus SCK/FFmpeg descendants are gone.
2684
+ await new Promise(() => undefined);
2685
+ });
2686
+ try {
2687
+ await clientModule.runClientRuntime(clientArgs);
2688
+ } finally {
2689
+ runtimeShutdownWatcher?.stop();
2690
+ }
2691
+ }
2116
2692
 
2117
2693
  async function runDesktopRoleBootstrap() {
2118
2694
  const bootstrap = createRoleBootstrapServer({
@@ -2208,6 +2784,7 @@ async function main() {
2208
2784
  }
2209
2785
  const launcherProcessDetails = await getProcessDetails(process.pid);
2210
2786
  const launcherInstanceMarker = processInstanceMarker(launcherProcessDetails);
2787
+ const launcherStartOrder = String(launcherProcessDetails?.startOrder || '').trim();
2211
2788
  const reportExistingRuntime = existing => {
2212
2789
  const port = DEFAULT_MANAGER_HTTP_PORT;
2213
2790
  openBrowser(`http://127.0.0.1:${port}`);
@@ -2219,17 +2796,21 @@ async function main() {
2219
2796
  stateDir: MANAGER_STATE_DIR,
2220
2797
  role: resolvedRole.role,
2221
2798
  ownerInstanceMarker: launcherInstanceMarker,
2799
+ ownerStartOrder: launcherStartOrder,
2222
2800
  openExisting: () => undefined
2223
2801
  });
2802
+ let replacementCleanup = null;
2224
2803
 
2225
2804
  const sameRoleRuntimeIsRunning = !lock.acquired
2226
2805
  && String(lock.existing?.role || '').trim().toLowerCase() === String(resolvedRole.role || '').trim().toLowerCase();
2227
2806
  if (!lock.acquired && sameRoleRuntimeIsRunning && !topLevel.noRestartExisting) {
2228
- await restartExistingRuntime(lock.existing, resolvedRole.role);
2807
+ replacementCleanup = await restartExistingRuntime(lock.existing, resolvedRole.role);
2229
2808
  lock = acquireRuntimeLock({
2230
2809
  stateDir: MANAGER_STATE_DIR,
2231
2810
  role: resolvedRole.role,
2232
2811
  ownerInstanceMarker: launcherInstanceMarker,
2812
+ ownerStartOrder: launcherStartOrder,
2813
+ staleOwnerProof: replacementCleanup?.staleOwnerProof || null,
2233
2814
  openExisting: () => undefined
2234
2815
  });
2235
2816
  }
@@ -2242,6 +2823,7 @@ async function main() {
2242
2823
  stateDir: MANAGER_STATE_DIR,
2243
2824
  role: resolvedRole.role,
2244
2825
  ownerInstanceMarker: launcherInstanceMarker,
2826
+ ownerStartOrder: launcherStartOrder,
2245
2827
  openExisting: () => undefined
2246
2828
  });
2247
2829
  }
@@ -2252,6 +2834,14 @@ async function main() {
2252
2834
  }
2253
2835
  process.once('exit', lock.release);
2254
2836
 
2837
+ // Only the contender that won the replacement lock may clean residual
2838
+ // listener ports. A simultaneous losing launcher must never kill the newly
2839
+ // acquired replacement generation.
2840
+ if (replacementCleanup && resolvedRole.role === 'hub') {
2841
+ const remotePort = normalizePort(process.env.REMOTE_HUB_PORT, DEFAULT_REMOTE_HUB_PORT);
2842
+ await stopProcessesOnPorts([replacementCleanup.runtimePort, remotePort]);
2843
+ }
2844
+
2255
2845
  if (resolvedRole.role === 'client') {
2256
2846
  const clientRuntimePort = normalizePort(
2257
2847
  process.env.LIVEDESK_CLIENT_RUNTIME_PORT || process.env.LIVEDESK_LOCAL_RUNTIME_PORT,
@@ -2272,7 +2862,7 @@ async function main() {
2272
2862
  printClientHelp();
2273
2863
  return;
2274
2864
  }
2275
- await runClient(runtimeArgs, resolvedRole);
2865
+ await runClient(runtimeArgs, resolvedRole, lock);
2276
2866
  return;
2277
2867
  }
2278
2868
  if (command === 'hub' || command === 'manager') {
@@ -2280,14 +2870,14 @@ async function main() {
2280
2870
  printHelp();
2281
2871
  return;
2282
2872
  }
2283
- await runManager(runtimeArgs, resolvedRole);
2873
+ await runManager(runtimeArgs, resolvedRole, lock);
2284
2874
  return;
2285
2875
  }
2286
2876
  if (resolvedRole.role === 'client') {
2287
- await runClient(runtimeArgs, resolvedRole);
2877
+ await runClient(runtimeArgs, resolvedRole, lock);
2288
2878
  return;
2289
2879
  }
2290
- await runManager(runtimeArgs, resolvedRole);
2880
+ await runManager(runtimeArgs, resolvedRole, lock);
2291
2881
  }
2292
2882
 
2293
2883
  main().catch(error => {