livedesk 0.1.388 → 0.1.389

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
@@ -27,13 +27,250 @@ const PORT_CLEANUP_TIMEOUT_MS = 3000;
27
27
  const PORT_CLEANUP_POLL_MS = 100;
28
28
  const EXISTING_RUNTIME_STOP_TIMEOUT_MS = 10000;
29
29
  const HUB_STARTUP_RETRY_LIMIT = 1;
30
- const HUB_UPDATE_POLL_MS = 250;
31
- const MANAGER_STATE_DIR = process.env.LIVEDESK_STATE_DIR || join(os.homedir(), '.livedesk');
30
+ const HUB_UPDATE_POLL_MS = 250;
31
+ const HUB_UPDATE_SUPERVISOR_HANDSHAKE_TIMEOUT_MS = 10_000;
32
+ const HUB_UPDATE_SUPERVISOR_HANDSHAKE_POLL_MS = 25;
33
+ const HUB_UPDATE_SUPERVISOR_CLAIM_STABILITY_MS = 350;
34
+ const UPDATE_ORIGINAL_CWD = resolve(
35
+ String(process.env.LIVEDESK_UPDATE_ORIGINAL_CWD || process.cwd()).trim() || process.cwd()
36
+ );
37
+ const MANAGER_STATE_DIR = resolve(
38
+ UPDATE_ORIGINAL_CWD,
39
+ process.env.LIVEDESK_STATE_DIR || join(os.homedir(), '.livedesk')
40
+ );
32
41
  const MANAGER_STATE_PATH = join(MANAGER_STATE_DIR, 'manager.json');
