livedesk 0.1.397 → 0.1.399
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
|
@@ -1146,6 +1146,7 @@ async function runManager(args, resolvedRole = null) {
|
|
|
1146
1146
|
LIVEDESK_DEVICE_ID: String(resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || ''),
|
|
1147
1147
|
LIVEDESK_DEVICE_NAME: String(resolvedRole?.deviceName || process.env.LIVEDESK_DEVICE_NAME || os.hostname()),
|
|
1148
1148
|
LIVEDESK_ROLE_CACHE_PATH: join(MANAGER_STATE_DIR, 'device-role.json'),
|
|
1149
|
+
LIVEDESK_AUTH_STATE_PATH: join(MANAGER_STATE_DIR, 'auth.json'),
|
|
1149
1150
|
LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
|
|
1150
1151
|
};
|
|
1151
1152
|
const debugConsole = process.env.LIVEDESK_DEBUG === '1';
|
|
@@ -243,8 +243,18 @@ function readConfig(env = process.env) {
|
|
|
243
243
|
cleanVersion(env.LIVEDESK_CLIENT_UPDATE_CURRENT_PRODUCT_VERSION)
|
|
244
244
|
|| cleanVersion(env.LIVEDESK_NPM_LAUNCHER_VERSION),
|
|
245
245
|
currentAgentVersion: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_CURRENT_AGENT_VERSION),
|
|
246
|
-
|
|
247
|
-
|
|
246
|
+
// Dedicated RemoteFast and Node Agent handoffs freeze the owned process
|
|
247
|
+
// tree under UPDATE_WAIT_PID / UPDATE_AGENT_PID. The Hub-owned bridge for
|
|
248
|
+
// older Agents uses the CLIENT_* aliases. Accept both contracts so the
|
|
249
|
+
// exact target package can always prove and stop the same old runtime.
|
|
250
|
+
launcherPid: positivePid(
|
|
251
|
+
env.LIVEDESK_UPDATE_WAIT_PID
|
|
252
|
+
|| env.LIVEDESK_CLIENT_PARENT_PID
|
|
253
|
+
),
|
|
254
|
+
agentPid: positivePid(
|
|
255
|
+
env.LIVEDESK_UPDATE_AGENT_PID
|
|
256
|
+
|| env.LIVEDESK_CLIENT_UPDATE_AGENT_PID
|
|
257
|
+
),
|
|
248
258
|
starterPid: positivePid(env.LIVEDESK_UPDATE_STARTER_PID),
|
|
249
259
|
updateDeadlineEpochMs: Number(env.LIVEDESK_CLIENT_UPDATE_DEADLINE_EPOCH_MS),
|
|
250
260
|
runtimePort: normalizePort(env.LIVEDESK_CLIENT_RUNTIME_PORT || env.LIVEDESK_LOCAL_RUNTIME_PORT),
|
|
@@ -1528,15 +1538,20 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
1528
1538
|
));
|
|
1529
1539
|
|
|
1530
1540
|
log(`started operation=${config.operationId || 'unknown'} target=${config.targetProductVersion}/${config.targetVersion} current=${config.currentProductVersion || 'unknown'} device=${config.deviceId}`);
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1541
|
+
const missingConfiguration = [
|
|
1542
|
+
['operationId', config.operationId],
|
|
1543
|
+
['deviceId', config.deviceId],
|
|
1544
|
+
['targetProductVersion', config.targetProductVersion],
|
|
1545
|
+
['targetVersion', config.targetVersion],
|
|
1546
|
+
['currentProductVersion', config.currentProductVersion],
|
|
1547
|
+
['currentAgentVersion', config.currentAgentVersion],
|
|
1548
|
+
['launcherPid', config.launcherPid > 1],
|
|
1549
|
+
['agentPid', config.agentPid > 1]
|
|
1550
|
+
].filter(([, value]) => !value).map(([name]) => name);
|
|
1551
|
+
if (missingConfiguration.length > 0) {
|
|
1552
|
+
throw new Error(
|
|
1553
|
+
`legacy update configuration is incomplete: missing ${missingConfiguration.join(', ')}`
|
|
1554
|
+
);
|
|
1540
1555
|
}
|
|
1541
1556
|
ensureDeadline('configuration validation');
|
|
1542
1557
|
if (cleanVersion(productManifest.version) !== config.targetProductVersion) {
|
|
@@ -14,6 +14,7 @@ import { createClientRuntimeServer } from '../src/runtime/client-runtime-server.
|
|
|
14
14
|
import { installAgentTerminationHandlers } from '../src/runtime/agent-process-lifecycle.js';
|
|
15
15
|
import { createHubWakeListener } from '../src/runtime/hub-wake-listener.js';
|
|
16
16
|
import { normalizeRuntimeAuthSession } from '../../runtime-core/src/auth-session.js';
|
|
17
|
+
import { startRoleTransitionSupervisor } from '../../runtime-core/src/role-transition-supervisor.js';
|
|
17
18
|
|
|
18
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const require = createRequire(import.meta.url);
|
|
@@ -134,27 +135,30 @@ export function buildUnifiedRoleRestartEnvironment(role, baseEnv = process.env,
|
|
|
134
135
|
};
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
function spawnUnifiedRoleRestart(role) {
|
|
138
|
+
async function spawnUnifiedRoleRestart(role) {
|
|
138
139
|
if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) {
|
|
139
140
|
console.log(`[LiveDesk] Desktop supervisor will restart the runtime as ${role}.`);
|
|
140
141
|
return false;
|
|
141
142
|
}
|
|
142
143
|
const unifiedEntry = String(process.env.LIVEDESK_UNIFIED_LAUNCHER_ENTRY || '').trim();
|
|
143
144
|
const restartEnvironment = buildUnifiedRoleRestartEnvironment(role);
|
|
144
|
-
|
|
145
|
+
if (!unifiedEntry || !existsSync(unifiedEntry)) {
|
|
146
|
+
throw new Error(`LiveDesk unified role launcher is unavailable: ${unifiedEntry || 'missing path'}`);
|
|
147
|
+
}
|
|
148
|
+
const handoff = await startRoleTransitionSupervisor({
|
|
149
|
+
role,
|
|
150
|
+
previousPid: process.pid,
|
|
151
|
+
launcherEntry: unifiedEntry,
|
|
152
|
+
stateDir: UNIFIED_CLIENT_STATE_DIR,
|
|
153
|
+
runtimePort: Number(process.env.LIVEDESK_CLIENT_RUNTIME_PORT || DEFAULT_AUTH_CALLBACK_PORT),
|
|
154
|
+
remotePort: resolveHubClientPort(),
|
|
145
155
|
env: restartEnvironment,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
...spawnOptions
|
|
153
|
-
})
|
|
154
|
-
: process.platform === 'win32'
|
|
155
|
-
? spawn('npx.cmd', ['-y', '--prefer-online', 'livedesk@latest', '--force-role', role, '--no-open'], spawnOptions)
|
|
156
|
-
: spawn('npx', ['-y', '--prefer-online', 'livedesk@latest', '--force-role', role, '--no-open'], spawnOptions);
|
|
157
|
-
child.unref();
|
|
156
|
+
cwd: process.cwd()
|
|
157
|
+
});
|
|
158
|
+
console.log(
|
|
159
|
+
`[LiveDesk] Role transition supervisor claimed target=${role} `
|
|
160
|
+
+ `pid=${handoff.supervisorPid}. Diagnostics: ${handoff.logPath}`
|
|
161
|
+
);
|
|
158
162
|
return true;
|
|
159
163
|
}
|
|
160
164
|
|
|
@@ -4010,12 +4014,12 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
|
|
|
4010
4014
|
while (true) {
|
|
4011
4015
|
const prepared = await prepareLoginConnection(parsed, connectionPage);
|
|
4012
4016
|
connectionPage = prepared.connectionPage || connectionPage;
|
|
4013
|
-
const roleRestartBeforeAgent = connectionPage?.getRoleRestartRequest?.() || roleRestartRequest;
|
|
4014
|
-
if (roleRestartBeforeAgent?.role) {
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
process.exit(0);
|
|
4017
|
+
const roleRestartBeforeAgent = connectionPage?.getRoleRestartRequest?.() || roleRestartRequest;
|
|
4018
|
+
if (roleRestartBeforeAgent?.role) {
|
|
4019
|
+
if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) process.exit(43);
|
|
4020
|
+
await spawnUnifiedRoleRestart(roleRestartBeforeAgent.role);
|
|
4021
|
+
connectionPage?.close?.();
|
|
4022
|
+
process.exit(0);
|
|
4019
4023
|
}
|
|
4020
4024
|
const fastRuntime = getFastRuntime();
|
|
4021
4025
|
const useFast = shouldTryFast(prepared, fastRuntime);
|
|
@@ -4104,12 +4108,12 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
|
|
|
4104
4108
|
});
|
|
4105
4109
|
});
|
|
4106
4110
|
}
|
|
4107
|
-
const requestedRoleRestart = connectionPage?.getRoleRestartRequest?.();
|
|
4108
|
-
if (requestedRoleRestart?.role) {
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
process.exit(0);
|
|
4111
|
+
const requestedRoleRestart = connectionPage?.getRoleRestartRequest?.();
|
|
4112
|
+
if (requestedRoleRestart?.role) {
|
|
4113
|
+
if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) process.exit(43);
|
|
4114
|
+
await spawnUnifiedRoleRestart(requestedRoleRestart.role);
|
|
4115
|
+
connectionPage?.close?.();
|
|
4116
|
+
process.exit(0);
|
|
4113
4117
|
}
|
|
4114
4118
|
if (result?.signal) {
|
|
4115
4119
|
connectionPage?.close?.();
|
package/hub/src/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import express from 'express';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { dirname, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import os from 'node:os';
|
|
@@ -106,6 +106,10 @@ const supabasePublishableKey = String(configuredSupabaseKey || PRODUCTION_SUPABA
|
|
|
106
106
|
const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
|
|
107
107
|
? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
|
|
108
108
|
: AUTH_REQUEST_TIMEOUT_MS;
|
|
109
|
+
const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
|
|
110
|
+
const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
|
|
111
|
+
? ''
|
|
112
|
+
: String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
|
|
109
113
|
const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
|
|
110
114
|
&& ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
|
|
111
115
|
? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
|
|
@@ -306,11 +310,82 @@ function normalizeSessionExpiryMs(value) {
|
|
|
306
310
|
return numeric < 100_000_000_000 ? Math.round(numeric * 1000) : Math.round(numeric);
|
|
307
311
|
}
|
|
308
312
|
|
|
313
|
+
function readRuntimeAuthState() {
|
|
314
|
+
if (!runtimeAuthStatePath) return {};
|
|
315
|
+
try {
|
|
316
|
+
const state = JSON.parse(readFileSync(runtimeAuthStatePath, 'utf8'));
|
|
317
|
+
return state && typeof state === 'object' ? state : {};
|
|
318
|
+
} catch {
|
|
319
|
+
return {};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
324
|
+
try {
|
|
325
|
+
const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
|
|
326
|
+
const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
327
|
+
return session && typeof session === 'object' ? session : null;
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function writePrivateRuntimeAuthState(state) {
|
|
334
|
+
if (!runtimeAuthStatePath) return false;
|
|
335
|
+
mkdirSync(dirname(runtimeAuthStatePath), { recursive: true });
|
|
336
|
+
const temporaryPath = `${runtimeAuthStatePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
337
|
+
try {
|
|
338
|
+
writeFileSync(temporaryPath, JSON.stringify(state, null, 2), 'utf8');
|
|
339
|
+
try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
|
|
340
|
+
renameSync(temporaryPath, runtimeAuthStatePath);
|
|
341
|
+
try { chmodSync(runtimeAuthStatePath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
|
|
342
|
+
return true;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
try { rmSync(temporaryPath, { force: true }); } catch { /* best effort */ }
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function persistRuntimeSession(session) {
|
|
350
|
+
if (!runtimeAuthStatePath) return false;
|
|
351
|
+
const state = readRuntimeAuthState();
|
|
352
|
+
const previous = readPersistedRuntimeSession(state) || {};
|
|
353
|
+
const incomingUserId = String(session?.user?.id || '').trim();
|
|
354
|
+
const previousUserId = String(previous?.user?.id || '').trim();
|
|
355
|
+
const sameUser = !incomingUserId || !previousUserId || incomingUserId === previousUserId;
|
|
356
|
+
const refreshToken = String(
|
|
357
|
+
session?.refresh_token
|
|
358
|
+
|| (sameUser ? previous?.refresh_token : '')
|
|
359
|
+
|| ''
|
|
360
|
+
).trim();
|
|
361
|
+
const normalized = normalizeRuntimeAuthSession({
|
|
362
|
+
...previous,
|
|
363
|
+
...session,
|
|
364
|
+
refresh_token: refreshToken,
|
|
365
|
+
user: {
|
|
366
|
+
...(sameUser && previous?.user && typeof previous.user === 'object' ? previous.user : {}),
|
|
367
|
+
...(session?.user && typeof session.user === 'object' ? session.user : {})
|
|
368
|
+
}
|
|
369
|
+
}, { requireRefreshToken: true });
|
|
370
|
+
if (!normalized.ok) return false;
|
|
371
|
+
state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
|
|
372
|
+
writePrivateRuntimeAuthState(state);
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function clearPersistedRuntimeSession() {
|
|
377
|
+
if (!runtimeAuthStatePath) return false;
|
|
378
|
+
const state = readRuntimeAuthState();
|
|
379
|
+
delete state[CLIENT_AUTH_STORAGE_KEY];
|
|
380
|
+
return writePrivateRuntimeAuthState(state);
|
|
381
|
+
}
|
|
382
|
+
|
|
309
383
|
function clearRuntimeSession() {
|
|
310
384
|
runtimeAccessToken = '';
|
|
311
385
|
runtimeRefreshToken = '';
|
|
312
386
|
runtimeAccessTokenExpiresAt = 0;
|
|
313
387
|
runtimeManager.setAuthenticated(false);
|
|
388
|
+
try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
|
|
314
389
|
}
|
|
315
390
|
|
|
316
391
|
async function getRuntimeAccessToken() {
|
|
@@ -356,6 +431,15 @@ async function getRuntimeAccessToken() {
|
|
|
356
431
|
runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
|
|
357
432
|
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
|
|
358
433
|
|| (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
|
|
434
|
+
try {
|
|
435
|
+
persistRuntimeSession({
|
|
436
|
+
access_token: runtimeAccessToken,
|
|
437
|
+
refresh_token: runtimeRefreshToken,
|
|
438
|
+
expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
|
|
439
|
+
});
|
|
440
|
+
} catch (error) {
|
|
441
|
+
console.warn(`[LiveDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
|
|
442
|
+
}
|
|
359
443
|
return runtimeAccessToken;
|
|
360
444
|
}
|
|
361
445
|
|
|
@@ -2913,13 +2997,19 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
2913
2997
|
runtimeRefreshToken = refreshToken;
|
|
2914
2998
|
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
|
|
2915
2999
|
runtimeManager.setAuthenticated(true, user.id);
|
|
3000
|
+
const persisted = persistRuntimeSession({
|
|
3001
|
+
access_token: accessToken,
|
|
3002
|
+
refresh_token: refreshToken,
|
|
3003
|
+
expires_at: expiresAt,
|
|
3004
|
+
user
|
|
3005
|
+
});
|
|
2916
3006
|
const hostTarget = runtimeRole === 'hub'
|
|
2917
3007
|
? await publishHubHostTargetWithPendingRoleTakeover('session-received')
|
|
2918
3008
|
: { ok: true, active: false };
|
|
2919
3009
|
if (runtimeRole === 'hub') {
|
|
2920
3010
|
startHubHostTargetLeaseRenewal();
|
|
2921
3011
|
}
|
|
2922
|
-
res.json({ ok: true, authenticated: true, userId: user.id, role: runtimeRole, hostTarget });
|
|
3012
|
+
res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
|
|
2923
3013
|
} catch (error) {
|
|
2924
3014
|
const message = error instanceof Error ? error.message : String(error);
|
|
2925
3015
|
const status = authVerificationHttpStatus(message);
|
package/package.json
CHANGED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const ROLE_TRANSITION_SUPERVISOR_FLAG = '--run-livedesk-role-transition-supervisor';
|
|
18
|
+
const SUPERVISOR_CLAIM_TIMEOUT_MS = 5_000;
|
|
19
|
+
const OLD_RUNTIME_EXIT_TIMEOUT_MS = 60_000;
|
|
20
|
+
const REPLACEMENT_HEALTH_TIMEOUT_MS = 25_000;
|
|
21
|
+
const REPLACEMENT_STABILITY_MS = 750;
|
|
22
|
+
const POLL_MS = 100;
|
|
23
|
+
|
|
24
|
+
function wait(milliseconds) {
|
|
25
|
+
return new Promise(resolveWait => setTimeout(resolveWait, milliseconds));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeRole(value) {
|
|
29
|
+
const role = String(value || '').trim().toLowerCase();
|
|
30
|
+
return role === 'hub' || role === 'client' ? role : '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function positiveInteger(value, fallback = 0) {
|
|
34
|
+
const number = Number(value);
|
|
35
|
+
return Number.isInteger(number) && number > 0 ? number : fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function processIsAlive(pid) {
|
|
39
|
+
const processId = positiveInteger(pid);
|
|
40
|
+
if (processId <= 1) return false;
|
|
41
|
+
try {
|
|
42
|
+
process.kill(processId, 0);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readJson(path) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeJsonAtomic(path, value) {
|
|
58
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
59
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
60
|
+
try {
|
|
61
|
+
writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
|
|
62
|
+
renameSync(temporaryPath, path);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
try { rmSync(temporaryPath, { force: true }); } catch { /* best effort */ }
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function appendDiagnostic(path, message) {
|
|
70
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
71
|
+
appendFileSync(path, `[${new Date().toISOString()}] ${String(message || '')}\n`, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function waitForProcessExit(pid, timeoutMs = OLD_RUNTIME_EXIT_TIMEOUT_MS) {
|
|
75
|
+
const deadline = Date.now() + timeoutMs;
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
if (!processIsAlive(pid)) return true;
|
|
78
|
+
await wait(POLL_MS);
|
|
79
|
+
}
|
|
80
|
+
return !processIsAlive(pid);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function probeRuntimeStatus(port, role, previousPid) {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/runtime/status`, {
|
|
86
|
+
cache: 'no-store',
|
|
87
|
+
signal: AbortSignal.timeout(1_500)
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) return null;
|
|
90
|
+
const status = await response.json();
|
|
91
|
+
const runtimePid = positiveInteger(status?.runtimePid);
|
|
92
|
+
if (status?.role !== role || runtimePid <= 1 || runtimePid === previousPid) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (role === 'hub' && status?.runtimeStarted !== true) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return { status, runtimePid };
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function probeTcp(port) {
|
|
105
|
+
return new Promise(resolveProbe => {
|
|
106
|
+
const socket = net.createConnection({ host: '127.0.0.1', port });
|
|
107
|
+
const finish = ok => {
|
|
108
|
+
socket.removeAllListeners();
|
|
109
|
+
socket.destroy();
|
|
110
|
+
resolveProbe(ok);
|
|
111
|
+
};
|
|
112
|
+
const timer = setTimeout(() => finish(false), 1_500);
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
socket.once('connect', () => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
finish(true);
|
|
117
|
+
});
|
|
118
|
+
socket.once('error', () => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
finish(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function verifyReplacement({ child, role, previousPid, runtimePort, remotePort }) {
|
|
126
|
+
const deadline = Date.now() + REPLACEMENT_HEALTH_TIMEOUT_MS;
|
|
127
|
+
let stableRuntimePid = 0;
|
|
128
|
+
let stableSince = 0;
|
|
129
|
+
while (Date.now() < deadline) {
|
|
130
|
+
if (!processIsAlive(child.pid)) {
|
|
131
|
+
throw new Error(`replacement-launcher-exited:${child.pid || 0}`);
|
|
132
|
+
}
|
|
133
|
+
const probe = await probeRuntimeStatus(runtimePort, role, previousPid);
|
|
134
|
+
const remoteReady = probe && (role !== 'hub' || await probeTcp(remotePort));
|
|
135
|
+
if (probe && remoteReady) {
|
|
136
|
+
if (stableRuntimePid !== probe.runtimePid) {
|
|
137
|
+
stableRuntimePid = probe.runtimePid;
|
|
138
|
+
stableSince = Date.now();
|
|
139
|
+
} else if (Date.now() - stableSince >= REPLACEMENT_STABILITY_MS) {
|
|
140
|
+
return { runtimePid: probe.runtimePid, appVersion: String(probe.status?.appVersion || '') };
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
stableRuntimePid = 0;
|
|
144
|
+
stableSince = 0;
|
|
145
|
+
}
|
|
146
|
+
await wait(POLL_MS);
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`replacement-health-timeout:${role}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function terminateOwnedProcessTree(pid) {
|
|
152
|
+
const processId = positiveInteger(pid);
|
|
153
|
+
if (processId <= 1 || !processIsAlive(processId)) return;
|
|
154
|
+
if (process.platform === 'win32') {
|
|
155
|
+
await new Promise(resolveKill => {
|
|
156
|
+
execFile(
|
|
157
|
+
'taskkill.exe',
|
|
158
|
+
['/PID', String(processId), '/T', '/F'],
|
|
159
|
+
{ windowsHide: true, timeout: 10_000 },
|
|
160
|
+
() => resolveKill()
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try { process.kill(-processId, 'SIGTERM'); } catch {
|
|
166
|
+
try { process.kill(processId, 'SIGTERM'); } catch { /* already stopped */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function supervisorSettings(env = process.env) {
|
|
171
|
+
const launcherEntry = String(env.LIVEDESK_ROLE_TRANSITION_LAUNCHER_ENTRY || '').trim();
|
|
172
|
+
const statePath = String(env.LIVEDESK_ROLE_TRANSITION_STATE_PATH || '').trim();
|
|
173
|
+
const logPath = String(env.LIVEDESK_ROLE_TRANSITION_LOG_PATH || '').trim();
|
|
174
|
+
return {
|
|
175
|
+
transitionId: String(env.LIVEDESK_ROLE_TRANSITION_ID || '').trim(),
|
|
176
|
+
role: normalizeRole(env.LIVEDESK_ROLE_TRANSITION_TARGET),
|
|
177
|
+
previousPid: positiveInteger(env.LIVEDESK_ROLE_TRANSITION_PREVIOUS_PID),
|
|
178
|
+
launcherEntry: launcherEntry ? resolve(launcherEntry) : '',
|
|
179
|
+
statePath: statePath ? resolve(statePath) : '',
|
|
180
|
+
logPath: logPath ? resolve(logPath) : '',
|
|
181
|
+
launchCwd: resolve(String(env.LIVEDESK_ROLE_TRANSITION_CWD || process.cwd()).trim()),
|
|
182
|
+
runtimePort: positiveInteger(env.LIVEDESK_ROLE_TRANSITION_RUNTIME_PORT, 5179),
|
|
183
|
+
remotePort: positiveInteger(env.LIVEDESK_ROLE_TRANSITION_REMOTE_PORT, 5197)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function runRoleTransitionSupervisor(env = process.env) {
|
|
188
|
+
const settings = supervisorSettings(env);
|
|
189
|
+
if (!settings.transitionId || !settings.role || settings.previousPid <= 1) {
|
|
190
|
+
throw new Error('invalid-role-transition-supervisor-settings');
|
|
191
|
+
}
|
|
192
|
+
if (!existsSync(settings.launcherEntry)) {
|
|
193
|
+
throw new Error(`role-transition-launcher-missing:${settings.launcherEntry}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const writeState = (stage, details = {}) => {
|
|
197
|
+
writeJsonAtomic(settings.statePath, {
|
|
198
|
+
transitionId: settings.transitionId,
|
|
199
|
+
role: settings.role,
|
|
200
|
+
stage,
|
|
201
|
+
supervisorPid: process.pid,
|
|
202
|
+
previousPid: settings.previousPid,
|
|
203
|
+
updatedAt: new Date().toISOString(),
|
|
204
|
+
...details
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
appendDiagnostic(
|
|
209
|
+
settings.logPath,
|
|
210
|
+
`transition=${settings.transitionId} target=${settings.role} supervisor=${process.pid} previous=${settings.previousPid}`
|
|
211
|
+
);
|
|
212
|
+
writeState('supervisor-ready');
|
|
213
|
+
if (!await waitForProcessExit(settings.previousPid)) {
|
|
214
|
+
throw new Error(`previous-runtime-exit-timeout:${settings.previousPid}`);
|
|
215
|
+
}
|
|
216
|
+
writeState('previous-runtime-exited');
|
|
217
|
+
|
|
218
|
+
let lastError = null;
|
|
219
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
220
|
+
const replacementEnvironment = {
|
|
221
|
+
...env,
|
|
222
|
+
LIVEDESK_ROLE_TRANSITION: '',
|
|
223
|
+
LIVEDESK_RESTART_WAIT_PID: '',
|
|
224
|
+
LIVEDESK_ROLE_TAKEOVER: settings.role === 'hub' ? '1' : '',
|
|
225
|
+
LIVEDESK_SKIP_BROWSER_OPEN: '1'
|
|
226
|
+
};
|
|
227
|
+
for (const name of Object.keys(replacementEnvironment)) {
|
|
228
|
+
if (name.startsWith('LIVEDESK_ROLE_TRANSITION_')) {
|
|
229
|
+
delete replacementEnvironment[name];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let logDescriptor = 0;
|
|
234
|
+
let child = null;
|
|
235
|
+
try {
|
|
236
|
+
logDescriptor = openSync(settings.logPath, 'a');
|
|
237
|
+
child = spawn(
|
|
238
|
+
process.execPath,
|
|
239
|
+
[settings.launcherEntry, '--force-role', settings.role, '--no-open'],
|
|
240
|
+
{
|
|
241
|
+
cwd: settings.launchCwd,
|
|
242
|
+
env: replacementEnvironment,
|
|
243
|
+
detached: true,
|
|
244
|
+
stdio: ['ignore', logDescriptor, logDescriptor],
|
|
245
|
+
windowsHide: true
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
await new Promise((resolveSpawn, rejectSpawn) => {
|
|
249
|
+
child.once('spawn', resolveSpawn);
|
|
250
|
+
child.once('error', rejectSpawn);
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
lastError = error;
|
|
254
|
+
appendDiagnostic(settings.logPath, `attempt=${attempt} launch-failed error=${error?.message || error}`);
|
|
255
|
+
if (child?.pid && processIsAlive(child.pid)) {
|
|
256
|
+
await terminateOwnedProcessTree(child.pid);
|
|
257
|
+
}
|
|
258
|
+
await wait(350);
|
|
259
|
+
continue;
|
|
260
|
+
} finally {
|
|
261
|
+
if (logDescriptor > 0) closeSync(logDescriptor);
|
|
262
|
+
}
|
|
263
|
+
child.unref();
|
|
264
|
+
writeState('replacement-launched', { attempt, launcherPid: child.pid || 0 });
|
|
265
|
+
appendDiagnostic(settings.logPath, `attempt=${attempt} replacement-launcher=${child.pid || 0}`);
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const verified = await verifyReplacement({
|
|
269
|
+
child,
|
|
270
|
+
role: settings.role,
|
|
271
|
+
previousPid: settings.previousPid,
|
|
272
|
+
runtimePort: settings.runtimePort,
|
|
273
|
+
remotePort: settings.remotePort
|
|
274
|
+
});
|
|
275
|
+
writeState('verified', {
|
|
276
|
+
attempt,
|
|
277
|
+
launcherPid: child.pid || 0,
|
|
278
|
+
runtimePid: verified.runtimePid,
|
|
279
|
+
appVersion: verified.appVersion,
|
|
280
|
+
completedAt: new Date().toISOString()
|
|
281
|
+
});
|
|
282
|
+
appendDiagnostic(
|
|
283
|
+
settings.logPath,
|
|
284
|
+
`verified target=${settings.role} launcher=${child.pid || 0} runtime=${verified.runtimePid} version=${verified.appVersion || 'unknown'}`
|
|
285
|
+
);
|
|
286
|
+
return;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
lastError = error;
|
|
289
|
+
appendDiagnostic(settings.logPath, `attempt=${attempt} failed error=${error?.message || error}`);
|
|
290
|
+
if (processIsAlive(child.pid)) {
|
|
291
|
+
await terminateOwnedProcessTree(child.pid);
|
|
292
|
+
}
|
|
293
|
+
await wait(350);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
throw lastError || new Error('role-transition-replacement-failed');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function startRoleTransitionSupervisor(options = {}) {
|
|
300
|
+
const role = normalizeRole(options.role);
|
|
301
|
+
const previousPid = positiveInteger(options.previousPid, process.pid);
|
|
302
|
+
const launcherEntryValue = String(options.launcherEntry || '').trim();
|
|
303
|
+
const stateDirValue = String(options.stateDir || '').trim();
|
|
304
|
+
const launcherEntry = launcherEntryValue ? resolve(launcherEntryValue) : '';
|
|
305
|
+
const stateDir = stateDirValue ? resolve(stateDirValue) : '';
|
|
306
|
+
if (!role || previousPid <= 1 || !launcherEntry || !existsSync(launcherEntry) || !stateDir) {
|
|
307
|
+
throw new Error('invalid-role-transition-handoff');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
mkdirSync(stateDir, { recursive: true });
|
|
311
|
+
const statePath = join(stateDir, 'role-transition.json');
|
|
312
|
+
const logPath = join(stateDir, 'role-transition.log');
|
|
313
|
+
const transitionId = `role-${role}-${Date.now()}-${process.pid}`;
|
|
314
|
+
rmSync(statePath, { force: true });
|
|
315
|
+
const child = spawn(
|
|
316
|
+
process.execPath,
|
|
317
|
+
[fileURLToPath(import.meta.url), ROLE_TRANSITION_SUPERVISOR_FLAG],
|
|
318
|
+
{
|
|
319
|
+
cwd: options.cwd || process.cwd(),
|
|
320
|
+
env: {
|
|
321
|
+
...(options.env || process.env),
|
|
322
|
+
LIVEDESK_ROLE_TRANSITION_ID: transitionId,
|
|
323
|
+
LIVEDESK_ROLE_TRANSITION_TARGET: role,
|
|
324
|
+
LIVEDESK_ROLE_TRANSITION_PREVIOUS_PID: String(previousPid),
|
|
325
|
+
LIVEDESK_ROLE_TRANSITION_LAUNCHER_ENTRY: launcherEntry,
|
|
326
|
+
LIVEDESK_ROLE_TRANSITION_STATE_PATH: statePath,
|
|
327
|
+
LIVEDESK_ROLE_TRANSITION_LOG_PATH: logPath,
|
|
328
|
+
LIVEDESK_ROLE_TRANSITION_CWD: String(options.cwd || process.cwd()),
|
|
329
|
+
LIVEDESK_ROLE_TRANSITION_RUNTIME_PORT: String(positiveInteger(options.runtimePort, 5179)),
|
|
330
|
+
LIVEDESK_ROLE_TRANSITION_REMOTE_PORT: String(positiveInteger(options.remotePort, 5197))
|
|
331
|
+
},
|
|
332
|
+
detached: true,
|
|
333
|
+
stdio: 'ignore',
|
|
334
|
+
windowsHide: true
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
await new Promise((resolveSpawn, rejectSpawn) => {
|
|
338
|
+
child.once('spawn', resolveSpawn);
|
|
339
|
+
child.once('error', rejectSpawn);
|
|
340
|
+
});
|
|
341
|
+
child.unref();
|
|
342
|
+
|
|
343
|
+
const deadline = Date.now() + SUPERVISOR_CLAIM_TIMEOUT_MS;
|
|
344
|
+
while (Date.now() < deadline) {
|
|
345
|
+
const state = readJson(statePath);
|
|
346
|
+
if (state?.transitionId === transitionId
|
|
347
|
+
&& state?.stage === 'supervisor-ready'
|
|
348
|
+
&& positiveInteger(state?.supervisorPid) === child.pid) {
|
|
349
|
+
return { transitionId, supervisorPid: child.pid, statePath, logPath };
|
|
350
|
+
}
|
|
351
|
+
if (!processIsAlive(child.pid)) {
|
|
352
|
+
throw new Error(`role-transition-supervisor-exited:${child.pid || 0}`);
|
|
353
|
+
}
|
|
354
|
+
await wait(50);
|
|
355
|
+
}
|
|
356
|
+
await terminateOwnedProcessTree(child.pid);
|
|
357
|
+
throw new Error(`role-transition-supervisor-claim-timeout:${child.pid || 0}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const isDirectSupervisorRun = process.argv.includes(ROLE_TRANSITION_SUPERVISOR_FLAG)
|
|
361
|
+
&& resolve(String(process.argv[1] || '')) === fileURLToPath(import.meta.url);
|
|
362
|
+
if (isDirectSupervisorRun) {
|
|
363
|
+
runRoleTransitionSupervisor().catch(error => {
|
|
364
|
+
const settings = supervisorSettings();
|
|
365
|
+
try {
|
|
366
|
+
appendDiagnostic(settings.logPath, `failed error=${error?.message || error}`);
|
|
367
|
+
writeJsonAtomic(settings.statePath, {
|
|
368
|
+
transitionId: settings.transitionId,
|
|
369
|
+
role: settings.role,
|
|
370
|
+
stage: 'failed',
|
|
371
|
+
supervisorPid: process.pid,
|
|
372
|
+
previousPid: settings.previousPid,
|
|
373
|
+
error: error?.message || String(error),
|
|
374
|
+
updatedAt: new Date().toISOString(),
|
|
375
|
+
completedAt: new Date().toISOString()
|
|
376
|
+
});
|
|
377
|
+
} catch {
|
|
378
|
+
// The detached supervisor has no console; best-effort diagnostics only.
|
|
379
|
+
}
|
|
380
|
+
process.exitCode = 1;
|
|
381
|
+
});
|
|
382
|
+
}
|