livedesk 0.1.384 → 0.1.385

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
@@ -5,7 +5,7 @@ import net from 'node:net';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { execFile, spawn } from 'node:child_process';
8
- import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { randomBytes } from 'node:crypto';
10
10
  import os from 'node:os';
11
11
  import { resolveDeviceRole, writeRoleCache } from '../bootstrap/device-role.js';
@@ -27,10 +27,29 @@ const PORT_CLEANUP_POLL_MS = 100;
27
27
  const EXISTING_RUNTIME_STOP_TIMEOUT_MS = 10000;
28
28
  const HUB_STARTUP_RETRY_LIMIT = 1;
29
29
  const HUB_UPDATE_POLL_MS = 250;
30
- const MANAGER_STATE_DIR = process.env.LIVEDESK_STATE_DIR || join(os.homedir(), '.livedesk');
31
- const MANAGER_STATE_PATH = join(MANAGER_STATE_DIR, 'manager.json');
32
-
33
- function printHelp() {
30
+ const MANAGER_STATE_DIR = process.env.LIVEDESK_STATE_DIR || join(os.homedir(), '.livedesk');
31
+ const MANAGER_STATE_PATH = join(MANAGER_STATE_DIR, 'manager.json');
32
+
33
+ function writeJsonAtomic(filePath, value) {
34
+ if (!filePath) return;
35
+ mkdirSync(dirname(filePath), { recursive: true });
36
+ const temporaryPath = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
37
+ let renamed = false;
38
+ try {
39
+ writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
40
+ encoding: 'utf8',
41
+ mode: 0o600
42
+ });
43
+ renameSync(temporaryPath, filePath);
44
+ renamed = true;
45
+ } finally {
46
+ if (!renamed) {
47
+ try { rmSync(temporaryPath, { force: true }); } catch { /* best effort cleanup */ }
48
+ }
49
+ }
50
+ }
51
+
52
+ function printHelp() {
34
53
  process.stdout.write(`
35
54
  LiveDesk
36
55
 
@@ -772,20 +791,44 @@ function resolveNpxInvocation(npxArgs) {
772
791
 
773
792
  function buildHubRestartBootstrapScript() {
774
793
  return [
775
- 'const { spawn } = require("node:child_process");',
776
- 'const { appendFileSync } = require("node:fs");',
794
+ 'const { execFile, spawn } = require("node:child_process");',
795
+ 'const { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } = require("node:fs");',
796
+ 'const { dirname } = require("node:path");',
797
+ 'const net = require("node:net");',
777
798
  'const waitPid = Number(process.env.LIVEDESK_RESTART_WAIT_PID || 0);',
799
+ 'const previousRuntimePid = Number(process.env.LIVEDESK_RESTART_PREVIOUS_RUNTIME_PID || 0);',
778
800
  'const logPath = process.env.LIVEDESK_RESTART_LOG_PATH || "";',
801
+ 'const resultPath = process.env.LIVEDESK_RESTART_RESULT_PATH || "";',
802
+ 'const operationId = process.env.LIVEDESK_RESTART_OPERATION_ID || `hub-update-${Date.now()}`;',
803
+ 'const startedAt = process.env.LIVEDESK_RESTART_STARTED_AT || new Date().toISOString();',
779
804
  'const log = message => { if (!logPath) return; try { appendFileSync(logPath, `[Hub restart] ${new Date().toISOString()} ${message}\\n`, "utf8"); } catch {} };',
805
+ 'const readPreviousResult = () => { if (!resultPath) return null; try { const value = JSON.parse(readFileSync(resultPath, "utf8")); return value?.operationId === operationId ? value : null; } catch { return null; } };',
806
+ '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 {} } } };',
780
807
  'const isAlive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
781
- 'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
782
- 'const waitForExit = async pid => { for (let attempt = 0; attempt < 240 && pid > 1; attempt += 1) { if (!isAlive(pid)) return; await sleep(250); } if (pid > 1 && isAlive(pid)) throw new Error("Previous LiveDesk Hub launcher did not exit before restart."); };',
783
- 'const command = process.env.LIVEDESK_RESTART_COMMAND || "";',
784
- 'const args = JSON.parse(Buffer.from(process.env.LIVEDESK_RESTART_ARGS_BASE64 || "", "base64").toString("utf8"));',
785
- '(async () => { log(`waiting for launcher pid=${waitPid}`); await waitForExit(waitPid); if (!command || !Array.isArray(args)) throw new Error("LiveDesk Hub restart command is unavailable."); log(`starting ${command}`); const next = spawn(command, args, { cwd: process.env.LIVEDESK_RESTART_CWD || process.cwd(), env: process.env, detached: true, stdio: "ignore", windowsHide: true }); await new Promise((resolve, reject) => { next.once("error", reject); next.once("spawn", () => { log(`spawned pid=${next.pid || 0}`); resolve(); }); }); next.once("exit", (code, signal) => log(`restart command exited code=${code ?? "none"} signal=${signal || "none"}`)); await sleep(1500); next.unref(); })().catch(error => { log("bootstrap failed: " + error.message); process.exitCode = 1; });',
786
- ''
787
- ].join('\n');
788
- }
808
+ 'const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
809
+ 'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
810
+ 'const numberFromEnv = (name, fallback, minimum) => { const value = Number(process.env[name]); return Number.isFinite(value) && value >= minimum ? Math.round(value) : fallback; };',
811
+ '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.`); };',
812
+ 'const decodeArgs = name => JSON.parse(Buffer.from(process.env[name] || "", "base64").toString("utf8"));',
813
+ 'const primary = { command: process.env.LIVEDESK_RESTART_COMMAND || "", args: decodeArgs("LIVEDESK_RESTART_ARGS_BASE64") };',
814
+ 'const fallback = { command: process.env.LIVEDESK_RESTART_FALLBACK_COMMAND || "", args: decodeArgs("LIVEDESK_RESTART_FALLBACK_ARGS_BASE64") };',
815
+ 'const expectedVersion = process.env.LIVEDESK_RESTART_EXPECTED_VERSION || "";',
816
+ 'const fallbackVersion = process.env.LIVEDESK_RESTART_FALLBACK_VERSION || "";',
817
+ 'const healthBaseUrl = process.env.LIVEDESK_RESTART_HEALTH_URL || "http://127.0.0.1:5179";',
818
+ 'const remoteHost = process.env.LIVEDESK_RESTART_REMOTE_HOST || "127.0.0.1";',
819
+ 'const remotePort = Number(process.env.LIVEDESK_RESTART_REMOTE_PORT || 5197);',
820
+ 'const attemptLimit = numberFromEnv("LIVEDESK_RESTART_ATTEMPTS", 3, 1);',
821
+ 'const healthTimeoutMs = numberFromEnv("LIVEDESK_RESTART_HEALTH_TIMEOUT_MS", 30000, 1000);',
822
+ 'const pollMs = numberFromEnv("LIVEDESK_RESTART_HEALTH_POLL_MS", 250, 50);',
823
+ '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(); };',
824
+ '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); }); });',
825
+ '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 || "") }; };',
826
+ '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); } };',
827
+ '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")}`); };',
828
+ '(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; });',
829
+ ''
830
+ ].join('\n');
831
+ }
789
832
 