42
+ const TERMINAL_HUB_UPDATE_OUTCOMES = new Set(['updated', 'restored', 'failed']);
43
+
44
+ function safeUpdatePathSegment(value, fallback = 'unknown') {
45
+ return String(value || '')
46
+ .replace(/[^A-Za-z0-9._-]/g, '_')
47
+ .slice(0, 120) || fallback;
48
+ }
49
+
50
+ function normalizeExactUpdateVersion(value) {
51
+ const version = String(value || '').trim();
52
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)
53
+ ? version
54
+ : '';
55
+ }
56
+
57
+ function prepareUpdateNeutralCwd(kind, operationId) {
58
+ const neutralCwd = join(
59
+ os.tmpdir(),
60
+ 'livedesk-update-cwd',
61
+ `${safeUpdatePathSegment(kind, 'update')}-${safeUpdatePathSegment(operationId)}`
62
+ );
63
+ mkdirSync(neutralCwd, { recursive: true });
64
+ for (let ancestor = neutralCwd; ; ancestor = dirname(ancestor)) {
65
+ const shadowPath = ['package.json', join('node_modules', 'livedesk', 'package.json')]
66
+ .map(relativePath => join(ancestor, relativePath))
67
+ .find(candidate => existsSync(candidate));
68
+ if (shadowPath) {
69
+ throw new Error(
70
+ `LiveDesk update neutral working directory is shadowed by ${shadowPath}: ${neutralCwd}`
71
+ );
72
+ }
73
+ const parent = dirname(ancestor);
74
+ if (parent === ancestor) break;
75
+ }
76
+ return neutralCwd;
77
+ }
78
+
79
+ function buildNeutralNpxEnvironment(baseEnv, neutralCwd) {
80
+ const env = { ...baseEnv };
81
+ const isolatedNames = new Set([
82
+ 'init_cwd',
83
+ 'npm_config_local_prefix',
84
+ 'npm_config_workspace',
85
+ 'npm_config_workspaces',
86
+ 'npm_config_include_workspace_root',
87
+ 'npm_package_json',
88
+ 'npm_lifecycle_event',
89
+ 'npm_lifecycle_script'
90
+ ]);
91
+ for (const key of Object.keys(env)) {
92
+ if (isolatedNames.has(key.toLowerCase())) delete env[key];
93
+ }
94
+ env.INIT_CWD = neutralCwd;
95
+ env.npm_config_local_prefix = neutralCwd;
96
+ env.npm_config_include_workspace_root = 'false';
97
+ return env;
98
+ }
99
+
100
+ function restoreUpdateOriginalCwd() {
101
+ if (!process.env.LIVEDESK_UPDATE_ORIGINAL_CWD) return;
102
+ try {
103
+ process.chdir(UPDATE_ORIGINAL_CWD);
104
+ } catch (error) {
105
+ throw new Error(
106
+ `LiveDesk could not restore the pre-update working directory ${UPDATE_ORIGINAL_CWD}: `
107
+ + `${error instanceof Error ? error.message : String(error)}`
108
+ );
109
+ }
110
+ }
111
+
112
+ function readJsonFile(filePath) {
113
+ try {
114
+ return JSON.parse(readFileSync(filePath, 'utf8'));
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function isPidAlive(pid) {
121
+ const processId = Number(pid);
122
+ if (!Number.isInteger(processId) || processId <= 1) return false;
123
+ try {
124
+ process.kill(processId, 0);
125
+ return true;
126
+ } catch (error) {
127
+ return error?.code === 'EPERM';
128
+ }
129
+ }
130
+
131
+ function canClaimHubUpdateResult(current, next) {
132
+ const currentOperationId = String(current?.operationId || '');
133
+ const nextOperationId = String(next?.operationId || '');
134
+ if (!currentOperationId || !nextOperationId || currentOperationId === nextOperationId) return true;
135
+ const isClaimStage = ['supervisor-starting', 'preflight-failed'].includes(String(next?.stage || ''));
136
+ if (!isClaimStage) return false;
137
+ if (TERMINAL_HUB_UPDATE_OUTCOMES.has(String(current?.outcome || ''))) return true;
138
+ const ownerPids = [
139
+ Number(current?.supervisorPid || 0),
140
+ Number(current?.launcherPid || 0)
141
+ ].filter(pid => Number.isInteger(pid) && pid > 1);
142
+ return ownerPids.length > 0 && ownerPids.every(pid => !isPidAlive(pid));
143
+ }
144
+
145
+ function resultRevision(value) {
146
+ return JSON.stringify([
147
+ String(value?.operationId || ''),
148
+ String(value?.stage || ''),
149
+ String(value?.outcome || ''),
150
+ String(value?.updatedAt || '')
151
+ ]);
152
+ }
153
+
154
+ function delay(milliseconds) {
155
+ return new Promise(resolveDelay => setTimeout(resolveDelay, milliseconds));
156
+ }
157
+
158
+ function readBoundedMilliseconds(value, fallback, minimum = 1) {
159
+ const parsed = Number(value);
160
+ return Number.isFinite(parsed) && parsed >= minimum ? Math.round(parsed) : fallback;
161
+ }
162
+
163
+ async function stopUnclaimedHubRestartSupervisor(child) {
164
+ const supervisorPid = Number(child?.pid || 0);
165
+ if (supervisorPid <= 1 || !isPidAlive(supervisorPid)) return;
166
+ try {
167
+ child.kill('SIGKILL');
168
+ } catch {
169
+ // The bounded liveness check below decides whether recovery is safe.
170
+ }
171
+ const deadline = Date.now() + 2_000;
172
+ while (Date.now() < deadline && isPidAlive(supervisorPid)) {
173
+ await delay(HUB_UPDATE_SUPERVISOR_HANDSHAKE_POLL_MS);
174
+ }
175
+ if (isPidAlive(supervisorPid)) {
176
+ throw new Error(
177
+ `Unclaimed Hub restart supervisor pid=${supervisorPid} could not be stopped; `
178
+ + 'the current launcher will remain alive and keep the unchanged Hub running.'
179
+ );
180
+ }
181
+ }
182
+
183
+ async function waitForHubRestartSupervisorClaim(child, expected) {
184
+ let exit = null;
185
+ let spawnError = null;
186
+ let spawned = false;
187
+ child.once('spawn', () => {
188
+ spawned = true;
189
+ });
190
+ child.once('error', error => {
191
+ spawnError = error;
192
+ });
193
+ child.once('exit', (code, signal) => {
194
+ exit = { code, signal };
195
+ });
196
+
197
+ const spawnDeadline = Date.now() + expected.timeoutMs;
198
+ while (!spawned && !spawnError && !exit && Date.now() < spawnDeadline) {
199
+ await delay(HUB_UPDATE_SUPERVISOR_HANDSHAKE_POLL_MS);
200
+ }
201
+ if (spawnError) {
202
+ throw new Error(`Hub restart supervisor spawn failed: ${spawnError.message}`);
203
+ }
204
+ if (!spawned) {
205
+ const detail = exit
206
+ ? `exited code=${exit.code ?? 'none'} signal=${exit.signal || 'none'}`
207
+ : `did not emit spawn within ${expected.timeoutMs}ms`;
208
+ throw new Error(`Hub restart supervisor ${detail} before claiming the handoff.`);
209
+ }
210
+
211
+ const supervisorPid = Number(child.pid || 0);
212
+ const claimDeadline = Date.now() + expected.timeoutMs;
213
+ let matchingClaimSince = 0;
214
+ while (Date.now() < claimDeadline) {
215
+ if (spawnError) {
216
+ throw new Error(`Hub restart supervisor failed before handoff: ${spawnError.message}`);
217
+ }
218
+ if (exit || !isPidAlive(supervisorPid)) {
219
+ throw new Error(
220
+ `Hub restart supervisor pid=${supervisorPid || 'unknown'} exited before handoff `
221
+ + `(code=${exit?.code ?? 'none'} signal=${exit?.signal || 'none'}).`
222
+ );
223
+ }
224
+ const claim = readJsonFile(expected.resultPath);
225
+ if (
226
+ claim?.operationId === expected.operationId
227
+ && claim?.stage === 'waiting-for-old-launcher'
228
+ && claim?.outcome === 'in-progress'
229
+ && Number(claim?.supervisorPid || 0) === supervisorPid
230
+ && Number(claim?.launcherPid || 0) === expected.launcherPid
231
+ && claim?.targetVersion === expected.targetVersion
232
+ && claim?.handoffToken === expected.handoffToken
233
+ ) {
234
+ if (!matchingClaimSince) matchingClaimSince = Date.now();
235
+ if (Date.now() - matchingClaimSince >= expected.stabilityMs) {
236
+ await delay(HUB_UPDATE_SUPERVISOR_HANDSHAKE_POLL_MS);
237
+ const stableClaim = readJsonFile(expected.resultPath);
238
+ if (
239
+ !exit
240
+ && isPidAlive(supervisorPid)
241
+ && stableClaim?.operationId === expected.operationId
242
+ && stableClaim?.stage === 'waiting-for-old-launcher'
243
+ && stableClaim?.outcome === 'in-progress'
244
+ && Number(stableClaim?.supervisorPid || 0) === supervisorPid
245
+ && Number(stableClaim?.launcherPid || 0) === expected.launcherPid
246
+ && stableClaim?.targetVersion === expected.targetVersion
247
+ && stableClaim?.handoffToken === expected.handoffToken
248
+ ) {
249
+ return stableClaim;
250
+ }
251
+ matchingClaimSince = 0;
252
+ }
253
+ } else {
254
+ matchingClaimSince = 0;
255
+ }
256
+ await delay(HUB_UPDATE_SUPERVISOR_HANDSHAKE_POLL_MS);
257
+ }
258
+ throw new Error(
259
+ `Hub restart supervisor pid=${supervisorPid || 'unknown'} did not atomically claim `
260
+ + `operation ${expected.operationId} within ${expected.timeoutMs}ms.`
261
+ );
262
+ }
33
263
 
34
264
  function writeJsonAtomic(filePath, value) {
35
265
  if (!filePath) return;
36
266
  mkdirSync(dirname(filePath), { recursive: true });
267
+ const previous = readJsonFile(filePath);
268
+ if (!canClaimHubUpdateResult(previous, value)) {
269
+ throw new Error(
270
+ `Hub update result is owned by ${previous?.operationId || 'another operation'}; `
271
+ + `${value?.operationId || 'unknown'} cannot overwrite it.`
272
+ );
273
+ }
37
274
  const temporaryPath = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
38
275
  let renamed = false;
39
276
  try {
@@ -41,6 +278,12 @@ function writeJsonAtomic(filePath, value) {
41
278
  encoding: 'utf8',
42
279
  mode: 0o600
43
280
  });
281
+ const current = readJsonFile(filePath);
282
+ if (resultRevision(current) !== resultRevision(previous)) {
283
+ throw new Error(
284
+ `Hub update result ownership changed before ${value?.operationId || 'unknown'} could commit.`
285
+ );
286
+ }
44
287
  renameSync(temporaryPath, filePath);
45
288
  renamed = true;
46
289
  } finally {
@@ -793,8 +1036,8 @@ function resolveNpxInvocation(npxArgs) {
793
1036
  function buildHubRestartBootstrapScript() {
794
1037
  return [
795
1038
  'const { execFile, spawn } = require("node:child_process");',
796
- 'const { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } = require("node:fs");',
797
- 'const { dirname } = require("node:path");',
1039
+ 'const { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } = require("node:fs");',
1040
+ 'const { dirname, join } = require("node:path");',
798
1041
  'const net = require("node:net");',
799
1042
  'const waitPid = Number(process.env.LIVEDESK_RESTART_WAIT_PID || 0);',
800
1043
  'const previousRuntimePid = Number(process.env.LIVEDESK_RESTART_PREVIOUS_RUNTIME_PID || 0);',
@@ -802,9 +1045,17 @@ function buildHubRestartBootstrapScript() {
802
1045
  'const resultPath = process.env.LIVEDESK_RESTART_RESULT_PATH || "";',
803
1046
  'const operationId = process.env.LIVEDESK_RESTART_OPERATION_ID || `hub-update-${Date.now()}`;',
804
1047
  'const startedAt = process.env.LIVEDESK_RESTART_STARTED_AT || new Date().toISOString();',
1048
+ 'const handoffToken = process.env.LIVEDESK_RESTART_HANDOFF_TOKEN || "";',
1049
+ 'const exitBeforeHandshakeOperationId = process.env.LIVEDESK_TEST_HUB_RESTART_EXIT_BEFORE_HANDSHAKE_OPERATION_ID || "";',
1050
+ 'const exitAfterHandshakeOperationId = process.env.LIVEDESK_TEST_HUB_RESTART_EXIT_AFTER_HANDSHAKE_OPERATION_ID || "";',
1051
+ 'const delayHandshakeOperationId = process.env.LIVEDESK_TEST_HUB_RESTART_DELAY_HANDSHAKE_OPERATION_ID || "";',
1052
+ 'const terminationFailureOperationId = process.env.LIVEDESK_TEST_HUB_RESTART_TERMINATION_FAILURE_OPERATION_ID || "";',
805
1053
  'const log = message => { if (!logPath) return; try { appendFileSync(logPath, `[Hub restart] ${new Date().toISOString()} ${message}\\n`, "utf8"); } catch {} };',
806
- 'const readPreviousResult = () => { if (!resultPath) return null; try { const value = JSON.parse(readFileSync(resultPath, "utf8")); return value?.operationId === operationId ? value : null; } catch { return null; } };',
807
- 'const writeResult = (stage, details = {}) => { if (!resultPath) return; const now = new Date().toISOString(); const previous = readPreviousResult(); const value = { ...(previous || {}), operationId, stage, targetVersion: expectedVersion, fallbackVersion, previousRuntimePid, supervisorPid: process.pid, startedAt: previous?.startedAt || startedAt, updatedAt: now, ...details }; const temporaryPath = `${resultPath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`; let renamed = false; try { mkdirSync(dirname(resultPath), { recursive: true }); writeFileSync(temporaryPath, JSON.stringify(value, null, 2), { encoding: "utf8", mode: 0o600 }); renameSync(temporaryPath, resultPath); renamed = true; } catch (error) { log(`stage=result-write-failed resultStage=${stage} error=${error.message}`); } finally { if (!renamed) { try { rmSync(temporaryPath, { force: true }); } catch {} } } };',
1054
+ 'const readRawResult = () => { if (!resultPath) return null; try { return JSON.parse(readFileSync(resultPath, "utf8")); } catch { return null; } };',
1055
+ 'const ownershipError = actual => { const error = new Error(`Hub update ${operationId} was superseded by ${actual || "another operation"}.`); error.code = "LIVEDESK_HUB_UPDATE_SUPERSEDED"; return error; };',
1056
+ 'const assertResultOwnership = () => { const current = readRawResult(); if (current?.operationId && current.operationId !== operationId) throw ownershipError(current.operationId); if (current?.operationId === operationId && current?.handoffCancelled === true) throw ownershipError(`${operationId} (handoff cancelled)`); return current; };',
1057
+ 'const resultRevision = value => JSON.stringify([String(value?.operationId || ""), String(value?.stage || ""), String(value?.outcome || ""), String(value?.updatedAt || "")]);',
1058
+ 'const writeResult = (stage, details = {}) => { if (!resultPath) return; const now = new Date().toISOString(); const previous = assertResultOwnership(); const value = { ...(previous || {}), operationId, stage, targetVersion: expectedVersion, fallbackVersion, previousRuntimePid, launcherPid: waitPid, supervisorPid: process.pid, handoffToken, startedAt: previous?.startedAt || startedAt, updatedAt: now, ...details }; const temporaryPath = `${resultPath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`; let renamed = false; try { mkdirSync(dirname(resultPath), { recursive: true }); writeFileSync(temporaryPath, JSON.stringify(value, null, 2), { encoding: "utf8", mode: 0o600 }); const current = readRawResult(); if (resultRevision(current) !== resultRevision(previous)) throw ownershipError(current?.operationId); renameSync(temporaryPath, resultPath); renamed = true; } catch (error) { log(`stage=result-write-failed resultStage=${stage} error=${error.message}`); throw error; } finally { if (!renamed) { try { rmSync(temporaryPath, { force: true }); } catch {} } } };',
808
1059
  'const isAlive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
809
1060
  'const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
810
1061
  'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
@@ -813,20 +1064,27 @@ function buildHubRestartBootstrapScript() {
813
1064
  'const decodeArgs = name => JSON.parse(Buffer.from(process.env[name] || "", "base64").toString("utf8"));',
814
1065
  'const primary = { command: process.env.LIVEDESK_RESTART_COMMAND || "", args: decodeArgs("LIVEDESK_RESTART_ARGS_BASE64") };',
815
1066
  'const fallback = { command: process.env.LIVEDESK_RESTART_FALLBACK_COMMAND || "", args: decodeArgs("LIVEDESK_RESTART_FALLBACK_ARGS_BASE64") };',
1067
+ 'const fallbackCwd = process.env.LIVEDESK_RESTART_FALLBACK_CWD || process.env.LIVEDESK_UPDATE_ORIGINAL_CWD || "";',
816
1068
  'const expectedVersion = process.env.LIVEDESK_RESTART_EXPECTED_VERSION || "";',
817
1069
  'const fallbackVersion = process.env.LIVEDESK_RESTART_FALLBACK_VERSION || "";',
1070
+ 'const neutralCwd = process.env.LIVEDESK_RESTART_NEUTRAL_CWD || "";',
818
1071
  'const healthBaseUrl = process.env.LIVEDESK_RESTART_HEALTH_URL || "http://127.0.0.1:5179";',
819
1072
  'const remoteHost = process.env.LIVEDESK_RESTART_REMOTE_HOST || "127.0.0.1";',
820
1073
  'const remotePort = Number(process.env.LIVEDESK_RESTART_REMOTE_PORT || 5197);',
821
1074
  'const attemptLimit = numberFromEnv("LIVEDESK_RESTART_ATTEMPTS", 3, 1);',
822
- 'const healthTimeoutMs = numberFromEnv("LIVEDESK_RESTART_HEALTH_TIMEOUT_MS", 30000, 1000);',
1075
+ 'const configuredHealthTimeoutMs = numberFromEnv("LIVEDESK_RESTART_HEALTH_TIMEOUT_MS", 30000, 1000);',
1076
+ 'const healthTimeoutMs = terminationFailureOperationId === operationId ? numberFromEnv("LIVEDESK_TEST_HUB_RESTART_TERMINATION_HEALTH_TIMEOUT_MS", 1000, 250) : configuredHealthTimeoutMs;',
823
1077
  'const pollMs = numberFromEnv("LIVEDESK_RESTART_HEALTH_POLL_MS", 250, 50);',
1078
+ 'const stabilityMs = numberFromEnv("LIVEDESK_RESTART_STABILITY_MS", 5000, 5000);',
1079
+ 'const prepareNeutralCwd = () => { if (!neutralCwd) throw new Error("LiveDesk Hub update neutral working directory is unavailable"); mkdirSync(neutralCwd, { recursive: true }); for (let ancestor = neutralCwd; ; ancestor = dirname(ancestor)) { const shadow = [join(ancestor, "package.json"), join(ancestor, "node_modules", "livedesk", "package.json")].find(existsSync); if (shadow) throw new Error(`LiveDesk Hub update neutral working directory is shadowed by ${shadow}: ${neutralCwd}`); const parent = dirname(ancestor); if (parent === ancestor) break; } };',
1080
+ 'const buildLaunchEnv = () => { const env = { ...process.env }; const isolated = new Set(["init_cwd", "npm_config_local_prefix", "npm_config_workspace", "npm_config_workspaces", "npm_config_include_workspace_root", "npm_package_json", "npm_lifecycle_event", "npm_lifecycle_script"]); for (const key of Object.keys(env)) if (isolated.has(key.toLowerCase())) delete env[key]; env.INIT_CWD = neutralCwd; env.npm_config_local_prefix = neutralCwd; env.npm_config_include_workspace_root = "false"; return env; };',
824
1081
  'const requestJson = async path => { const response = await fetch(new URL(path, healthBaseUrl), { cache: "no-store", signal: AbortSignal.timeout(Math.min(2000, healthTimeoutMs)) }); if (!response.ok) throw new Error(`HTTP ${response.status} from ${path}`); return response.json(); };',
825
1082
  '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); }); });',
826
1083
  '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 || "") }; };',
827
- 'const terminateTree = async child => { const pid = Number(child?.pid || 0); if (pid <= 1) return; log(`terminating unverified replacement pid=${pid}`); if (process.platform === "win32") { if (isAlive(pid)) await new Promise(resolve => execFile("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, () => resolve())); await sleep(250); return; } try { process.kill(-pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch {} } const gracefulDeadline = Date.now() + 2000; while (Date.now() < gracefulDeadline && (isGroupAlive(pid) || isAlive(pid))) await sleep(100); if (isGroupAlive(pid) || isAlive(pid)) { 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) || isAlive(pid))) await sleep(100); } };',
828
- 'const launchAndVerify = async (invocation, label, requiredVersion, attempt) => { if (!invocation.command || !Array.isArray(invocation.args)) throw new Error(`${label} restart command is unavailable`); log(`${label} attempt=${attempt} stage=spawning command=${invocation.command} target=${requiredVersion || "latest"}`); let exit = null; let exitedAt = 0; const child = spawn(invocation.command, invocation.args, { cwd: process.env.LIVEDESK_RESTART_CWD || process.cwd(), env: process.env, 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 }; exitedAt = Date.now(); 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 { const status = await probeReplacement(requiredVersion); log(`${label} attempt=${attempt} stage=verified launcherPid=${child.pid || 0} runtimePid=${status.runtimePid} version=${status.appVersion || "unknown"} http=${healthBaseUrl} remote=${remoteHost}:${remotePort}`); child.unref(); return { ...status, launcherPid: Number(child.pid || 0) }; } catch (error) { lastError = error; } if (exit && Date.now() - exitedAt >= 1500) break; await sleep(pollMs); } await terminateTree(child); throw new Error(`${label} attempt=${attempt} did not become healthy: ${lastError?.message || (exit ? `command exited code=${exit.code ?? "none"} signal=${exit.signal || "none"}` : "health deadline expired")}`); };',
829
- '(async () => { const targetErrors = []; const fallbackErrors = []; writeResult("waiting-for-old-launcher", { outcome: "in-progress" }); log(`stage=waiting-for-old-launcher pid=${waitPid}`); 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) { 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 || "latest"} failed verification: ${targetErrors.join(" | ")}` }); log("stage=completed-with-fallback"); return; } catch (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 livedesk@latest manually.`); })().catch(error => { writeResult("failed", { outcome: "failed", completedAt: new Date().toISOString(), error: error.message }); log("stage=bootstrap-failed error=" + error.message); process.exitCode = 1; });',
1084
+ '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; };',
1085
+ '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"})`); };',
1086
+ '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")}`); };',
1087
+ '(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; });',
830
1088
  ''
831
1089
  ].join('\n');
832
1090
  }
@@ -916,19 +1174,35 @@ async function runManager(args, resolvedRole = null) {
916
1174
  let updateStopTimer = null;
917
1175
  let updateHardStopTimer = null;
918
1176
 
919
- const restartWithLatest = request => {
920
- const requestedVersion = /^[0-9A-Za-z][0-9A-Za-z.+-]*$/.test(String(request?.latestVersion || '').trim())
921
- ? String(request.latestVersion).trim()
922
- : '';
923
- const packageTarget = requestedVersion ? `livedesk@${requestedVersion}` : 'livedesk@latest';
1177
+ const restartWithLatest = async request => {
1178
+ const requestedVersion = normalizeExactUpdateVersion(request?.latestVersion);
1179
+ if (!requestedVersion) {
1180
+ throw new Error('The Hub update target version must be one exact semantic version.');
1181
+ }
1182
+ const operationId = String(request?.operationId || `hub-update-${Date.now()}`).trim();
1183
+ const originalCwd = String(
1184
+ request?._originalCwd
1185
+ || process.env.LIVEDESK_UPDATE_ORIGINAL_CWD
1186
+ || process.cwd()
1187
+ ).trim();
1188
+ const neutralCwd = String(
1189
+ request?._neutralCwd
1190
+ || prepareUpdateNeutralCwd('hub', operationId)
1191
+ );
1192
+ const packageTarget = `livedesk@${requestedVersion}`;
924
1193
  const nextArgs = ['-y', '--prefer-online', packageTarget, '--force-role', 'hub', '--no-open', ...args];
925
1194
  const restartInvocation = resolveNpxInvocation(nextArgs);
926
1195
  const fallbackInvocation = {
927
1196
  command: process.execPath,
928
- args: [String(process.argv[1] || resolve(__dirname, 'livedesk.js')), '--force-role', 'hub', '--no-open', ...args]
1197
+ args: [resolve(String(process.argv[1] || resolve(__dirname, 'livedesk.js'))), '--force-role', 'hub', '--no-open', ...args]
929
1198
  };
930
- const operationId = String(request?.operationId || `hub-update-${Date.now()}`).trim();
931
1199
  const restartStartedAt = new Date().toISOString();
1200
+ const handoffToken = randomBytes(24).toString('hex');
1201
+ const handshakeTimeoutMs = readBoundedMilliseconds(
1202
+ process.env.LIVEDESK_RESTART_HANDSHAKE_TIMEOUT_MS,
1203
+ HUB_UPDATE_SUPERVISOR_HANDSHAKE_TIMEOUT_MS,
1204
+ 250
1205
+ );
932
1206
  try {
933
1207
  writeJsonAtomic(hubUpdateResultPath, {
934
1208
  operationId,
@@ -938,19 +1212,23 @@ async function runManager(args, resolvedRole = null) {
938
1212
  fallbackVersion: readVersion(),
939
1213
  previousRuntimePid: Number(request?.pid || 0),
940
1214
  launcherPid: process.pid,
1215
+ handoffToken,
941
1216
  startedAt: restartStartedAt,
942
1217
  updatedAt: restartStartedAt
943
1218
  });
944
1219
  } catch (error) {
945
1220
  reportLauncherUpdate(`[LiveDesk Hub] Could not persist the restart supervisor state: ${error instanceof Error ? error.message : String(error)}.`, true);
1221
+ throw error;
946
1222
  }
947
1223
  const restartBootstrap = spawn(process.execPath, ['-e', buildHubRestartBootstrapScript()], {
1224
+ cwd: neutralCwd,
948
1225
  env: {
949
1226
  ...process.env,
950
1227
  LIVEDESK_RESTART_WAIT_PID: String(process.pid),
951
1228
  LIVEDESK_RESTART_PREVIOUS_RUNTIME_PID: String(request?.pid || 0),
952
1229
  LIVEDESK_RESTART_OPERATION_ID: operationId,
953
1230
  LIVEDESK_RESTART_STARTED_AT: restartStartedAt,
1231
+ LIVEDESK_RESTART_HANDOFF_TOKEN: handoffToken,
954
1232
  LIVEDESK_RESTART_RESULT_PATH: hubUpdateResultPath,
955
1233
  LIVEDESK_RESTART_COMMAND: restartInvocation.command,
956
1234
  LIVEDESK_RESTART_ARGS_BASE64: Buffer.from(JSON.stringify(restartInvocation.args), 'utf8').toString('base64'),
@@ -961,39 +1239,49 @@ async function runManager(args, resolvedRole = null) {
961
1239
  LIVEDESK_RESTART_HEALTH_URL: hubProbeBaseUrl,
962
1240
  LIVEDESK_RESTART_REMOTE_HOST: '127.0.0.1',
963
1241
  LIVEDESK_RESTART_REMOTE_PORT: String(remotePort),
964
- LIVEDESK_RESTART_CWD: process.cwd(),
1242
+ LIVEDESK_RESTART_NEUTRAL_CWD: neutralCwd,
1243
+ LIVEDESK_RESTART_FALLBACK_CWD: originalCwd,
1244
+ LIVEDESK_UPDATE_ORIGINAL_CWD: originalCwd,
965
1245
  LIVEDESK_RESTART_LOG_PATH: hubLogPath
966
1246
  },
967
1247
  stdio: 'ignore',
968
- detached: true,
969
- windowsHide: true
1248
+ detached: true,
1249
+ windowsHide: true
970
1250
  });
971
- restartBootstrap.once('error', error => {
972
- reportLauncherUpdate(`[LiveDesk Hub] Failed to start the update restart supervisor: ${error.message}. Restoring the current Hub.`, true);
1251
+ try {
1252
+ await waitForHubRestartSupervisorClaim(restartBootstrap, {
1253
+ operationId,
1254
+ targetVersion: requestedVersion,
1255
+ resultPath: hubUpdateResultPath,
1256
+ launcherPid: process.pid,
1257
+ handoffToken,
1258
+ timeoutMs: handshakeTimeoutMs,
1259
+ stabilityMs: HUB_UPDATE_SUPERVISOR_CLAIM_STABILITY_MS
1260
+ });
1261
+ } catch (handshakeError) {
1262
+ let stopError = null;
973
1263
  try {
974
- writeJsonAtomic(hubUpdateResultPath, {
975
- operationId,
976
- stage: 'failed',
977
- outcome: 'failed',
978
- targetVersion: requestedVersion,
979
- fallbackVersion: readVersion(),
980
- previousRuntimePid: Number(request?.pid || 0),
981
- launcherPid: process.pid,
982
- startedAt: restartStartedAt,
983
- updatedAt: new Date().toISOString(),
984
- completedAt: new Date().toISOString(),
985
- error: `The Hub restart supervisor could not start: ${error.message}. The existing launcher restarted the unchanged Hub.`
986
- });
987
- } catch (persistError) {
988
- reportLauncherUpdate(`[LiveDesk Hub] Could not persist the supervisor failure: ${persistError instanceof Error ? persistError.message : String(persistError)}.`, true);
1264
+ await stopUnclaimedHubRestartSupervisor(restartBootstrap);
1265
+ } catch (error) {
1266
+ stopError = error;
989
1267
  }
990
- startHubProcess();
991
- });
992
- restartBootstrap.once('spawn', () => {
993
- restartBootstrap.unref();
994
- reportLauncherUpdate(`[LiveDesk Hub] Update handoff prepared target=${requestedVersion || 'latest'} supervisorPid=${restartBootstrap.pid || 0}. The current launcher will exit after its Hub stopped. Restart diagnostics: ${hubLogPath}`);
995
- setTimeout(() => process.exit(0), 100);
996
- });
1268
+ const error = new Error(
1269
+ `Hub restart supervisor handoff failed: `
1270
+ + `${handshakeError instanceof Error ? handshakeError.message : String(handshakeError)}`
1271
+ + `${stopError ? ` ${stopError.message}` : ''}`
1272
+ );
1273
+ error.supervisorPid = Number(restartBootstrap.pid || 0);
1274
+ error.handoffToken = handoffToken;
1275
+ error.handoffCancelled = true;
1276
+ throw error;
1277
+ }
1278
+ restartBootstrap.unref();
1279
+ reportLauncherUpdate(
1280
+ `[LiveDesk Hub] Update handoff claimed target=${requestedVersion} `
1281
+ + `supervisorPid=${restartBootstrap.pid || 0} operation=${operationId}. `
1282
+ + `The current launcher will now exit. Restart diagnostics: ${hubLogPath}`
1283
+ );
1284
+ process.exit(0);
997
1285
  };
998
1286
 
999
1287
  const pollUpdateRequest = () => {
@@ -1015,8 +1303,50 @@ async function runManager(args, resolvedRole = null) {
1015
1303
  } else if (requestPid !== activeChildPid) {
1016
1304
  reportLauncherUpdate(`[LiveDesk Hub] Ignored stale update restart request for pid ${requestPid}; active Hub pid is ${activeChildPid || 'unavailable'}.`, true);
1017
1305
  } else {
1018
- restartRequest = { ...request, pid: requestPid };
1019
- reportLauncherUpdate(`[LiveDesk Hub] Restart requested for update ${request.operationId || 'unknown'} target=${request.latestVersion || 'latest'}.`);
1306
+ const operationId = String(request?.operationId || `hub-update-${Date.now()}`).trim();
1307
+ const requestedVersion = normalizeExactUpdateVersion(request?.latestVersion);
1308
+ const failPreflight = message => {
1309
+ reportLauncherUpdate(`[LiveDesk Hub] ${message}. The current Hub remains running.`, true);
1310
+ try {
1311
+ const now = new Date().toISOString();
1312
+ writeJsonAtomic(hubUpdateResultPath, {
1313
+ operationId,
1314
+ stage: 'preflight-failed',
1315
+ outcome: 'failed',
1316
+ targetVersion: String(request?.latestVersion || ''),
1317
+ fallbackVersion: readVersion(),
1318
+ previousRuntimePid: requestPid,
1319
+ launcherPid: process.pid,
1320
+ startedAt: now,
1321
+ updatedAt: now,
1322
+ completedAt: now,
1323
+ error: message
1324
+ });
1325
+ } catch (persistError) {
1326
+ reportLauncherUpdate(`[LiveDesk Hub] Could not persist the update preflight failure: ${persistError instanceof Error ? persistError.message : String(persistError)}.`, true);
1327
+ }
1328
+ };
1329
+ if (!requestedVersion) {
1330
+ failPreflight('Hub update preflight failed before shutdown: the requested target must be one exact semantic version');
1331
+ return;
1332
+ }
1333
+ let neutralCwd;
1334
+ try {
1335
+ neutralCwd = prepareUpdateNeutralCwd('hub', operationId);
1336
+ } catch (error) {
1337
+ const message = `Hub update preflight failed before shutdown: ${error instanceof Error ? error.message : String(error)}`;
1338
+ failPreflight(message);
1339
+ return;
1340
+ }
1341
+ restartRequest = {
1342
+ ...request,
1343
+ latestVersion: requestedVersion,
1344
+ pid: requestPid,
1345
+ operationId,
1346
+ _neutralCwd: neutralCwd,
1347
+ _originalCwd: UPDATE_ORIGINAL_CWD
1348
+ };
1349
+ reportLauncherUpdate(`[LiveDesk Hub] Restart requested for update ${request.operationId || 'unknown'} target=${requestedVersion}.`);
1020
1350
  const updateChild = activeChild;
1021
1351
  let shutdownFallbackStarted = false;
1022
1352
  const forceStop = reason => {
@@ -1102,7 +1432,39 @@ async function runManager(args, resolvedRole = null) {
1102
1432
  clearInterval(updatePollId);
1103
1433
  updatePollId = null;
1104
1434
  }
1105
- restartWithLatest(request);
1435
+ void restartWithLatest(request).catch(error => {
1436
+ const message = `The Hub restart handoff failed after shutdown: ${error instanceof Error ? error.message : String(error)}`;
1437
+ reportLauncherUpdate(`[LiveDesk Hub] ${message}. Restarting the unchanged Hub.`, true);
1438
+ try {
1439
+ const now = new Date().toISOString();
1440
+ const previous = readJsonFile(hubUpdateResultPath);
1441
+ writeJsonAtomic(hubUpdateResultPath, {
1442
+ operationId: String(request?.operationId || ''),
1443
+ stage: 'failed',
1444
+ outcome: 'failed',
1445
+ targetVersion: String(request?.latestVersion || ''),
1446
+ fallbackVersion: readVersion(),
1447
+ previousRuntimePid: Number(request?.pid || 0),
1448
+ launcherPid: process.pid,
1449
+ supervisorPid: Number(error?.supervisorPid || 0),
1450
+ handoffToken: String(error?.handoffToken || previous?.handoffToken || ''),
1451
+ handoffCancelled: error?.handoffCancelled === true,
1452
+ startedAt: previous?.operationId === request?.operationId
1453
+ ? (previous?.startedAt || now)
1454
+ : now,
1455
+ updatedAt: now,
1456
+ completedAt: now,
1457
+ error: message
1458
+ });
1459
+ } catch {
1460
+ // The unchanged Hub still restarts even if diagnostics are read-only.
1461
+ }
1462
+ startHubProcess();
1463
+ if (!updatePollId) {
1464
+ updatePollId = setInterval(pollUpdateRequest, HUB_UPDATE_POLL_MS);
1465
+ updatePollId.unref?.();
1466
+ }
1467
+ });
1106
1468
  return;
1107
1469
  }
1108
1470
  if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
@@ -1220,6 +1582,7 @@ async function runDesktopRoleBootstrap() {
1220
1582
 
1221
1583
  async function main() {
1222
1584
  const rawArgs = process.argv.slice(2);
1585
+ restoreUpdateOriginalCwd();
1223
1586
  if (rawArgs.includes('--internal-legacy-client-update')) {
1224
1587
  await runLegacyClientUpdateSupervisorCli();
1225
1588
  return;