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 +410 -47
- package/bootstrap/legacy-client-update.js +730 -86
- package/client/bin/livedesk-client-node.js +215 -45
- package/client/package.json +5 -5
- package/hub/src/live-desk-update.js +55 -26
- package/hub/src/server.js +8 -4
- package/package.json +6 -6
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
|
|
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
|
|
807
|
-
'const
|
|
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
|
|
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
|
|
828
|
-
'const
|
|
829
|
-
'
|
|
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 =
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
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
|
-
|
|
972
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
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;
|