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
|
@@ -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,10 +191,51 @@ 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),
|
|
@@ -97,13 +246,12 @@ function readConfig(env = process.env) {
|
|
|
97
246
|
launcherPid: positivePid(env.LIVEDESK_CLIENT_PARENT_PID),
|
|
98
247
|
agentPid: positivePid(env.LIVEDESK_CLIENT_UPDATE_AGENT_PID),
|
|
99
248
|
starterPid: positivePid(env.LIVEDESK_UPDATE_STARTER_PID),
|
|
249
|
+
updateDeadlineEpochMs: Number(env.LIVEDESK_CLIENT_UPDATE_DEADLINE_EPOCH_MS),
|
|
100
250
|
runtimePort: normalizePort(env.LIVEDESK_CLIENT_RUNTIME_PORT || env.LIVEDESK_LOCAL_RUNTIME_PORT),
|
|
101
251
|
stateDir,
|
|
102
|
-
statePath:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|| join(stateDir, 'client-update-legacy.json')
|
|
106
|
-
),
|
|
252
|
+
statePath: resolve(originalCwd, configuredStatePath),
|
|
253
|
+
neutralCwd: getLegacyClientUpdateNeutralCwd(stateDir, operationId),
|
|
254
|
+
originalCwd,
|
|
107
255
|
restartArgs: []
|
|
108
256
|
};
|
|
109
257
|
}
|
|
@@ -393,7 +541,11 @@ export function buildLegacyClientRestartConfiguration({
|
|
|
393
541
|
function captureRestartConfiguration(config, env, readArguments = readLegacyProcessArguments) {
|
|
394
542
|
const preservedArgs = decodePreservedArgs(env);
|
|
395
543
|
if (preservedArgs) {
|
|
396
|
-
return
|
|
544
|
+
return {
|
|
545
|
+
...buildLegacyClientRestartConfiguration({ env, preservedArgs }),
|
|
546
|
+
launcherArgs: [],
|
|
547
|
+
agentArgs: []
|
|
548
|
+
};
|
|
397
549
|
}
|
|
398
550
|
let launcherArgs;
|
|
399
551
|
let agentArgs;
|
|
@@ -406,7 +558,63 @@ function captureRestartConfiguration(config, env, readArguments = readLegacyProc
|
|
|
406
558
|
+ `the existing Client was left running. ${error?.message || error}`
|
|
407
559
|
);
|
|
408
560
|
}
|
|
409
|
-
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
|
+
);
|
|
410
618
|
}
|
|
411
619
|
|
|
412
620
|
function readUpdateState(config) {
|
|
@@ -417,6 +625,176 @@ function readUpdateState(config) {
|
|
|
417
625
|
}
|
|
418
626
|
}
|
|
419
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
|
+
|
|
420
798
|
function proofStatePath(config, attemptId) {
|
|
421
799
|
const safeAttemptId = String(attemptId || '')
|
|
422
800
|
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
@@ -424,29 +802,36 @@ function proofStatePath(config, attemptId) {
|
|
|
424
802
|
return join(dirname(config.statePath), `client-update-proof-${safeAttemptId}.json`);
|
|
425
803
|
}
|
|
426
804
|
|
|
427
|
-
function writeUpdateState(config, stage, details = {}) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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 });
|
|
450
835
|
}
|
|
451
836
|
|
|
452
837
|
function isAlive(pid) {
|
|
@@ -463,13 +848,21 @@ function sleep(milliseconds) {
|
|
|
463
848
|
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
464
849
|
}
|
|
465
850
|
|
|
466
|
-
async function waitForExit(
|
|
851
|
+
async function waitForExit(
|
|
852
|
+
pid,
|
|
853
|
+
timeoutMs,
|
|
854
|
+
sleepImpl = sleep,
|
|
855
|
+
aliveImpl = isAlive,
|
|
856
|
+
ownershipCheck = () => undefined
|
|
857
|
+
) {
|
|
467
858
|
if (pid <= 1) return true;
|
|
468
859
|
const deadline = Date.now() + timeoutMs;
|
|
469
860
|
while (Date.now() < deadline) {
|
|
861
|
+
ownershipCheck();
|
|
470
862
|
if (!aliveImpl(pid)) return true;
|
|
471
863
|
await sleepImpl(200);
|
|
472
864
|
}
|
|
865
|
+
ownershipCheck();
|
|
473
866
|
return !aliveImpl(pid);
|
|
474
867
|
}
|
|
475
868
|
|
|
@@ -600,8 +993,7 @@ function resolveNpxInvocation(args, env = process.env) {
|
|
|
600
993
|
};
|
|
601
994
|
}
|
|
602
995
|
|
|
603
|
-
function spawnReplacement(version, preference, env = process.env, restartArgs = [], outputPath = '') {
|
|
604
|
-
const packageArgs = ['-y', preference, `livedesk@${version}`];
|
|
996
|
+
export function spawnReplacement(version, preference, env = process.env, restartArgs = [], outputPath = '') {
|
|
605
997
|
const roleArgs = [
|
|
606
998
|
'--force-role',
|
|
607
999
|
'client',
|
|
@@ -611,7 +1003,22 @@ function spawnReplacement(version, preference, env = process.env, restartArgs =
|
|
|
611
1003
|
'--device-id',
|
|
612
1004
|
env.LIVEDESK_DEVICE_ID
|
|
613
1005
|
];
|
|
614
|
-
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;
|
|
615
1022
|
let outputFd = null;
|
|
616
1023
|
if (outputPath) {
|
|
617
1024
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
@@ -620,7 +1027,8 @@ function spawnReplacement(version, preference, env = process.env, restartArgs =
|
|
|
620
1027
|
let child;
|
|
621
1028
|
try {
|
|
622
1029
|
child = spawn(invocation.command, invocation.args, {
|
|
623
|
-
env,
|
|
1030
|
+
env: replacementEnv,
|
|
1031
|
+
cwd: replacementCwd,
|
|
624
1032
|
detached: true,
|
|
625
1033
|
stdio: outputFd === null ? 'ignore' : ['ignore', outputFd, outputFd],
|
|
626
1034
|
windowsHide: true
|
|
@@ -635,13 +1043,8 @@ function spawnReplacement(version, preference, env = process.env, restartArgs =
|
|
|
635
1043
|
}
|
|
636
1044
|
|
|
637
1045
|
function hasLegacyConnectedOutput(outputPath, deviceId) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
try {
|
|
641
|
-
output = readFileSync(outputPath, 'utf8').slice(-256 * 1024);
|
|
642
|
-
} catch {
|
|
643
|
-
return false;
|
|
644
|
-
}
|
|
1046
|
+
const output = readReplacementOutput(outputPath);
|
|
1047
|
+
if (!output) return false;
|
|
645
1048
|
const escapedDeviceId = String(deviceId || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
646
1049
|
if (!escapedDeviceId) return false;
|
|
647
1050
|
return new RegExp(
|
|
@@ -649,6 +1052,90 @@ function hasLegacyConnectedOutput(outputPath, deviceId) {
|
|
|
649
1052
|
).test(output);
|
|
650
1053
|
}
|
|
651
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
|
+
|
|
652
1139
|
async function terminateReplacementTree(child) {
|
|
653
1140
|
const pid = positivePid(child?.pid);
|
|
654
1141
|
if (pid <= 1) return;
|
|
@@ -658,9 +1145,23 @@ async function terminateReplacementTree(child) {
|
|
|
658
1145
|
windowsHide: true,
|
|
659
1146
|
stdio: 'ignore'
|
|
660
1147
|
});
|
|
661
|
-
|
|
662
|
-
|
|
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);
|
|
663
1161
|
});
|
|
1162
|
+
if (isAlive(pid)) {
|
|
1163
|
+
try { child.kill('SIGKILL'); } catch { /* best effort after bounded taskkill */ }
|
|
1164
|
+
}
|
|
664
1165
|
return;
|
|
665
1166
|
}
|
|
666
1167
|
try {
|
|
@@ -674,10 +1175,23 @@ async function terminateReplacementTree(child) {
|
|
|
674
1175
|
}
|
|
675
1176
|
}
|
|
676
1177
|
|
|
677
|
-
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();
|
|
678
1188
|
if (config.launcherPid <= 1) throw new Error('legacy update has no valid launcher PID');
|
|
679
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();
|
|
680
1193
|
for (const pid of victims) {
|
|
1194
|
+
ownershipCheck();
|
|
681
1195
|
try {
|
|
682
1196
|
process.kill(pid, 'SIGTERM');
|
|
683
1197
|
} catch {
|
|
@@ -685,6 +1199,7 @@ async function terminateOldRuntime(config, { sleepImpl = sleep, aliveImpl = isAl
|
|
|
685
1199
|
}
|
|
686
1200
|
}
|
|
687
1201
|
await sleepImpl(2500);
|
|
1202
|
+
ownershipCheck();
|
|
688
1203
|
for (const pid of victims) {
|
|
689
1204
|
if (!aliveImpl(pid)) continue;
|
|
690
1205
|
try {
|
|
@@ -693,8 +1208,20 @@ async function terminateOldRuntime(config, { sleepImpl = sleep, aliveImpl = isAl
|
|
|
693
1208
|
// Already stopped.
|
|
694
1209
|
}
|
|
695
1210
|
}
|
|
696
|
-
const launcherExited = await waitForExit(
|
|
697
|
-
|
|
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
|
+
);
|
|
698
1225
|
if (!launcherExited || !agentExited) {
|
|
699
1226
|
throw new Error(`old runtime did not exit launcher=${launcherExited} agent=${agentExited}`);
|
|
700
1227
|
}
|
|
@@ -715,6 +1242,8 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
715
1242
|
const aliveImpl = options.aliveImpl || isAlive;
|
|
716
1243
|
const now = options.now || Date.now;
|
|
717
1244
|
const log = options.log || (() => undefined);
|
|
1245
|
+
const ownershipCheck = options.assertOperationOwnership
|
|
1246
|
+
|| (() => assertLegacyClientUpdateOperationOwnership(config));
|
|
718
1247
|
const attemptId = String(
|
|
719
1248
|
options.attemptId
|
|
720
1249
|
|| `${config.operationId}-${kind}-${attempt}-${randomBytes(5).toString('hex')}`
|
|
@@ -746,10 +1275,16 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
746
1275
|
LIVEDESK_CLIENT_UPDATE_TARGET_AGENT_VERSION: agentVersion,
|
|
747
1276
|
LIVEDESK_CLIENT_UPDATE_TARGET_VERSION: agentVersion,
|
|
748
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
|
+
: '',
|
|
749
1283
|
LIVEDESK_CLIENT_RUNTIME_PORT: String(config.runtimePort),
|
|
750
1284
|
LIVEDESK_DEVICE_ID: config.deviceId,
|
|
751
1285
|
LIVEDESK_SKIP_BROWSER_OPEN: '1'
|
|
752
1286
|
};
|
|
1287
|
+
ownershipCheck();
|
|
753
1288
|
writeState(config, `${kind}-launching`, {
|
|
754
1289
|
attemptId,
|
|
755
1290
|
attemptKind: kind,
|
|
@@ -760,6 +1295,7 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
760
1295
|
restartVerified: false,
|
|
761
1296
|
error: ''
|
|
762
1297
|
});
|
|
1298
|
+
ownershipCheck();
|
|
763
1299
|
const child = await (options.spawnReplacement || spawnReplacement)(
|
|
764
1300
|
version,
|
|
765
1301
|
preference,
|
|
@@ -778,12 +1314,51 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
778
1314
|
let verifiedSince = null;
|
|
779
1315
|
let lastVerified = null;
|
|
780
1316
|
let terminalProofFailure = false;
|
|
1317
|
+
let legacyRestoreAgent = null;
|
|
781
1318
|
while (now() < deadline) {
|
|
782
1319
|
try {
|
|
1320
|
+
ownershipCheck();
|
|
783
1321
|
const state = readProofState();
|
|
784
1322
|
let proof;
|
|
785
1323
|
let proofSource = 'agent-state';
|
|
786
|
-
|
|
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 {
|
|
787
1362
|
proof = validateLegacyClientConnectedProof(state, {
|
|
788
1363
|
operationId: config.operationId,
|
|
789
1364
|
attemptId,
|
|
@@ -791,22 +1366,13 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
791
1366
|
agentVersion,
|
|
792
1367
|
previousRuntimePid: config.launcherPid
|
|
793
1368
|
});
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
if (!restoreWelcomeObserved) throw proofError;
|
|
801
|
-
proof = null;
|
|
802
|
-
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
|
+
});
|
|
803
1375
|
}
|
|
804
|
-
const status = await requestStatus(config.runtimePort);
|
|
805
|
-
const verified = validateLegacyClientReplacementStatus(status, {
|
|
806
|
-
deviceId: config.deviceId,
|
|
807
|
-
productVersion: version,
|
|
808
|
-
previousRuntimePid: config.launcherPid
|
|
809
|
-
});
|
|
810
1376
|
if (!proof) {
|
|
811
1377
|
proof = {
|
|
812
1378
|
launcherPid: verified.runtimePid,
|
|
@@ -828,6 +1394,7 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
828
1394
|
lastVerified = { ...verified, proof, proofSource };
|
|
829
1395
|
if (verifiedSince === null) verifiedSince = now();
|
|
830
1396
|
if (now() - verifiedSince >= (options.verifyStableMs ?? VERIFY_STABLE_MS)) {
|
|
1397
|
+
ownershipCheck();
|
|
831
1398
|
log(
|
|
832
1399
|
`${kind} attempt=${attempt} id=${attemptId} verified `
|
|
833
1400
|
+ `launcher=${verified.runtimePid} agent=${verified.agentPid} `
|
|
@@ -855,6 +1422,11 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
855
1422
|
return { ...verified, agentVersion: proof.agentVersion, attemptId };
|
|
856
1423
|
}
|
|
857
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
|
+
}
|
|
858
1430
|
lastError = error;
|
|
859
1431
|
const state = readProofState();
|
|
860
1432
|
terminalProofFailure = state?.stage === 'connected'
|
|
@@ -873,6 +1445,7 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
873
1445
|
`${kind} attempt=${attempt} did not become healthy: `
|
|
874
1446
|
+ `${lastError?.message || (lastVerified ? 'runtime did not remain stable' : 'verification deadline expired')}`
|
|
875
1447
|
);
|
|
1448
|
+
ownershipCheck();
|
|
876
1449
|
writeState(config, `${kind}-attempt-failed`, {
|
|
877
1450
|
attemptId,
|
|
878
1451
|
attemptKind: kind,
|
|
@@ -886,17 +1459,50 @@ export async function launchAndVerifyLegacyClientReplacement(
|
|
|
886
1459
|
}
|
|
887
1460
|
|
|
888
1461
|
export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
889
|
-
const env = options.env || process.env;
|
|
1462
|
+
const env = expandLegacyBridgeEnvironment(options.env || process.env);
|
|
890
1463
|
const config = readConfig(env);
|
|
891
1464
|
const log = createLogger(config, options.writeLog);
|
|
892
|
-
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));
|
|
893
1472
|
const productManifest = options.productManifest || readManifest('../package.json');
|
|
894
1473
|
const clientManifest = options.clientManifest || readManifest('../client/package.json');
|
|
895
1474
|
const sleepImpl = options.sleepImpl || sleep;
|
|
896
1475
|
const terminateOld = options.terminateOldRuntime || (value => terminateOldRuntime(value, {
|
|
897
1476
|
sleepImpl,
|
|
898
|
-
aliveImpl: options.aliveImpl || isAlive
|
|
1477
|
+
aliveImpl: options.aliveImpl || isAlive,
|
|
1478
|
+
ownershipCheck,
|
|
1479
|
+
deadlineCheck: () => ensureDeadline('old-runtime termination')
|
|
899
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
|
+
};
|
|
900
1506
|
const attemptReplacement = options.attemptReplacement || ((
|
|
901
1507
|
version,
|
|
902
1508
|
kind,
|
|
@@ -915,7 +1521,8 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
915
1521
|
...options,
|
|
916
1522
|
env,
|
|
917
1523
|
log,
|
|
918
|
-
sleepImpl
|
|
1524
|
+
sleepImpl,
|
|
1525
|
+
assertOperationOwnership: ownershipCheck
|
|
919
1526
|
}
|
|
920
1527
|
)
|
|
921
1528
|
));
|
|
@@ -931,6 +1538,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
931
1538
|
|| config.agentPid <= 1) {
|
|
932
1539
|
throw new Error('legacy update configuration is incomplete');
|
|
933
1540
|
}
|
|
1541
|
+
ensureDeadline('configuration validation');
|
|
934
1542
|
if (cleanVersion(productManifest.version) !== config.targetProductVersion) {
|
|
935
1543
|
throw new Error(`prepared product mismatch expected=${config.targetProductVersion} actual=${productManifest.version || 'unknown'}`);
|
|
936
1544
|
}
|
|
@@ -938,6 +1546,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
938
1546
|
|| cleanVersion(clientManifest.version) !== config.targetVersion) {
|
|
939
1547
|
throw new Error(`prepared Agent mismatch expected=${config.targetVersion} bundled=${productManifest.livedeskClientVersion || 'unknown'} client=${clientManifest.version || 'unknown'}`);
|
|
940
1548
|
}
|
|
1549
|
+
config.neutralCwd = prepareNeutralCwd(config.neutralCwd);
|
|
941
1550
|
const frozen = (options.captureRestartConfiguration || captureRestartConfiguration)(
|
|
942
1551
|
config,
|
|
943
1552
|
env,
|
|
@@ -945,21 +1554,49 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
945
1554
|
);
|
|
946
1555
|
config.restartArgs = [...frozen.restartArgs];
|
|
947
1556
|
config.runtimePort = frozen.runtimePort;
|
|
1557
|
+
config.localRestoreLauncher = (
|
|
1558
|
+
options.resolveExactLocalRestoreLauncher
|
|
1559
|
+
|| resolveExactLocalRestoreLauncher
|
|
1560
|
+
)(config, env, frozen);
|
|
948
1561
|
log(`options-frozen count=${config.restartArgs.length} runtimePort=${config.runtimePort} engine=${frozen.engine}`);
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
+
}
|
|
956
1584
|
log(`preflight-ready target=${config.targetProductVersion}/${config.targetVersion}`);
|
|
957
|
-
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');
|
|
958
1594
|
log(`stopping launcher=${config.launcherPid} agent=${config.agentPid}`);
|
|
959
1595
|
await terminateOld(config);
|
|
960
1596
|
|
|
961
1597
|
const targetErrors = [];
|
|
962
1598
|
for (let attempt = 1; attempt <= TARGET_ATTEMPT_LIMIT; attempt += 1) {
|
|
1599
|
+
ownershipCheck();
|
|
963
1600
|
try {
|
|
964
1601
|
await attemptReplacement(
|
|
965
1602
|
config.targetProductVersion,
|
|
@@ -968,6 +1605,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
968
1605
|
'--prefer-online',
|
|
969
1606
|
config.targetVersion
|
|
970
1607
|
);
|
|
1608
|
+
ownershipCheck();
|
|
971
1609
|
log(`completed target=${config.targetProductVersion}/${config.targetVersion}`);
|
|
972
1610
|
persistState(config, 'connected', {
|
|
973
1611
|
restartVerified: true,
|
|
@@ -976,6 +1614,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
976
1614
|
});
|
|
977
1615
|
return { outcome: 'updated', attempt };
|
|
978
1616
|
} catch (error) {
|
|
1617
|
+
if (isLegacyClientUpdateSupersededError(error)) throw error;
|
|
979
1618
|
targetErrors.push(String(error?.message || error));
|
|
980
1619
|
log(`target attempt=${attempt} failed error=${error?.message || error}`);
|
|
981
1620
|
}
|
|
@@ -986,6 +1625,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
986
1625
|
}
|
|
987
1626
|
const restoreErrors = [];
|
|
988
1627
|
for (let attempt = 1; attempt <= RESTORE_ATTEMPT_LIMIT; attempt += 1) {
|
|
1628
|
+
ownershipCheck();
|
|
989
1629
|
try {
|
|
990
1630
|
await attemptReplacement(
|
|
991
1631
|
config.currentProductVersion,
|
|
@@ -994,6 +1634,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
994
1634
|
'--prefer-offline',
|
|
995
1635
|
config.currentAgentVersion
|
|
996
1636
|
);
|
|
1637
|
+
ownershipCheck();
|
|
997
1638
|
log(`restored version=${config.currentProductVersion}`);
|
|
998
1639
|
persistState(config, 'restored', {
|
|
999
1640
|
productVersion: config.currentProductVersion,
|
|
@@ -1009,6 +1650,7 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
|
1009
1650
|
error: `Target ${config.targetProductVersion} failed verification: ${targetErrors.join(' | ')}`
|
|
1010
1651
|
};
|
|
1011
1652
|
} catch (error) {
|
|
1653
|
+
if (isLegacyClientUpdateSupersededError(error)) throw error;
|
|
1012
1654
|
restoreErrors.push(String(error?.message || error));
|
|
1013
1655
|
log(`restore attempt=${attempt} failed error=${error?.message || error}`);
|
|
1014
1656
|
}
|
|
@@ -1027,14 +1669,16 @@ export async function runLegacyClientUpdateSupervisorCli() {
|
|
|
1027
1669
|
const config = readConfig();
|
|
1028
1670
|
const log = createLogger(config);
|
|
1029
1671
|
log(`failed error=${error?.stack || error}`);
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
+
}
|
|
1038
1682
|
}
|
|
1039
1683
|
console.error(error?.message || error);
|
|
1040
1684
|
process.exitCode = 1;
|