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.
@@ -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
- const stateDir = String(env.LIVEDESK_STATE_DIR || join(os.homedir(), '.livedesk'));
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: String(env.LIVEDESK_CLIENT_UPDATE_OPERATION_ID || '').trim(),
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: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_CURRENT_PRODUCT_VERSION),
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: String(
101
- env.LIVEDESK_LEGACY_CLIENT_UPDATE_STATE_PATH
102
- || env.LIVEDESK_CLIENT_UPDATE_STATE_PATH
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 buildLegacyClientRestartConfiguration({ env, preservedArgs });
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 buildLegacyClientRestartConfiguration({ env, launcherArgs, agentArgs });
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
- mkdirSync(dirname(config.statePath), { recursive: true });
427
- const previous = readUpdateState(config);
428
- const next = {
429
- ...(previous && previous.operationId === config.operationId ? previous : {}),
430
- operationId: config.operationId,
431
- stage,
432
- targetVersion: config.targetVersion,
433
- targetProductVersion: config.targetProductVersion,
434
- targetAgentVersion: config.targetVersion,
435
- currentProductVersion: config.currentProductVersion,
436
- supervisorPid: process.pid,
437
- updatedAt: new Date().toISOString(),
438
- ...details
439
- };
440
- const temporaryPath = `${config.statePath}.${process.pid}.${randomBytes(5).toString('hex')}.tmp`;
441
- try {
442
- writeFileSync(temporaryPath, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
443
- renameSync(temporaryPath, config.statePath);
444
- } finally {
445
- rmSync(temporaryPath, { force: true });
446
- }
447
- return next;
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(pid, timeoutMs, sleepImpl = sleep, aliveImpl = isAlive) {
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 invocation = resolveNpxInvocation([...packageArgs, ...roleArgs], env);
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
- if (!outputPath || !existsSync(outputPath)) return false;
637
- let output = '';
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
- killer.once('error', resolve);
660
- killer.once('exit', resolve);
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(config, { sleepImpl = sleep, aliveImpl = isAlive } = {}) {
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(config.launcherPid, 15_000, sleepImpl, aliveImpl);
695
- const agentExited = await waitForExit(config.agentPid, 15_000, sleepImpl, aliveImpl);
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
- try {
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
- } catch (proofError) {
793
- const restoreWelcomeObserved = kind === 'restore'
794
- && (options.hasLegacyConnectedOutput || hasLegacyConnectedOutput)(
795
- outputPath,
796
- config.deviceId
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 persistState = options.writeUpdateState || writeUpdateState;
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
- persistState(config, 'preflight-ready', {
948
- runtimePort: config.runtimePort,
949
- engine: frozen.engine,
950
- restartArgCount: config.restartArgs.length,
951
- restartVerified: false,
952
- error: ''
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(config.starterPid, 10_000, sleepImpl, options.aliveImpl || isAlive);
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
- try {
1029
- writeUpdateState(config, 'failed', {
1030
- restartVerified: false,
1031
- failedAt: new Date().toISOString(),
1032
- error: String(error?.message || error).slice(0, 4000)
1033
- });
1034
- } catch {
1035
- // The persistent log remains the fallback on a read-only state directory.
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;