790
833
  async function runManager(args, resolvedRole = null) {
791
834
  const options = parseManagerArgs(args);
@@ -800,30 +843,42 @@ async function runManager(args, resolvedRole = null) {
800
843
  const openUrl = options.openUrlExplicit
801
844
  ? (options.openUrl || `http://127.0.0.1:${httpPort}`)
802
845
  : `http://127.0.0.1:${httpPort}`;
803
- const pairToken = process.env.REMOTE_HUB_PAIR_TOKEN
804
- || process.env.LIVEDESK_CLIENT_PAIR_TOKEN
805
- || process.env.MINDEXEC_REMOTE_PAIR_TOKEN
806
- || getStablePairToken();
807
- mkdirSync(MANAGER_STATE_DIR, { recursive: true });
808
- const updateRequestPath = join(MANAGER_STATE_DIR, `hub-update-${httpPort}-${remotePort}.json`);
809
- rmSync(updateRequestPath, { force: true });
846
+ const pairToken = process.env.REMOTE_HUB_PAIR_TOKEN
847
+ || process.env.LIVEDESK_CLIENT_PAIR_TOKEN
848
+ || process.env.MINDEXEC_REMOTE_PAIR_TOKEN
849
+ || getStablePairToken();
850
+ const configuredHttpHost = options.host || process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
851
+ const normalizedHttpHost = String(configuredHttpHost).trim().replace(/^\[|\]$/g, '');
852
+ const hubProbeHost = normalizedHttpHost === '0.0.0.0'
853
+ ? '127.0.0.1'
854
+ : (normalizedHttpHost === '::' ? '::1' : normalizedHttpHost);
855
+ const hubProbeUrlHost = hubProbeHost.includes(':') ? `[${hubProbeHost}]` : hubProbeHost;
856
+ const hubProbeBaseUrl = `http://${hubProbeUrlHost}:${httpPort}`;
857
+ mkdirSync(MANAGER_STATE_DIR, { recursive: true });
858
+ const updateRequestPath = join(MANAGER_STATE_DIR, `hub-update-${httpPort}-${remotePort}.json`);
859
+ const hubUpdateResultPath = join(MANAGER_STATE_DIR, `hub-update-result-${httpPort}-${remotePort}.json`);
860
+ rmSync(updateRequestPath, { force: true });
810
861
 
811
862
  if (options.cleanPortsOnStart) {
812
863
  await stopProcessesOnPorts([httpPort, remotePort]);
813
864
  }
814
865
 
815
- const internalHubEntry = resolve(packageRoot, 'hub', 'src', 'server.js');
816
- const hubEntry = existsSync(internalHubEntry)
817
- ? internalHubEntry
818
- : resolvePackageEntry('@livedesk/hub', '@livedesk/hub/src/server.js');
866
+ const configuredHubEntry = String(process.env.LIVEDESK_HUB_ENTRY || '').trim();
867
+ const internalHubEntry = resolve(packageRoot, 'hub', 'src', 'server.js');
868
+ const hubEntry = configuredHubEntry && existsSync(configuredHubEntry)
869
+ ? configuredHubEntry
870
+ : (existsSync(internalHubEntry)
871
+ ? internalHubEntry
872
+ : resolvePackageEntry('@livedesk/hub', '@livedesk/hub/src/server.js'));
819
873
  const packagedWebDist = resolve(packageRoot, 'web', 'dist');
820
- const env = {
821
- ...process.env,
822
- LIVEDESK_HUB_HTTP_HOST: options.host || process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1',
874
+ const env = {
875
+ ...process.env,
876
+ LIVEDESK_HUB_HTTP_HOST: configuredHttpHost,
823
877
  LIVEDESK_HUB_HTTP_PORT: String(httpPort),
824
- LIVEDESK_MANAGER_VERSION: readVersion(),
825
- LIVEDESK_CLIENT_PACKAGE_VERSION: readClientVersion(),
826
- LIVEDESK_HUB_UPDATE_REQUEST_PATH: updateRequestPath,
878
+ LIVEDESK_MANAGER_VERSION: readVersion(),
879
+ LIVEDESK_CLIENT_PACKAGE_VERSION: readClientVersion(),
880
+ LIVEDESK_HUB_UPDATE_REQUEST_PATH: updateRequestPath,
881
+ LIVEDESK_HUB_UPDATE_RESULT_PATH: hubUpdateResultPath,
827
882
  REMOTE_HUB_PORT: String(remotePort),
828
883
  REMOTE_HUB_PAIR_TOKEN: pairToken,
829
884
  LIVEDESK_WEB_DIST: existsSync(resolve(packagedWebDist, 'index.html')) ? packagedWebDist : (process.env.LIVEDESK_WEB_DIST || ''),
@@ -854,35 +909,93 @@ async function runManager(args, resolvedRole = null) {
854
909
  };
855
910
 
856
911
  let startupRetryCount = 0;
857
- let restartRequested = false;
858
- let activeChild = null;
859
- let updatePollId = null;
860
-
861
- const restartWithLatest = () => {
862
- const nextArgs = ['-y', '--prefer-online', 'livedesk@latest', '--force-role', 'hub', '--no-open', ...args];
912
+ let restartRequest = null;
913
+ let activeChild = null;
914
+ let updatePollId = null;
915
+ let updateStopTimer = null;
916
+ let updateHardStopTimer = null;
917
+
918
+ const restartWithLatest = request => {
919
+ const requestedVersion = /^[0-9A-Za-z][0-9A-Za-z.+-]*$/.test(String(request?.latestVersion || '').trim())
920
+ ? String(request.latestVersion).trim()
921
+ : '';
922
+ const packageTarget = requestedVersion ? `livedesk@${requestedVersion}` : 'livedesk@latest';
923
+ const nextArgs = ['-y', '--prefer-online', packageTarget, '--force-role', 'hub', '--no-open', ...args];
863
924
  const restartInvocation = resolveNpxInvocation(nextArgs);
864
- const restartBootstrap = spawn(process.execPath, ['-e', buildHubRestartBootstrapScript()], {
865
- env: {
866
- ...process.env,
867
- LIVEDESK_RESTART_WAIT_PID: String(process.pid),
925
+ const fallbackInvocation = {
926
+ command: process.execPath,
927
+ args: [String(process.argv[1] || resolve(__dirname, 'livedesk.js')), '--force-role', 'hub', '--no-open', ...args]
928
+ };
929
+ const operationId = String(request?.operationId || `hub-update-${Date.now()}`).trim();
930
+ const restartStartedAt = new Date().toISOString();
931
+ try {
932
+ writeJsonAtomic(hubUpdateResultPath, {
933
+ operationId,
934
+ stage: 'supervisor-starting',
935
+ outcome: 'in-progress',
936
+ targetVersion: requestedVersion,
937
+ fallbackVersion: readVersion(),
938
+ previousRuntimePid: Number(request?.pid || 0),
939
+ launcherPid: process.pid,
940
+ startedAt: restartStartedAt,
941
+ updatedAt: restartStartedAt
942
+ });
943
+ } catch (error) {
944
+ reportLauncherUpdate(`[LiveDesk Hub] Could not persist the restart supervisor state: ${error instanceof Error ? error.message : String(error)}.`, true);
945
+ }
946
+ const restartBootstrap = spawn(process.execPath, ['-e', buildHubRestartBootstrapScript()], {
947
+ env: {
948
+ ...process.env,
949
+ LIVEDESK_RESTART_WAIT_PID: String(process.pid),
950
+ LIVEDESK_RESTART_PREVIOUS_RUNTIME_PID: String(request?.pid || 0),
951
+ LIVEDESK_RESTART_OPERATION_ID: operationId,
952
+ LIVEDESK_RESTART_STARTED_AT: restartStartedAt,
953
+ LIVEDESK_RESTART_RESULT_PATH: hubUpdateResultPath,
868
954
  LIVEDESK_RESTART_COMMAND: restartInvocation.command,
869
955
  LIVEDESK_RESTART_ARGS_BASE64: Buffer.from(JSON.stringify(restartInvocation.args), 'utf8').toString('base64'),
956
+ LIVEDESK_RESTART_FALLBACK_COMMAND: fallbackInvocation.command,
957
+ LIVEDESK_RESTART_FALLBACK_ARGS_BASE64: Buffer.from(JSON.stringify(fallbackInvocation.args), 'utf8').toString('base64'),
958
+ LIVEDESK_RESTART_EXPECTED_VERSION: requestedVersion,
959
+ LIVEDESK_RESTART_FALLBACK_VERSION: readVersion(),
960
+ LIVEDESK_RESTART_HEALTH_URL: hubProbeBaseUrl,
961
+ LIVEDESK_RESTART_REMOTE_HOST: '127.0.0.1',
962
+ LIVEDESK_RESTART_REMOTE_PORT: String(remotePort),
870
963
  LIVEDESK_RESTART_CWD: process.cwd(),
871
964
  LIVEDESK_RESTART_LOG_PATH: hubLogPath
872
- },
965
+ },
873
966
  stdio: 'ignore',
874
967
  detached: true,
875
968
  windowsHide: true
876
- });
877
- restartBootstrap.once('error', error => {
878
- console.error(`Failed to prepare LiveDesk Hub restart: ${error.message}`);
879
- });
880
- restartBootstrap.once('spawn', () => restartBootstrap.unref());
881
- reportLauncherUpdate('[LiveDesk Hub] Update approved. The current Hub launcher is exiting and the latest Hub will start automatically.');
882
- setTimeout(() => process.exit(0), 100);
883
- };
969
+ });
970
+ restartBootstrap.once('error', error => {
971
+ reportLauncherUpdate(`[LiveDesk Hub] Failed to start the update restart supervisor: ${error.message}. Restoring the current Hub.`, true);
972
+ try {
973
+ writeJsonAtomic(hubUpdateResultPath, {
974
+ operationId,
975
+ stage: 'failed',
976
+ outcome: 'failed',
977
+ targetVersion: requestedVersion,
978
+ fallbackVersion: readVersion(),
979
+ previousRuntimePid: Number(request?.pid || 0),
980
+ launcherPid: process.pid,
981
+ startedAt: restartStartedAt,
982
+ updatedAt: new Date().toISOString(),
983
+ completedAt: new Date().toISOString(),
984
+ error: `The Hub restart supervisor could not start: ${error.message}. The existing launcher restarted the unchanged Hub.`
985
+ });
986
+ } catch (persistError) {
987
+ reportLauncherUpdate(`[LiveDesk Hub] Could not persist the supervisor failure: ${persistError instanceof Error ? persistError.message : String(persistError)}.`, true);
988
+ }
989
+ startHubProcess();
990
+ });
991
+ restartBootstrap.once('spawn', () => {
992
+ restartBootstrap.unref();
993
+ 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}`);
994
+ setTimeout(() => process.exit(0), 100);
995
+ });
996
+ };
884
997
 
885
- const pollUpdateRequest = () => {
998
+ const pollUpdateRequest = () => {
886
999
  if (!existsSync(updateRequestPath)) return;
887
1000
  try {
888
1001
  const request = JSON.parse(readFileSync(updateRequestPath, 'utf8'));
@@ -901,12 +1014,41 @@ async function runManager(args, resolvedRole = null) {
901
1014
  } else if (requestPid !== activeChildPid) {
902
1015
  reportLauncherUpdate(`[LiveDesk Hub] Ignored stale update restart request for pid ${requestPid}; active Hub pid is ${activeChildPid || 'unavailable'}.`, true);
903
1016
  } else {
904
- restartRequested = true;
905
- reportLauncherUpdate(`[LiveDesk Hub] Restart requested for update ${request.operationId || 'unknown'}.`);
906
- if (activeChild && !activeChild.killed) {
907
- activeChild.kill('SIGTERM');
908
- }
909
- }
1017
+ restartRequest = { ...request, pid: requestPid };
1018
+ reportLauncherUpdate(`[LiveDesk Hub] Restart requested for update ${request.operationId || 'unknown'} target=${request.latestVersion || 'latest'}.`);
1019
+ const updateChild = activeChild;
1020
+ let shutdownFallbackStarted = false;
1021
+ const forceStop = reason => {
1022
+ if (!updateChild || updateChild.exitCode !== null || updateChild.signalCode) return;
1023
+ if (shutdownFallbackStarted) return;
1024
+ shutdownFallbackStarted = true;
1025
+ reportLauncherUpdate(`[LiveDesk Hub] Update shutdown fallback: ${reason}; terminating Hub pid=${requestPid}.`, true);
1026
+ try { updateChild.kill('SIGTERM'); } catch { /* child already exited */ }
1027
+ updateHardStopTimer = setTimeout(() => {
1028
+ if (!updateChild || updateChild.exitCode !== null || updateChild.signalCode) return;
1029
+ reportLauncherUpdate(`[LiveDesk Hub] Update shutdown hard fallback: Hub pid=${requestPid} ignored graceful termination; force-stopping its process tree.`, true);
1030
+ if (process.platform === 'win32') {
1031
+ execFile('taskkill.exe', ['/PID', String(requestPid), '/T', '/F'], { windowsHide: true }, error => {
1032
+ if (error && updateChild.exitCode === null && !updateChild.signalCode) {
1033
+ reportLauncherUpdate(`[LiveDesk Hub] taskkill fallback failed for pid=${requestPid}: ${error.message}`, true);
1034
+ }
1035
+ });
1036
+ } else {
1037
+ try { updateChild.kill('SIGKILL'); } catch { /* child already exited */ }
1038
+ }
1039
+ }, 3_000);
1040
+ updateHardStopTimer.unref?.();
1041
+ };
1042
+ updateStopTimer = setTimeout(() => forceStop('graceful shutdown exceeded 10 seconds'), 10_000);
1043
+ updateStopTimer.unref?.();
1044
+ void fetch(`${hubProbeBaseUrl}/api/runtime/shutdown`, {
1045
+ method: 'POST',
1046
+ signal: AbortSignal.timeout(2_500)
1047
+ }).then(response => {
1048
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1049
+ reportLauncherUpdate(`[LiveDesk Hub] Update shutdown acknowledged by Hub pid=${requestPid}.`);
1050
+ }).catch(error => forceStop(`shutdown API failed (${error?.message || error})`));
1051
+ }
910
1052
  } catch {
911
1053
  // The Hub writes the request atomically; a partial/stale file is retried.
912
1054
  }
@@ -941,18 +1083,27 @@ async function runManager(args, resolvedRole = null) {
941
1083
  child.once('error', error => {
942
1084
  console.error(`Failed to start LiveDesk Hub: ${error.message}`);
943
1085
  process.exitCode = 1;
944
- });
945
- child.once('exit', (code, signal) => {
946
- const startupOutput = stderrChunks.join('');
947
- if (restartRequested) {
948
- restartRequested = false;
949
- if (updatePollId) {
950
- clearInterval(updatePollId);
951
- updatePollId = null;
952
- }
953
- restartWithLatest();
954
- return;
955
- }
1086
+ });
1087
+ child.once('exit', (code, signal) => {
1088
+ const startupOutput = stderrChunks.join('');
1089
+ if (restartRequest) {
1090
+ const request = restartRequest;
1091
+ restartRequest = null;
1092
+ if (updateStopTimer) {
1093
+ clearTimeout(updateStopTimer);
1094
+ updateStopTimer = null;
1095
+ }
1096
+ if (updateHardStopTimer) {
1097
+ clearTimeout(updateHardStopTimer);
1098
+ updateHardStopTimer = null;
1099
+ }
1100
+ if (updatePollId) {
1101
+ clearInterval(updatePollId);
1102
+ updatePollId = null;
1103
+ }
1104
+ restartWithLatest(request);
1105
+ return;
1106
+ }
956
1107
  if (!signal && code === ROLE_TRANSITION_EXIT_CODE) {
957
1108
  if (updatePollId) {
958
1109
  clearInterval(updatePollId);