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.
@@ -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
- 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),
@@ -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: String(
103
- env.LIVEDESK_LEGACY_CLIENT_UPDATE_STATE_PATH
104
- || env.LIVEDESK_CLIENT_UPDATE_STATE_PATH
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 buildLegacyClientRestartConfiguration({ env, preservedArgs });
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 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
+ );
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
- mkdirSync(dirname(config.statePath), { recursive: true });
429
- const previous = readUpdateState(config);
430
- const next = {
431
- ...(previous && previous.operationId === config.operationId ? previous : {}),
432
- operationId: config.operationId,
433
- stage,
434
- targetVersion: config.targetVersion,
435
- targetProductVersion: config.targetProductVersion,
436
- targetAgentVersion: config.targetVersion,
437
- currentProductVersion: config.currentProductVersion,
438
- supervisorPid: process.pid,
439
- updatedAt: new Date().toISOString(),
440
- ...details
441
- };
442
- const temporaryPath = `${config.statePath}.${process.pid}.${randomBytes(5).toString('hex')}.tmp`;
443
- try {
444
- writeFileSync(temporaryPath, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
445
- renameSync(temporaryPath, config.statePath);
446
- } finally {
447
- rmSync(temporaryPath, { force: true });
448
- }
449
- 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 });
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(pid, timeoutMs, sleepImpl = sleep, aliveImpl = isAlive) {
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 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;
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
- if (!outputPath || !existsSync(outputPath)) return false;
639
- let output = '';
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
- killer.once('error', resolve);
662
- 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);
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(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();
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(config.launcherPid, 15_000, sleepImpl, aliveImpl);
697
- 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
+ );
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
- 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 {
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
- } catch (proofError) {
795
- const restoreWelcomeObserved = kind === 'restore'
796
- && (options.hasLegacyConnectedOutput || hasLegacyConnectedOutput)(
797
- outputPath,
798
- config.deviceId
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 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));
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
- persistState(config, 'preflight-ready', {
950
- runtimePort: config.runtimePort,
951
- engine: frozen.engine,
952
- restartArgCount: config.restartArgs.length,
953
- restartVerified: false,
954
- error: ''
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(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');
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
- try {
1031
- writeUpdateState(config, 'failed', {
1032
- restartVerified: false,
1033
- failedAt: new Date().toISOString(),
1034
- error: String(error?.message || error).slice(0, 4000)
1035
- });
1036
- } catch {
1037
- // 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
+ }
1038
1682
  }
1039
1683
  console.error(error?.message || error);
1040
1684
  process.exitCode = 1;