livedesk 0.1.387 → 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 +733 -87
- 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
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
readFileSync,
|
|
10
10
|
renameSync,
|
|
11
11
|
rmSync,
|
|
12
|
+
statSync,
|
|
12
13
|
writeFileSync
|
|
13
14
|
} from 'node:fs';
|
|
14
15
|
import http from 'node:http';
|
|
15
16
|
import os from 'node:os';
|
|
16
|
-
import { dirname, join } from 'node:path';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
18
|
|
|
18
19
|
const DEFAULT_RUNTIME_PORT = 5179;
|
|
19
20
|
const TARGET_ATTEMPT_LIMIT = 3;
|
|
@@ -22,6 +23,13 @@ const VERIFY_TIMEOUT_MS = 45_000;
|
|
|
22
23
|
const VERIFY_POLL_MS = 500;
|
|
23
24
|
const VERIFY_STABLE_MS = 5_000;
|
|
24
25
|
const PROCESS_ARGUMENT_QUERY_TIMEOUT_MS = 10_000;
|
|
26
|
+
const SUPERSEDED_OPERATION_CODE = 'LIVEDESK_CLIENT_UPDATE_SUPERSEDED';
|
|
27
|
+
const DEADLINE_EXPIRED_CODE = 'LIVEDESK_CLIENT_UPDATE_DEADLINE_EXPIRED';
|
|
28
|
+
const TERMINAL_UPDATE_STAGES = new Set(['connected', 'failed', 'restored']);
|
|
29
|
+
const UPDATE_STATE_LOCK_TIMEOUT_MS = 10_000;
|
|
30
|
+
const UPDATE_STATE_LOCK_POLL_MS = 25;
|
|
31
|
+
const UPDATE_STATE_EMPTY_LOCK_STALE_MS = 5_000;
|
|
32
|
+
const updateStateLockWaitBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
25
33
|
|
|
26
34
|
const PRESERVED_SINGLE_FLAGS = new Set([
|
|
27
35
|
'--control',
|
|
@@ -67,6 +75,106 @@ function cleanVersion(value) {
|
|
|
67
75
|
return String(value || '').trim().replace(/^v/i, '');
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
function safePathSegment(value, fallback = 'unknown') {
|
|
79
|
+
return String(value || '')
|
|
80
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
81
|
+
.slice(0, 120) || fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class LegacyClientUpdateSupersededError extends Error {
|
|
85
|
+
constructor(expectedOperationId, actualOperationId) {
|
|
86
|
+
super(
|
|
87
|
+
`LiveDesk Client update ${expectedOperationId || 'unknown'} was superseded by `
|
|
88
|
+
+ `${actualOperationId || 'another operation'}.`
|
|
89
|
+
);
|
|
90
|
+
this.name = 'LegacyClientUpdateSupersededError';
|
|
91
|
+
this.code = SUPERSEDED_OPERATION_CODE;
|
|
92
|
+
this.expectedOperationId = String(expectedOperationId || '');
|
|
93
|
+
this.actualOperationId = String(actualOperationId || '');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isLegacyClientUpdateSupersededError(error) {
|
|
98
|
+
return error?.code === SUPERSEDED_OPERATION_CODE;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isLegacyClientUpdateDeadlineExpiredError(error) {
|
|
102
|
+
return error?.code === DEADLINE_EXPIRED_CODE;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function assertLegacyClientUpdateDeadline(
|
|
106
|
+
config,
|
|
107
|
+
now = Date.now,
|
|
108
|
+
checkpoint = 'update'
|
|
109
|
+
) {
|
|
110
|
+
const deadlineEpochMs = Number(config?.updateDeadlineEpochMs);
|
|
111
|
+
if (!Number.isSafeInteger(deadlineEpochMs) || deadlineEpochMs <= 0) {
|
|
112
|
+
const error = new Error('LiveDesk Client update has no valid absolute deadline.');
|
|
113
|
+
error.code = DEADLINE_EXPIRED_CODE;
|
|
114
|
+
error.deadlineExpired = true;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
const currentEpochMs = Number(now());
|
|
118
|
+
if (currentEpochMs < deadlineEpochMs) return deadlineEpochMs - currentEpochMs;
|
|
119
|
+
const error = new Error(
|
|
120
|
+
`LiveDesk Client update deadline expired before ${checkpoint} `
|
|
121
|
+
+ `(deadline=${new Date(deadlineEpochMs).toISOString()}).`
|
|
122
|
+
);
|
|
123
|
+
error.code = DEADLINE_EXPIRED_CODE;
|
|
124
|
+
error.deadlineExpired = true;
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getLegacyClientUpdateNeutralCwd(stateDir, operationId) {
|
|
129
|
+
return join(
|
|
130
|
+
os.tmpdir(),
|
|
131
|
+
'livedesk-update-cwd',
|
|
132
|
+
`client-${safePathSegment(operationId)}`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function prepareNeutralCwd(cwd) {
|
|
137
|
+
const configuredCwd = String(cwd || '').trim();
|
|
138
|
+
if (!configuredCwd) throw new Error('LiveDesk update neutral working directory is unavailable');
|
|
139
|
+
const neutralCwd = resolve(configuredCwd);
|
|
140
|
+
mkdirSync(neutralCwd, { recursive: true });
|
|
141
|
+
for (let ancestor = neutralCwd; ; ancestor = dirname(ancestor)) {
|
|
142
|
+
const shadowPath = [
|
|
143
|
+
join(ancestor, 'package.json'),
|
|
144
|
+
join(ancestor, 'node_modules', 'livedesk', 'package.json')
|
|
145
|
+
].find(candidate => existsSync(candidate));
|
|
146
|
+
if (shadowPath) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`LiveDesk update neutral working directory is shadowed by ${shadowPath}: ${neutralCwd}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const parent = dirname(ancestor);
|
|
152
|
+
if (parent === ancestor) break;
|
|
153
|
+
}
|
|
154
|
+
return neutralCwd;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function buildLegacyClientUpdateNpxEnvironment(baseEnv, neutralCwd) {
|
|
158
|
+
const env = { ...baseEnv };
|
|
159
|
+
const isolatedNames = new Set([
|
|
160
|
+
'init_cwd',
|
|
161
|
+
'npm_config_local_prefix',
|
|
162
|
+
'npm_config_workspace',
|
|
163
|
+
'npm_config_workspaces',
|
|
164
|
+
'npm_config_include_workspace_root',
|
|
165
|
+
'npm_package_json',
|
|
166
|
+
'npm_lifecycle_event',
|
|
167
|
+
'npm_lifecycle_script'
|
|
168
|
+
]);
|
|
169
|
+
for (const key of Object.keys(env)) {
|
|
170
|
+
if (isolatedNames.has(key.toLowerCase())) delete env[key];
|
|
171
|
+
}
|
|
172
|
+
env.INIT_CWD = neutralCwd;
|
|
173
|
+
env.npm_config_local_prefix = neutralCwd;
|
|
174
|
+
env.npm_config_include_workspace_root = 'false';
|
|
175
|
+
return env;
|
|
176
|
+
}
|
|
177
|
+
|
|
70
178
|
function positivePid(value) {
|
|
71
179
|
const parsed = Number(value);
|
|
72
180
|
return Number.isInteger(parsed) && parsed > 1 ? parsed : 0;
|
|
@@ -83,25 +191,67 @@ function readManifest(relativePath) {
|
|
|
83
191
|
return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8'));
|
|
84
192
|
}
|
|
85
193
|
|
|
194
|
+
export function expandLegacyBridgeEnvironment(env) {
|
|
195
|
+
try {
|
|
196
|
+
const values = JSON.parse(
|
|
197
|
+
Buffer.from(String(env.C || ''), 'base64').toString('utf8')
|
|
198
|
+
);
|
|
199
|
+
if (!Array.isArray(values)
|
|
200
|
+
|| values.length !== 8
|
|
201
|
+
|| values.some(value => typeof value !== 'string')) {
|
|
202
|
+
return env;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
...env,
|
|
206
|
+
LIVEDESK_CLIENT_UPDATE_OPERATION_ID: values[0],
|
|
207
|
+
LIVEDESK_DEVICE_ID: values[1],
|
|
208
|
+
LIVEDESK_CLIENT_UPDATE_AGENT_PID: values[2],
|
|
209
|
+
LIVEDESK_CLIENT_UPDATE_CURRENT_PRODUCT_VERSION: values[3],
|
|
210
|
+
LIVEDESK_CLIENT_UPDATE_CURRENT_AGENT_VERSION: values[4],
|
|
211
|
+
LIVEDESK_CLIENT_UPDATE_TARGET_VERSION: values[5],
|
|
212
|
+
LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION: values[6],
|
|
213
|
+
LIVEDESK_CLIENT_UPDATE_DEADLINE_EPOCH_MS: values[7]
|
|
214
|
+
};
|
|
215
|
+
} catch {
|
|
216
|
+
return env;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
86
220
|
function readConfig(env = process.env) {
|
|
87
|
-
|
|
221
|
+
env = expandLegacyBridgeEnvironment(env);
|
|
222
|
+
const operationId = String(env.LIVEDESK_CLIENT_UPDATE_OPERATION_ID || '').trim();
|
|
223
|
+
const originalCwd = resolve(String(
|
|
224
|
+
env.LIVEDESK_UPDATE_ORIGINAL_CWD
|
|
225
|
+
|| env.LIVEDESK_CLIENT_UPDATE_CWD
|
|
226
|
+
|| process.cwd()
|
|
227
|
+
).trim());
|
|
228
|
+
const stateDir = resolve(
|
|
229
|
+
originalCwd,
|
|
230
|
+
String(env.LIVEDESK_STATE_DIR || join(os.homedir(), '.livedesk'))
|
|
231
|
+
);
|
|
232
|
+
const configuredStatePath = String(
|
|
233
|
+
env.LIVEDESK_LEGACY_CLIENT_UPDATE_STATE_PATH
|
|
234
|
+
|| env.LIVEDESK_CLIENT_UPDATE_STATE_PATH
|
|
235
|
+
|| join(stateDir, 'client-update-legacy.json')
|
|
236
|
+
);
|
|
88
237
|
return {
|
|
89
|
-
operationId
|
|
238
|
+
operationId,
|
|
90
239
|
deviceId: String(env.LIVEDESK_DEVICE_ID || '').trim(),
|
|
91
240
|
targetVersion: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_TARGET_VERSION),
|
|
92
241
|
targetProductVersion: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION),
|
|
93
|
-
currentProductVersion:
|
|
242
|
+
currentProductVersion:
|
|
243
|
+
cleanVersion(env.LIVEDESK_CLIENT_UPDATE_CURRENT_PRODUCT_VERSION)
|
|
244
|
+
|| cleanVersion(env.LIVEDESK_NPM_LAUNCHER_VERSION),
|
|
94
245
|
currentAgentVersion: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_CURRENT_AGENT_VERSION),
|
|
95
246
|
launcherPid: positivePid(env.LIVEDESK_CLIENT_PARENT_PID),
|
|
96
247
|
agentPid: positivePid(env.LIVEDESK_CLIENT_UPDATE_AGENT_PID),
|
|
97
248
|
starterPid: positivePid(env.LIVEDESK_UPDATE_STARTER_PID),
|
|
249
|
+
updateDeadlineEpochMs: Number(env.LIVEDESK_CLIENT_UPDATE_DEADLINE_EPOCH_MS),
|
|
98
250
|
runtimePort: normalizePort(env.LIVEDESK_CLIENT_RUNTIME_PORT || env.LIVEDESK_LOCAL_RUNTIME_PORT),
|
|
99
251
|
stateDir,
|
|
100
|
-
statePath:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|| join(stateDir, 'client-update-legacy.json')
|
|
104
|
-
),
|
|
252
|
+
statePath: resolve(originalCwd, configuredStatePath),
|
|
253
|
+
neutralCwd: getLegacyClientUpdateNeutralCwd(stateDir, operationId),
|
|
254
|
+
originalCwd,
|
|
105
255
|
restartArgs: []
|
|
106
256
|
};
|
|
107
257
|
}
|
|
@@ -391,7 +541,11 @@ export function buildLegacyClientRestartConfiguration({
|
|
|
391
541
|
function captureRestartConfiguration(config, env, readArguments = readLegacyProcessArguments) {
|
|
392
542
|
const preservedArgs = decodePreservedArgs(env);
|
|
393
543
|
if (preservedArgs) {
|
|
394
|
-
return
|
|
544
|
+
return {
|
|
545
|
+
...buildLegacyClientRestartConfiguration({ env, preservedArgs }),
|
|
546
|
+
launcherArgs: [],
|
|
547
|
+
agentArgs: []
|
|
548
|
+
};
|
|
395
549
|
}
|
|
396
550
|
let launcherArgs;
|
|
397
551
|
let agentArgs;
|
|
@@ -404,7 +558,63 @@ function captureRestartConfiguration(config, env, readArguments = readLegacyProc
|
|
|
404
558
|
+ `the existing Client was left running. ${error?.message || error}`
|
|
405
559
|
);
|
|
406
560
|
}
|
|
407
|
-
return
|
|
561
|
+
return {
|
|
562
|
+
...buildLegacyClientRestartConfiguration({ env, launcherArgs, agentArgs }),
|
|
563
|
+
launcherArgs,
|
|
564
|
+
agentArgs
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function resolveExactLocalRestoreLauncher(config, env, frozen) {
|
|
569
|
+
const candidates = [
|
|
570
|
+
env.LIVEDESK_CLIENT_UPDATE_LOCAL_LAUNCHER,
|
|
571
|
+
env.LIVEDESK_UNIFIED_LAUNCHER_ENTRY,
|
|
572
|
+
...(Array.isArray(frozen?.launcherArgs) ? frozen.launcherArgs : [])
|
|
573
|
+
]
|
|
574
|
+
.map(value => String(value || '').trim())
|
|
575
|
+
.filter(Boolean);
|
|
576
|
+
const checked = [];
|
|
577
|
+
for (const candidate of candidates) {
|
|
578
|
+
const entryPath = resolve(config.originalCwd, candidate);
|
|
579
|
+
if (!existsSync(entryPath) || checked.includes(entryPath)) continue;
|
|
580
|
+
checked.push(entryPath);
|
|
581
|
+
const productRoot = resolve(dirname(entryPath), '..');
|
|
582
|
+
const productManifestPath = join(productRoot, 'package.json');
|
|
583
|
+
const clientManifestPath = join(productRoot, 'client', 'package.json');
|
|
584
|
+
if (!existsSync(productManifestPath) || !existsSync(clientManifestPath)) continue;
|
|
585
|
+
let productManifest;
|
|
586
|
+
let clientManifest;
|
|
587
|
+
try {
|
|
588
|
+
productManifest = JSON.parse(readFileSync(productManifestPath, 'utf8'));
|
|
589
|
+
clientManifest = JSON.parse(readFileSync(clientManifestPath, 'utf8'));
|
|
590
|
+
} catch {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const productName = String(productManifest?.name || '');
|
|
594
|
+
const clientName = String(clientManifest?.name || '');
|
|
595
|
+
const productVersion = cleanVersion(productManifest?.version);
|
|
596
|
+
const bundledAgentVersion = cleanVersion(productManifest?.livedeskClientVersion);
|
|
597
|
+
const clientVersion = cleanVersion(clientManifest?.version);
|
|
598
|
+
if (productName !== 'livedesk'
|
|
599
|
+
|| clientName !== '@livedesk/client'
|
|
600
|
+
|| productVersion !== config.currentProductVersion
|
|
601
|
+
|| clientVersion !== config.currentAgentVersion) {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (bundledAgentVersion && bundledAgentVersion !== config.currentAgentVersion) continue;
|
|
605
|
+
return {
|
|
606
|
+
entryPath,
|
|
607
|
+
productRoot,
|
|
608
|
+
productManifestPath,
|
|
609
|
+
clientManifestPath,
|
|
610
|
+
productVersion,
|
|
611
|
+
agentVersion: clientVersion
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
throw new Error(
|
|
615
|
+
`LiveDesk could not freeze an exact local restore artifact for `
|
|
616
|
+
+ `${config.currentProductVersion}/${config.currentAgentVersion}; the existing Client was left running.`
|
|
617
|
+
);
|
|
408
618
|
}
|
|
409
619
|
|
|
410
620
|
function readUpdateState(config) {
|
|
@@ -415,6 +625,176 @@ function readUpdateState(config) {
|
|
|
415
625
|
}
|
|
416
626
|
}
|
|
417
627
|
|
|
628
|
+
function waitForUpdateStateLock(milliseconds) {
|
|
629
|
+
Atomics.wait(updateStateLockWaitBuffer, 0, 0, Math.max(1, milliseconds));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function updateStateLockPath(config) {
|
|
633
|
+
return `${config.statePath}.lock`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function removeAbandonedUpdateStateLock(lockPath, aliveImpl, now) {
|
|
637
|
+
let owner = null;
|
|
638
|
+
try {
|
|
639
|
+
owner = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
640
|
+
} catch {
|
|
641
|
+
// A just-created lock can be observed before its owner record is written.
|
|
642
|
+
}
|
|
643
|
+
const ownerPid = positivePid(owner?.pid);
|
|
644
|
+
if (ownerPid > 1) {
|
|
645
|
+
if (aliveImpl(ownerPid)) return false;
|
|
646
|
+
} else {
|
|
647
|
+
try {
|
|
648
|
+
if (now() - statSync(lockPath).mtimeMs < UPDATE_STATE_EMPTY_LOCK_STALE_MS) return false;
|
|
649
|
+
} catch {
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
rmSync(lockPath);
|
|
655
|
+
return true;
|
|
656
|
+
} catch {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function withLegacyClientUpdateStateLock(
|
|
662
|
+
config,
|
|
663
|
+
callback,
|
|
664
|
+
{
|
|
665
|
+
aliveImpl = isAlive,
|
|
666
|
+
now = Date.now,
|
|
667
|
+
timeoutMs = UPDATE_STATE_LOCK_TIMEOUT_MS,
|
|
668
|
+
pollMs = UPDATE_STATE_LOCK_POLL_MS
|
|
669
|
+
} = {}
|
|
670
|
+
) {
|
|
671
|
+
const lockPath = updateStateLockPath(config);
|
|
672
|
+
const deadline = now() + Math.max(1, timeoutMs);
|
|
673
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
674
|
+
let descriptor = null;
|
|
675
|
+
let token = '';
|
|
676
|
+
while (descriptor === null) {
|
|
677
|
+
try {
|
|
678
|
+
descriptor = openSync(lockPath, 'wx', 0o600);
|
|
679
|
+
token = randomBytes(12).toString('hex');
|
|
680
|
+
writeFileSync(descriptor, JSON.stringify({
|
|
681
|
+
pid: process.pid,
|
|
682
|
+
token,
|
|
683
|
+
acquiredAt: new Date().toISOString()
|
|
684
|
+
}), 'utf8');
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (descriptor !== null) {
|
|
687
|
+
try { closeSync(descriptor); } catch { /* best effort */ }
|
|
688
|
+
descriptor = null;
|
|
689
|
+
try { rmSync(lockPath, { force: true }); } catch { /* failed owner initialization */ }
|
|
690
|
+
}
|
|
691
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
692
|
+
if (removeAbandonedUpdateStateLock(lockPath, aliveImpl, now)) continue;
|
|
693
|
+
if (now() >= deadline) {
|
|
694
|
+
throw new Error(`Timed out waiting for the LiveDesk update state lock: ${lockPath}`);
|
|
695
|
+
}
|
|
696
|
+
waitForUpdateStateLock(Math.min(Math.max(1, pollMs), Math.max(1, deadline - now())));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
return callback();
|
|
701
|
+
} finally {
|
|
702
|
+
try { closeSync(descriptor); } catch { /* process teardown may have closed it */ }
|
|
703
|
+
try {
|
|
704
|
+
const owner = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
705
|
+
if (owner?.token === token && Number(owner?.pid) === process.pid) {
|
|
706
|
+
rmSync(lockPath, { force: true });
|
|
707
|
+
}
|
|
708
|
+
} catch {
|
|
709
|
+
// A killed owner leaves a stale lock for the next operation to reclaim.
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function assertLegacyClientUpdateOperationOwnership(
|
|
715
|
+
config,
|
|
716
|
+
readState = readUpdateState
|
|
717
|
+
) {
|
|
718
|
+
const state = readState(config);
|
|
719
|
+
const actualOperationId = String(state?.operationId || '').trim();
|
|
720
|
+
if (actualOperationId && actualOperationId !== String(config?.operationId || '').trim()) {
|
|
721
|
+
throw new LegacyClientUpdateSupersededError(config?.operationId, actualOperationId);
|
|
722
|
+
}
|
|
723
|
+
if (actualOperationId
|
|
724
|
+
&& actualOperationId === String(config?.operationId || '').trim()
|
|
725
|
+
&& state?.cancelRequested === true) {
|
|
726
|
+
throw new LegacyClientUpdateSupersededError(
|
|
727
|
+
config?.operationId,
|
|
728
|
+
`${actualOperationId}:cancelled`
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
return state;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export function canReclaimLegacyClientUpdateOperation(previous, aliveImpl = isAlive) {
|
|
735
|
+
const handoffOwners = [
|
|
736
|
+
positivePid(previous?.supervisorPid),
|
|
737
|
+
positivePid(previous?.starterPid)
|
|
738
|
+
].filter(pid => pid > 1);
|
|
739
|
+
if (handoffOwners.length > 0) {
|
|
740
|
+
return handoffOwners.every(pid => !aliveImpl(pid));
|
|
741
|
+
}
|
|
742
|
+
if (String(previous?.stage || '') === 'scheduled') {
|
|
743
|
+
const schedulingOwners = [
|
|
744
|
+
positivePid(previous?.agentPid),
|
|
745
|
+
positivePid(previous?.launcherPid)
|
|
746
|
+
].filter(pid => pid > 1);
|
|
747
|
+
return schedulingOwners.length > 0
|
|
748
|
+
&& schedulingOwners.every(pid => !aliveImpl(pid));
|
|
749
|
+
}
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function claimLegacyClientUpdateOperation(config, aliveImpl = isAlive, now = Date.now) {
|
|
754
|
+
return withLegacyClientUpdateStateLock(config, () => {
|
|
755
|
+
assertLegacyClientUpdateDeadline(config, now, 'operation claim');
|
|
756
|
+
const previous = readUpdateState(config);
|
|
757
|
+
const previousOperationId = String(previous?.operationId || '').trim();
|
|
758
|
+
if (previousOperationId === config.operationId) {
|
|
759
|
+
if (previous?.cancelRequested === true) {
|
|
760
|
+
throw new LegacyClientUpdateSupersededError(
|
|
761
|
+
config.operationId,
|
|
762
|
+
`${previousOperationId}:cancelled`
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
return previous;
|
|
766
|
+
}
|
|
767
|
+
if (previousOperationId && !TERMINAL_UPDATE_STAGES.has(String(previous?.stage || ''))) {
|
|
768
|
+
if (!canReclaimLegacyClientUpdateOperation(previous, aliveImpl)) {
|
|
769
|
+
throw new LegacyClientUpdateSupersededError(config.operationId, previousOperationId);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
mkdirSync(dirname(config.statePath), { recursive: true });
|
|
773
|
+
const next = {
|
|
774
|
+
operationId: config.operationId,
|
|
775
|
+
stage: 'scheduled',
|
|
776
|
+
targetVersion: config.targetVersion,
|
|
777
|
+
targetProductVersion: config.targetProductVersion,
|
|
778
|
+
targetAgentVersion: config.targetVersion,
|
|
779
|
+
currentProductVersion: config.currentProductVersion,
|
|
780
|
+
updateDeadlineEpochMs: config.updateDeadlineEpochMs,
|
|
781
|
+
supervisorPid: process.pid,
|
|
782
|
+
claimedAt: new Date().toISOString(),
|
|
783
|
+
updatedAt: new Date().toISOString(),
|
|
784
|
+
restartVerified: false,
|
|
785
|
+
error: ''
|
|
786
|
+
};
|
|
787
|
+
const temporaryPath = `${config.statePath}.${process.pid}.${randomBytes(5).toString('hex')}.claim.tmp`;
|
|
788
|
+
try {
|
|
789
|
+
writeFileSync(temporaryPath, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
790
|
+
renameSync(temporaryPath, config.statePath);
|
|
791
|
+
} finally {
|
|
792
|
+
rmSync(temporaryPath, { force: true });
|
|
793
|
+
}
|
|
794
|
+
return next;
|
|
795
|
+
}, { aliveImpl, now });
|
|
796
|
+
}
|
|
797
|
+
|
|
418
798
|
function proofStatePath(config, attemptId) {
|
|
419
799
|
const safeAttemptId = String(attemptId || '')
|
|
420
800
|
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
@@ -422,29 +802,36 @@ function proofStatePath(config, attemptId) {
|
|
|
422
802
|
return join(dirname(config.statePath), `client-update-proof-${safeAttemptId}.json`);
|
|
423
803
|
}
|
|
424
804
|
|
|
425
|
-
function writeUpdateState(config, stage, details = {}) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
805
|
+
export function writeUpdateState(config, stage, details = {}, { now = Date.now } = {}) {
|
|
806
|
+
return withLegacyClientUpdateStateLock(config, () => {
|
|
807
|
+
assertLegacyClientUpdateOperationOwnership(config);
|
|
808
|
+
if (stage === 'preflight-ready') {
|
|
809
|
+
assertLegacyClientUpdateDeadline(config, now, 'preflight-ready commit');
|
|
810
|
+
}
|
|
811
|
+
mkdirSync(dirname(config.statePath), { recursive: true });
|
|
812
|
+
const previous = readUpdateState(config);
|
|
813
|
+
const next = {
|
|
814
|
+
...(previous && previous.operationId === config.operationId ? previous : {}),
|
|
815
|
+
operationId: config.operationId,
|
|
816
|
+
stage,
|
|
817
|
+
targetVersion: config.targetVersion,
|
|
818
|
+
targetProductVersion: config.targetProductVersion,
|
|
819
|
+
targetAgentVersion: config.targetVersion,
|
|
820
|
+
currentProductVersion: config.currentProductVersion,
|
|
821
|
+
updateDeadlineEpochMs: config.updateDeadlineEpochMs,
|
|
822
|
+
supervisorPid: process.pid,
|
|
823
|
+
updatedAt: new Date().toISOString(),
|
|
824
|
+
...details
|
|
825
|
+
};
|
|
826
|
+
const temporaryPath = `${config.statePath}.${process.pid}.${randomBytes(5).toString('hex')}.tmp`;
|
|
827
|
+
try {
|
|
828
|
+
writeFileSync(temporaryPath, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
829
|
+
renameSync(temporaryPath, config.statePath);
|
|
830
|
+
} finally {
|
|
831
|
+
rmSync(temporaryPath, { force: true });
|
|
832
|
+
}
|
|
833
|
+
return next;
|
|
834
|
+
}, { now });
|
|
448
835
|
}
|
|
449
836
|
|
|
450
837
|
function isAlive(pid) {
|
|
@@ -461,13 +848,21 @@ function sleep(milliseconds) {
|
|
|
461
848
|
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
462
849
|
}
|
|
463
850
|
|
|
464
|
-
async function waitForExit(
|
|
851
|
+
async function waitForExit(
|
|
852
|
+
pid,
|
|
853
|
+
timeoutMs,
|
|
854
|
+
sleepImpl = sleep,
|
|
855
|
+
aliveImpl = isAlive,
|
|
856
|
+
ownershipCheck = () => undefined
|
|
857
|
+
) {
|
|
465
858
|
if (pid <= 1) return true;
|
|
466
859
|
const deadline = Date.now() + timeoutMs;
|
|
467
860
|
while (Date.now() < deadline) {
|
|
861
|
+
ownershipCheck();
|
|
468
862
|
if (!aliveImpl(pid)) return true;
|
|
469
863
|
await sleepImpl(200);
|
|
470
864
|
}
|
|
865
|
+
ownershipCheck();
|
|
471
866
|
return !aliveImpl(pid);
|
|
472
867
|
}
|
|
473
868
|
|
|
@@ -598,8 +993,7 @@ function resolveNpxInvocation(args, env = process.env) {
|
|
|
598
993
|
};
|
|
599
994
|
}
|
|
600
995
|
|
|
601
|
-
function spawnReplacement(version, preference, env = process.env, restartArgs = [], outputPath = '') {
|
|
602
|
-
const packageArgs = ['-y', preference, `livedesk@${version}`];
|
|
996
|
+
export function spawnReplacement(version, preference, env = process.env, restartArgs = [], outputPath = '') {
|
|
603
997
|
const roleArgs = [
|
|
604
998
|
'--force-role',
|
|
605
999
|
'client',
|
|
@@ -609,7 +1003,22 @@ function spawnReplacement(version, preference, env = process.env, restartArgs =
|
|
|
609
1003
|
'--device-id',
|
|
610
1004
|
env.LIVEDESK_DEVICE_ID
|
|
611
1005
|
];
|
|
612
|
-
const
|
|
1006
|
+
const localRestoreLauncher = String(env.LIVEDESK_CLIENT_UPDATE_LOCAL_LAUNCHER || '').trim();
|
|
1007
|
+
const isLocalRestore = env.LIVEDESK_CLIENT_UPDATE_ATTEMPT_KIND === 'restore'
|
|
1008
|
+
&& localRestoreLauncher
|
|
1009
|
+
&& existsSync(localRestoreLauncher);
|
|
1010
|
+
const invocation = isLocalRestore
|
|
1011
|
+
? { command: process.execPath, args: [localRestoreLauncher, ...roleArgs] }
|
|
1012
|
+
: resolveNpxInvocation(['-y', preference, `livedesk@${version}`, ...roleArgs], env);
|
|
1013
|
+
const neutralCwd = isLocalRestore
|
|
1014
|
+
? ''
|
|
1015
|
+
: prepareNeutralCwd(env.LIVEDESK_UPDATE_NEUTRAL_CWD);
|
|
1016
|
+
const replacementEnv = isLocalRestore
|
|
1017
|
+
? { ...env }
|
|
1018
|
+
: buildLegacyClientUpdateNpxEnvironment(env, neutralCwd);
|
|
1019
|
+
const replacementCwd = isLocalRestore
|
|
1020
|
+
? resolve(String(env.LIVEDESK_UPDATE_ORIGINAL_CWD || process.cwd()))
|
|
1021
|
+
: neutralCwd;
|
|
613
1022
|
let outputFd = null;
|
|
614
1023
|
if (outputPath) {
|
|
615
1024
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
@@ -618,7 +1027,8 @@ function spawnReplacement(version, preference, env = process.env, restartArgs =
|
|
|
618
1027
|
let child;
|
|
619
1028
|
try {
|
|
620
1029
|
child = spawn(invocation.command, invocation.args, {
|
|
621
|
-
env,
|
|
1030
|
+
env: replacementEnv,
|
|
1031
|
+
cwd: replacementCwd,
|
|
622
1032
|
detached: true,
|
|
623
1033
|
stdio: outputFd === null ? 'ignore' : ['ignore', outputFd, outputFd],
|
|
624
1034
|
windowsHide: true
|
|
@@ -633,13 +1043,8 @@ function spawnReplacement(version, preference, env = process.env, restartArgs =
|
|
|
633
1043
|
}
|
|
634
1044
|
|
|
635
1045
|
function hasLegacyConnectedOutput(outputPath, deviceId) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
try {
|
|
639
|
-
output = readFileSync(outputPath, 'utf8').slice(-256 * 1024);
|
|
640
|
-
} catch {
|
|
641
|
-
return false;
|
|
642
|
-
}
|
|
1046
|
+
const output = readReplacementOutput(outputPath);
|
|
1047
|
+
if (!output) return false;
|
|
643
1048
|
const escapedDeviceId = String(deviceId || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
644
1049
|
if (!escapedDeviceId) return false;
|
|
645
1050
|
return new RegExp(
|
|
@@ -647,6 +1052,90 @@ function hasLegacyConnectedOutput(outputPath, deviceId) {
|
|
|
647
1052
|
).test(output);
|
|
648
1053
|
}
|
|
649
1054
|
|
|
1055
|
+
function readReplacementOutput(outputPath) {
|
|
1056
|
+
if (!outputPath || !existsSync(outputPath)) return '';
|
|
1057
|
+
try {
|
|
1058
|
+
return readFileSync(outputPath, 'utf8').slice(-256 * 1024);
|
|
1059
|
+
} catch {
|
|
1060
|
+
return '';
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function listWindowsProcessTree() {
|
|
1065
|
+
const script = [
|
|
1066
|
+
'$ErrorActionPreference = "Stop"',
|
|
1067
|
+
'[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)',
|
|
1068
|
+
'Get-CimInstance Win32_Process',
|
|
1069
|
+
'| Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine',
|
|
1070
|
+
'| ConvertTo-Json -Compress'
|
|
1071
|
+
].join(' ');
|
|
1072
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
1073
|
+
const result = spawnSync(
|
|
1074
|
+
'powershell.exe',
|
|
1075
|
+
['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
|
1076
|
+
{
|
|
1077
|
+
encoding: 'utf8',
|
|
1078
|
+
timeout: PROCESS_ARGUMENT_QUERY_TIMEOUT_MS,
|
|
1079
|
+
windowsHide: true
|
|
1080
|
+
}
|
|
1081
|
+
);
|
|
1082
|
+
if (result.error || result.status !== 0) return [];
|
|
1083
|
+
try {
|
|
1084
|
+
const parsed = JSON.parse(String(result.stdout || '').trim() || '[]');
|
|
1085
|
+
return (Array.isArray(parsed) ? parsed : [parsed]).map(item => ({
|
|
1086
|
+
pid: positivePid(item?.ProcessId),
|
|
1087
|
+
parentPid: positivePid(item?.ParentProcessId),
|
|
1088
|
+
command: `${item?.Name || ''} ${item?.ExecutablePath || ''} ${item?.CommandLine || ''}`
|
|
1089
|
+
}));
|
|
1090
|
+
} catch {
|
|
1091
|
+
return [];
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function listPosixProcessTree() {
|
|
1096
|
+
const result = spawnSync('ps', ['-axo', 'pid=,ppid=,comm=,command='], {
|
|
1097
|
+
encoding: 'utf8',
|
|
1098
|
+
timeout: PROCESS_ARGUMENT_QUERY_TIMEOUT_MS
|
|
1099
|
+
});
|
|
1100
|
+
if (result.error || result.status !== 0) return [];
|
|
1101
|
+
return String(result.stdout || '')
|
|
1102
|
+
.split(/\r?\n/)
|
|
1103
|
+
.map(line => line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/))
|
|
1104
|
+
.filter(Boolean)
|
|
1105
|
+
.map(match => ({
|
|
1106
|
+
pid: positivePid(match[1]),
|
|
1107
|
+
parentPid: positivePid(match[2]),
|
|
1108
|
+
command: `${match[3] || ''} ${match[4] || ''}`
|
|
1109
|
+
}));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
export function findLegacyRestoreAgentProcess(
|
|
1113
|
+
launcherPid,
|
|
1114
|
+
platform = process.platform,
|
|
1115
|
+
listProcesses = platform === 'win32' ? listWindowsProcessTree : listPosixProcessTree
|
|
1116
|
+
) {
|
|
1117
|
+
const rootPid = positivePid(launcherPid);
|
|
1118
|
+
if (!rootPid) return null;
|
|
1119
|
+
const processes = listProcesses().filter(item => item.pid > 1);
|
|
1120
|
+
const byParent = new Map();
|
|
1121
|
+
for (const item of processes) {
|
|
1122
|
+
const children = byParent.get(item.parentPid) || [];
|
|
1123
|
+
children.push(item);
|
|
1124
|
+
byParent.set(item.parentPid, children);
|
|
1125
|
+
}
|
|
1126
|
+
const queue = [...(byParent.get(rootPid) || [])];
|
|
1127
|
+
const descendants = [];
|
|
1128
|
+
while (queue.length > 0) {
|
|
1129
|
+
const next = queue.shift();
|
|
1130
|
+
descendants.push(next);
|
|
1131
|
+
queue.push(...(byParent.get(next.pid) || []));
|
|
1132
|
+
}
|
|
1133
|
+
return descendants.find(item => (
|
|
1134
|
+
/livedesk-client-fast|livedesk-client-node|MindExec\.RemoteAgent\.Fast|RemoteAgent\.Fast/i
|
|
1135
|
+
.test(String(item.command || ''))
|
|
1136
|
+
)) || null;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
650
1139
|
async function terminateReplacementTree(child) {
|
|
651
1140
|
const pid = positivePid(child?.pid);
|
|
652
1141
|
if (pid <= 1) return;
|
|
@@ -656,9 +1145,23 @@ async function terminateReplacementTree(child) {
|
|
|
656
1145
|
windowsHide: true,
|
|
657
1146
|
stdio: 'ignore'
|
|
658
1147
|
});
|
|
659
|
-
|
|
660
|
-
|
|
1148
|
+
let settled = false;
|
|
1149
|
+
const finish = () => {
|
|
1150
|
+
if (settled) return;
|
|
1151
|
+
settled = true;
|
|
1152
|
+
clearTimeout(timer);
|
|
1153
|
+
resolve();
|
|
1154
|
+
};
|
|
1155
|
+
const timer = setTimeout(() => {
|
|
1156
|
+
try { killer.kill('SIGKILL'); } catch { /* taskkill already stopped */ }
|
|
1157
|
+
finish();
|
|
1158
|
+
}, 10_000);
|
|
1159
|
+
killer.once('error', finish);
|
|
1160
|
+
killer.once('exit', finish);
|
|
661
1161
|
});
|
|
1162
|
+
if (isAlive(pid)) {
|
|
1163
|
+
try { child.kill('SIGKILL'); } catch { /* best effort after bounded taskkill */ }
|
|
1164
|
+
}
|
|
662
1165
|
return;
|
|
663
1166
|
}
|
|
664
1167
|
try {
|
|
@@ -672,10 +1175,23 @@ async function terminateReplacementTree(child) {
|
|
|
672
1175
|
}
|
|
673
1176
|
}
|
|
674
1177
|
|
|
675
|
-
async function terminateOldRuntime(
|
|
1178
|
+
async function terminateOldRuntime(
|
|
1179
|
+
config,
|
|
1180
|
+
{
|
|
1181
|
+
sleepImpl = sleep,
|
|
1182
|
+
aliveImpl = isAlive,
|
|
1183
|
+
ownershipCheck = () => undefined,
|
|
1184
|
+
deadlineCheck = () => undefined
|
|
1185
|
+
} = {}
|
|
1186
|
+
) {
|
|
1187
|
+
ownershipCheck();
|
|
676
1188
|
if (config.launcherPid <= 1) throw new Error('legacy update has no valid launcher PID');
|
|
677
1189
|
const victims = [...new Set([config.launcherPid, config.agentPid].filter(pid => pid > 1 && pid !== process.pid))];
|
|
1190
|
+
// This is the last side-effect-free boundary. Once the first signal is sent,
|
|
1191
|
+
// expiry recovery can no longer promise that the unchanged Client survives.
|
|
1192
|
+
deadlineCheck();
|
|
678
1193
|
for (const pid of victims) {
|
|
1194
|
+
ownershipCheck();
|
|
679
1195
|
try {
|
|
680
1196
|
process.kill(pid, 'SIGTERM');
|
|
681
1197
|
} catch {
|
|
@@ -683,6 +1199,7 @@ async function terminateOldRuntime(config, { sleepImpl = sleep, aliveImpl = isAl
|
|
|
683
1199
|
}
|
|
684
1200
|
}
|
|
685
1201
|
await sleepImpl(2500);
|
|
1202
|
+
ownershipCheck();
|
|
686
1203
|
for (const pid of victims) {
|
|
687
1204
|
if (!aliveImpl(pid)) continue;
|
|
688
1205
|
try {
|
|
@@ -691,8 +1208,20 @@ async function terminateOldRuntime(config, { sleepImpl = sleep, aliveImpl = isAl
|
|
|
691
1208
|
// Already stopped.
|
|
692
1209
|
}
|
|
693
1210
|
}
|
|
694
|
-
const launcherExited = await waitForExit(
|
|
695
|
-
|
|
1211
|
+
const launcherExited = await waitForExit(
|
|
1212
|
+
config.launcherPid,
|
|
1213
|
+
15_000,
|
|
1214
|
+
sleepImpl,
|
|
1215
|
+
aliveImpl,
|
|
1216
|
+
ownershipCheck
|
|
1217
|
+
);
|
|
1218
|
+
const agentExited = await waitForExit(
|
|
1219
|
+
config.agentPid,
|
|
1220
|
+
15_000,
|
|
1221
|
+
sleepImpl,
|
|
1222
|
+
aliveImpl,
|
|
1223
|
+
ownershipCheck
|
|
1224
|
+
);
|
|
696
1225
|
if (!launcherExited || !agentExited) {
|
|
697
1226
|
throw new Error(`old runtime did not exit launcher=${launcherExited} agent=${agentExited}`);
|
|
698
1227
|
}
|
|
@@ -713,6 +1242,8 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
713
1242
|
const aliveImpl = options.aliveImpl || isAlive;
|
|
714
1243
|
const now = options.now || Date.now;
|
|
715
1244
|
const log = options.log || (() => undefined);
|
|
1245
|
+
const ownershipCheck = options.assertOperationOwnership
|
|
1246
|
+
|| (() => assertLegacyClientUpdateOperationOwnership(config));
|
|
716
1247
|
const attemptId = String(
|
|
717
1248
|
options.attemptId
|
|
718
1249
|
|| `${config.operationId}-${kind}-${attempt}-${randomBytes(5).toString('hex')}`
|
|
@@ -744,10 +1275,16 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
744
1275
|
LIVEDESK_CLIENT_UPDATE_TARGET_AGENT_VERSION: agentVersion,
|
|
745
1276
|
LIVEDESK_CLIENT_UPDATE_TARGET_VERSION: agentVersion,
|
|
746
1277
|
LIVEDESK_CLIENT_UPDATE_STATE_PATH: proofConfig.statePath,
|
|
1278
|
+
LIVEDESK_UPDATE_NEUTRAL_CWD: config.neutralCwd,
|
|
1279
|
+
LIVEDESK_UPDATE_ORIGINAL_CWD: config.originalCwd,
|
|
1280
|
+
LIVEDESK_CLIENT_UPDATE_LOCAL_LAUNCHER: kind === 'restore'
|
|
1281
|
+
? String(config.localRestoreLauncher?.entryPath || '')
|
|
1282
|
+
: '',
|
|
747
1283
|
LIVEDESK_CLIENT_RUNTIME_PORT: String(config.runtimePort),
|
|
748
1284
|
LIVEDESK_DEVICE_ID: config.deviceId,
|
|
749
1285
|
LIVEDESK_SKIP_BROWSER_OPEN: '1'
|
|
750
1286
|
};
|
|
1287
|
+
ownershipCheck();
|
|
751
1288
|
writeState(config, `${kind}-launching`, {
|
|
752
1289
|
attemptId,
|
|
753
1290
|
attemptKind: kind,
|
|
@@ -758,6 +1295,7 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
758
1295
|
restartVerified: false,
|
|
759
1296
|
error: ''
|
|
760
1297
|
});
|
|
1298
|
+
ownershipCheck();
|
|
761
1299
|
const child = await (options.spawnReplacement || spawnReplacement)(
|
|
762
1300
|
version,
|
|
763
1301
|
preference,
|
|
@@ -776,12 +1314,51 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
776
1314
|
let verifiedSince = null;
|
|
777
1315
|
let lastVerified = null;
|
|
778
1316
|
let terminalProofFailure = false;
|
|
1317
|
+
let legacyRestoreAgent = null;
|
|
779
1318
|
while (now() < deadline) {
|
|
780
1319
|
try {
|
|
1320
|
+
ownershipCheck();
|
|
781
1321
|
const state = readProofState();
|
|
782
1322
|
let proof;
|
|
783
1323
|
let proofSource = 'agent-state';
|
|
784
|
-
|
|
1324
|
+
let verified;
|
|
1325
|
+
const localRestoreArtifact = kind === 'restore'
|
|
1326
|
+
? config.localRestoreLauncher
|
|
1327
|
+
: null;
|
|
1328
|
+
const restoreWelcomeObserved = !!localRestoreArtifact
|
|
1329
|
+
&& (options.hasLegacyConnectedOutput || hasLegacyConnectedOutput)(
|
|
1330
|
+
outputPath,
|
|
1331
|
+
config.deviceId
|
|
1332
|
+
);
|
|
1333
|
+
const restoreArtifactMatches = cleanVersion(localRestoreArtifact?.productVersion) === cleanVersion(version)
|
|
1334
|
+
&& cleanVersion(localRestoreArtifact?.agentVersion) === cleanVersion(agentVersion);
|
|
1335
|
+
if (restoreWelcomeObserved && restoreArtifactMatches) {
|
|
1336
|
+
if (!legacyRestoreAgent || !aliveImpl(positivePid(legacyRestoreAgent.pid))) {
|
|
1337
|
+
legacyRestoreAgent = await (
|
|
1338
|
+
options.findLegacyRestoreAgentProcess
|
|
1339
|
+
|| findLegacyRestoreAgentProcess
|
|
1340
|
+
)(pid);
|
|
1341
|
+
}
|
|
1342
|
+
const restoreAgentPid = positivePid(legacyRestoreAgent?.pid);
|
|
1343
|
+
if (pid <= 1 || !aliveImpl(pid)) {
|
|
1344
|
+
throw new Error('exact local restore launcher exited during verification');
|
|
1345
|
+
}
|
|
1346
|
+
if (restoreAgentPid <= 1 || !aliveImpl(restoreAgentPid)) {
|
|
1347
|
+
throw new Error('exact local restore Agent descendant is not running');
|
|
1348
|
+
}
|
|
1349
|
+
proof = {
|
|
1350
|
+
launcherPid: pid,
|
|
1351
|
+
agentPid: restoreAgentPid,
|
|
1352
|
+
productVersion: version,
|
|
1353
|
+
agentVersion
|
|
1354
|
+
};
|
|
1355
|
+
proofSource = 'legacy-hub-welcome-process-tree';
|
|
1356
|
+
verified = {
|
|
1357
|
+
runtimePid: pid,
|
|
1358
|
+
agentPid: restoreAgentPid,
|
|
1359
|
+
appVersion: version
|
|
1360
|
+
};
|
|
1361
|
+
} else {
|
|
785
1362
|
proof = validateLegacyClientConnectedProof(state, {
|
|
786
1363
|
operationId: config.operationId,
|
|
787
1364
|
attemptId,
|
|
@@ -789,22 +1366,13 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
789
1366
|
agentVersion,
|
|
790
1367
|
previousRuntimePid: config.launcherPid
|
|
791
1368
|
});
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
if (!restoreWelcomeObserved) throw proofError;
|
|
799
|
-
proof = null;
|
|
800
|
-
proofSource = 'legacy-welcome-output';
|
|
1369
|
+
const status = await requestStatus(config.runtimePort);
|
|
1370
|
+
verified = validateLegacyClientReplacementStatus(status, {
|
|
1371
|
+
deviceId: config.deviceId,
|
|
1372
|
+
productVersion: version,
|
|
1373
|
+
previousRuntimePid: config.launcherPid
|
|
1374
|
+
});
|
|
801
1375
|
}
|
|
802
|
-
const status = await requestStatus(config.runtimePort);
|
|
803
|
-
const verified = validateLegacyClientReplacementStatus(status, {
|
|
804
|
-
deviceId: config.deviceId,
|
|
805
|
-
productVersion: version,
|
|
806
|
-
previousRuntimePid: config.launcherPid
|
|
807
|
-
});
|
|
808
1376
|
if (!proof) {
|
|
809
1377
|
proof = {
|
|
810
1378
|
launcherPid: verified.runtimePid,
|
|
@@ -826,6 +1394,7 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
826
1394
|
lastVerified = { ...verified, proof, proofSource };
|
|
827
1395
|
if (verifiedSince === null) verifiedSince = now();
|
|
828
1396
|
if (now() - verifiedSince >= (options.verifyStableMs ?? VERIFY_STABLE_MS)) {
|
|
1397
|
+
ownershipCheck();
|
|
829
1398
|
log(
|
|
830
1399
|
`${kind} attempt=${attempt} id=${attemptId} verified `
|
|
831
1400
|
+ `launcher=${verified.runtimePid} agent=${verified.agentPid} `
|
|
@@ -853,6 +1422,11 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
853
1422
|
return { ...verified, agentVersion: proof.agentVersion, attemptId };
|
|
854
1423
|
}
|
|
855
1424
|
} catch (error) {
|
|
1425
|
+
if (isLegacyClientUpdateSupersededError(error)) {
|
|
1426
|
+
await (options.terminateReplacementTree || terminateReplacementTree)(child);
|
|
1427
|
+
if (!options.readUpdateState) rmSync(proofConfig.statePath, { force: true });
|
|
1428
|
+
throw error;
|
|
1429
|
+
}
|
|
856
1430
|
lastError = error;
|
|
857
1431
|
const state = readProofState();
|
|
858
1432
|
terminalProofFailure = state?.stage === 'connected'
|
|
@@ -871,6 +1445,7 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
871
1445
|
`${kind} attempt=${attempt} did not become healthy: `
|
|
872
1446
|
+ `${lastError?.message || (lastVerified ? 'runtime did not remain stable' : 'verification deadline expired')}`
|
|
873
1447
|
);
|
|
1448
|
+
ownershipCheck();
|
|
874
1449
|
writeState(config, `${kind}-attempt-failed`, {
|
|
875
1450
|
attemptId,
|
|
876
1451
|
attemptKind: kind,
|
|
@@ -884,17 +1459,50 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
884
1459
|
}
|
|
885
1460
|
|
|
886
1461
|
export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
887
|
-
const env = options.env || process.env;
|
|
1462
|
+
const env = expandLegacyBridgeEnvironment(options.env || process.env);
|
|
888
1463
|
const config = readConfig(env);
|
|
889
1464
|
const log = createLogger(config, options.writeLog);
|
|
890
|
-
const
|
|
1465
|
+
const now = options.now || Date.now;
|
|
1466
|
+
const persistState = options.writeUpdateState
|
|
1467
|
+
|| ((value, stage, details) => writeUpdateState(value, stage, details, { now }));
|
|
1468
|
+
const ownershipCheck = options.assertOperationOwnership
|
|
1469
|
+
|| (() => assertLegacyClientUpdateOperationOwnership(config));
|
|
1470
|
+
const claimOperation = options.claimOperation
|
|
1471
|
+
|| (() => claimLegacyClientUpdateOperation(config, options.aliveImpl || isAlive, now));
|
|
891
1472
|
const productManifest = options.productManifest || readManifest('../package.json');
|
|
892
1473
|
const clientManifest = options.clientManifest || readManifest('../client/package.json');
|
|
893
1474
|
const sleepImpl = options.sleepImpl || sleep;
|
|
894
1475
|
const terminateOld = options.terminateOldRuntime || (value => terminateOldRuntime(value, {
|
|
895
1476
|
sleepImpl,
|
|
896
|
-
aliveImpl: options.aliveImpl || isAlive
|
|
1477
|
+
aliveImpl: options.aliveImpl || isAlive,
|
|
1478
|
+
ownershipCheck,
|
|
1479
|
+
deadlineCheck: () => ensureDeadline('old-runtime termination')
|
|
897
1480
|
}));
|
|
1481
|
+
const cancelExpiredDeadline = (error, checkpoint) => {
|
|
1482
|
+
try {
|
|
1483
|
+
persistState(config, 'failed', {
|
|
1484
|
+
restartVerified: false,
|
|
1485
|
+
cancelRequested: true,
|
|
1486
|
+
deadlineExpired: true,
|
|
1487
|
+
deadlineCheckpoint: checkpoint,
|
|
1488
|
+
deadlineExpiredAt: new Date(now()).toISOString(),
|
|
1489
|
+
failedAt: new Date(now()).toISOString(),
|
|
1490
|
+
error: String(error?.message || error).slice(0, 4000)
|
|
1491
|
+
});
|
|
1492
|
+
} catch {
|
|
1493
|
+
// A concurrent cancellation/supersession already makes the operation inert.
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
const ensureDeadline = checkpoint => {
|
|
1497
|
+
try {
|
|
1498
|
+
return assertLegacyClientUpdateDeadline(config, now, checkpoint);
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
if (isLegacyClientUpdateDeadlineExpiredError(error)) {
|
|
1501
|
+
cancelExpiredDeadline(error, checkpoint);
|
|
1502
|
+
}
|
|
1503
|
+
throw error;
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
898
1506
|
const attemptReplacement = options.attemptReplacement || ((
|
|
899
1507
|
version,
|
|
900
1508
|
kind,
|
|
@@ -913,7 +1521,8 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
913
1521
|
...options,
|
|
914
1522
|
env,
|
|
915
1523
|
log,
|
|
916
|
-
sleepImpl
|
|
1524
|
+
sleepImpl,
|
|
1525
|
+
assertOperationOwnership: ownershipCheck
|
|
917
1526
|
}
|
|
918
1527
|
)
|
|
919
1528
|
));
|
|
@@ -929,6 +1538,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
929
1538
|
|| config.agentPid <= 1) {
|
|
930
1539
|
throw new Error('legacy update configuration is incomplete');
|
|
931
1540
|
}
|
|
1541
|
+
ensureDeadline('configuration validation');
|
|
932
1542
|
if (cleanVersion(productManifest.version) !== config.targetProductVersion) {
|
|
933
1543
|
throw new Error(`prepared product mismatch expected=${config.targetProductVersion} actual=${productManifest.version || 'unknown'}`);
|
|
934
1544
|
}
|
|
@@ -936,6 +1546,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
936
1546
|
|| cleanVersion(clientManifest.version) !== config.targetVersion) {
|
|
937
1547
|
throw new Error(`prepared Agent mismatch expected=${config.targetVersion} bundled=${productManifest.livedeskClientVersion || 'unknown'} client=${clientManifest.version || 'unknown'}`);
|
|
938
1548
|
}
|
|
1549
|
+
config.neutralCwd = prepareNeutralCwd(config.neutralCwd);
|
|
939
1550
|
const frozen = (options.captureRestartConfiguration || captureRestartConfiguration)(
|
|
940
1551
|
config,
|
|
941
1552
|
env,
|
|
@@ -943,21 +1554,49 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
943
1554
|
);
|
|
944
1555
|
config.restartArgs = [...frozen.restartArgs];
|
|
945
1556
|
config.runtimePort = frozen.runtimePort;
|
|
1557
|
+
config.localRestoreLauncher = (
|
|
1558
|
+
options.resolveExactLocalRestoreLauncher
|
|
1559
|
+
|| resolveExactLocalRestoreLauncher
|
|
1560
|
+
)(config, env, frozen);
|
|
946
1561
|
log(`options-frozen count=${config.restartArgs.length} runtimePort=${config.runtimePort} engine=${frozen.engine}`);
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1562
|
+
log(
|
|
1563
|
+
`restore-frozen launcher=${config.localRestoreLauncher.entryPath} `
|
|
1564
|
+
+ `version=${config.localRestoreLauncher.productVersion}/${config.localRestoreLauncher.agentVersion}`
|
|
1565
|
+
);
|
|
1566
|
+
ensureDeadline('operation claim');
|
|
1567
|
+
claimOperation();
|
|
1568
|
+
ownershipCheck();
|
|
1569
|
+
ensureDeadline('preflight-ready commit');
|
|
1570
|
+
try {
|
|
1571
|
+
persistState(config, 'preflight-ready', {
|
|
1572
|
+
runtimePort: config.runtimePort,
|
|
1573
|
+
engine: frozen.engine,
|
|
1574
|
+
restartArgCount: config.restartArgs.length,
|
|
1575
|
+
restartVerified: false,
|
|
1576
|
+
error: ''
|
|
1577
|
+
});
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
if (isLegacyClientUpdateDeadlineExpiredError(error)) {
|
|
1580
|
+
cancelExpiredDeadline(error, 'preflight-ready commit');
|
|
1581
|
+
}
|
|
1582
|
+
throw error;
|
|
1583
|
+
}
|
|
954
1584
|
log(`preflight-ready target=${config.targetProductVersion}/${config.targetVersion}`);
|
|
955
|
-
await waitForExit(
|
|
1585
|
+
await waitForExit(
|
|
1586
|
+
config.starterPid,
|
|
1587
|
+
10_000,
|
|
1588
|
+
sleepImpl,
|
|
1589
|
+
options.aliveImpl || isAlive,
|
|
1590
|
+
ownershipCheck
|
|
1591
|
+
);
|
|
1592
|
+
ownershipCheck();
|
|
1593
|
+
ensureDeadline('old-runtime termination');
|
|
956
1594
|
log(`stopping launcher=${config.launcherPid} agent=${config.agentPid}`);
|
|
957
1595
|
await terminateOld(config);
|
|
958
1596
|
|
|
959
1597
|
const targetErrors = [];
|
|
960
1598
|
for (let attempt = 1; attempt <= TARGET_ATTEMPT_LIMIT; attempt += 1) {
|
|
1599
|
+
ownershipCheck();
|
|
961
1600
|
try {
|
|
962
1601
|
await attemptReplacement(
|
|
963
1602
|
config.targetProductVersion,
|
|
@@ -966,6 +1605,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
966
1605
|
'--prefer-online',
|
|
967
1606
|
config.targetVersion
|
|
968
1607
|
);
|
|
1608
|
+
ownershipCheck();
|
|
969
1609
|
log(`completed target=${config.targetProductVersion}/${config.targetVersion}`);
|
|
970
1610
|
persistState(config, 'connected', {
|
|
971
1611
|
restartVerified: true,
|
|
@@ -974,6 +1614,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
974
1614
|
});
|
|
975
1615
|
return { outcome: 'updated', attempt };
|
|
976
1616
|
} catch (error) {
|
|
1617
|
+
if (isLegacyClientUpdateSupersededError(error)) throw error;
|
|
977
1618
|
targetErrors.push(String(error?.message || error));
|
|
978
1619
|
log(`target attempt=${attempt} failed error=${error?.message || error}`);
|
|
979
1620
|
}
|
|
@@ -984,6 +1625,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
984
1625
|
}
|
|
985
1626
|
const restoreErrors = [];
|
|
986
1627
|
for (let attempt = 1; attempt <= RESTORE_ATTEMPT_LIMIT; attempt += 1) {
|
|
1628
|
+
ownershipCheck();
|
|
987
1629
|
try {
|
|
988
1630
|
await attemptReplacement(
|
|
989
1631
|
config.currentProductVersion,
|
|
@@ -992,6 +1634,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
992
1634
|
'--prefer-offline',
|
|
993
1635
|
config.currentAgentVersion
|
|
994
1636
|
);
|
|
1637
|
+
ownershipCheck();
|
|
995
1638
|
log(`restored version=${config.currentProductVersion}`);
|
|
996
1639
|
persistState(config, 'restored', {
|
|
997
1640
|
productVersion: config.currentProductVersion,
|
|
@@ -1007,6 +1650,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
1007
1650
|
error: `Target ${config.targetProductVersion} failed verification: ${targetErrors.join(' | ')}`
|
|
1008
1651
|
};
|
|
1009
1652
|
} catch (error) {
|
|
1653
|
+
if (isLegacyClientUpdateSupersededError(error)) throw error;
|
|
1010
1654
|
restoreErrors.push(String(error?.message || error));
|
|
1011
1655
|
log(`restore attempt=${attempt} failed error=${error?.message || error}`);
|
|
1012
1656
|
}
|
|
@@ -1025,14 +1669,16 @@ export async function runLegacyClientUpdateSupervisorCli() {
|
|
|
1025
1669
|
const config = readConfig();
|
|
1026
1670
|
const log = createLogger(config);
|
|
1027
1671
|
log(`failed error=${error?.stack || error}`);
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1672
|
+
if (!isLegacyClientUpdateSupersededError(error)) {
|
|
1673
|
+
try {
|
|
1674
|
+
writeUpdateState(config, 'failed', {
|
|
1675
|
+
restartVerified: false,
|
|
1676
|
+
failedAt: new Date().toISOString(),
|
|
1677
|
+
error: String(error?.message || error).slice(0, 4000)
|
|
1678
|
+
});
|
|
1679
|
+
} catch {
|
|
1680
|
+
// The persistent log remains the fallback on a read-only state directory.
|
|
1681
|
+
}
|
|
1036
1682
|
}
|
|
1037
1683
|
console.error(error?.message || error);
|
|
1038
1684
|
process.exitCode = 1;
|