agl 22.0.0 → 22.0.2

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.
Files changed (33) hide show
  1. package/changelog.md +20 -0
  2. package/dist_serve/bundle.js +520 -509
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.authstore.d.ts +1 -2
  5. package/dist_ts/classes.authstore.js +1 -6
  6. package/dist_ts/classes.cli.d.ts +4 -2
  7. package/dist_ts/classes.cli.js +161 -68
  8. package/dist_ts/classes.controller.js +68 -30
  9. package/dist_ts/classes.upgradecoordinator.d.ts +39 -1
  10. package/dist_ts/classes.upgradecoordinator.js +740 -74
  11. package/dist_ts/classes.upgradetransaction.d.ts +68 -1
  12. package/dist_ts/classes.upgradetransaction.js +383 -223
  13. package/dist_ts_migration/classes.documentmigrationrunner.js +3 -1
  14. package/dist_ts_migration/index.d.ts +2 -0
  15. package/dist_ts_migration/index.js +3 -1
  16. package/dist_ts_migration/v24_legacyterminallayout.d.ts +18 -0
  17. package/dist_ts_migration/v24_legacyterminallayout.js +79 -0
  18. package/dist_ts_migration/v24_legacyterminallayoutmigration.d.ts +6 -0
  19. package/dist_ts_migration/v24_legacyterminallayoutmigration.js +67 -0
  20. package/package.json +1 -1
  21. package/readme.md +255 -378
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/classes.authstore.ts +0 -15
  24. package/ts/classes.cli.ts +184 -83
  25. package/ts/classes.controller.ts +79 -33
  26. package/ts/classes.upgradecoordinator.ts +876 -74
  27. package/ts/classes.upgradetransaction.ts +574 -250
  28. package/ts_migration/classes.documentmigrationrunner.ts +2 -0
  29. package/ts_migration/index.ts +2 -0
  30. package/ts_migration/v24_legacyterminallayout.ts +131 -0
  31. package/ts_migration/v24_legacyterminallayoutmigration.ts +88 -0
  32. package/ts_web/00_commitinfo_data.ts +1 -1
  33. package/ts_web/elements.harnesscontrollerapp.ts +32 -2
@@ -41,11 +41,17 @@ const initializationGraceMs = 30_000;
41
41
  const startLeaseDrainTimeoutMs = 60_000;
42
42
  const upgradeTokenPattern = /^[A-Za-z0-9_-]{43}$/;
43
43
  const upgradeTokenHashPattern = /^[a-f0-9]{64}$/;
44
+ const upgradeSemverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
44
45
  const leaseDirectoryPattern = /^[a-f0-9]{20}$/;
45
46
  const tokenMetadataPattern = /^(?:grant|ack|action-(?:prepare|finalize))-[a-f0-9]{20}\.json(?:\.consuming-[1-9][0-9]*-[a-f0-9]{8})?$/;
47
+ const acknowledgementTemporaryPattern = /^ack-[a-f0-9]{20}\.json\.tmp-[1-9][0-9]*-[a-f0-9]{8}$/;
48
+ const ownerTransferTemporaryPattern = /^owner-transfer-[1-9][0-9]*-[a-f0-9]{8}\.json\.tmp$/;
46
49
  const transactionMetadataPattern = /^transaction-[a-f0-9]{20}\.json$/;
47
50
  const transactionTemporaryMetadataPattern = /^transaction-[a-f0-9]{20}\.json\.tmp-[1-9][0-9]*-[a-f0-9]{8}$/;
48
51
  const transactionMutationDirectoryPattern = /^transaction-[a-f0-9]{20}\.json\.lock$/;
52
+ const transactionAdoptionPattern = /^transaction-([a-f0-9]{20})\.json\.adopting-([a-f0-9]{20})$/;
53
+ const transactionAdoptionAuditPattern = /^adoption-([a-f0-9]{20})-([a-f0-9]{20})\.json$/;
54
+ const transactionAdoptionAuditTemporaryPattern = /^adoption-[a-f0-9]{20}-[a-f0-9]{20}\.json\.tmp-[1-9][0-9]*-[a-f0-9]{8}$/;
49
55
  const ownershipGuardDirectoryPattern = /^[a-f0-9]{64}\.guard$/;
50
56
  const ownershipGuardTemporaryPattern = /^[a-f0-9]{64}\.guard\.tmp-[1-9][0-9]*-[a-f0-9]{32}$/;
51
57
  const ownerRemovalPattern = /^\.owner-removing-([1-9][0-9]*)-([a-f0-9]{64})-([a-f0-9]{16})$/;
@@ -60,6 +66,13 @@ const workerTerminationGraceMs = 5_000;
60
66
 
61
67
  type TUpgradeOwnerKind = 'upgrade' | 'start';
62
68
 
69
+ interface IUpgradeSemver {
70
+ major: string;
71
+ minor: string;
72
+ patch: string;
73
+ prerelease: string[];
74
+ }
75
+
63
76
  interface IUpgradeProcessOwner {
64
77
  version: typeof coordinationVersion;
65
78
  kind: TUpgradeOwnerKind;
@@ -90,6 +103,14 @@ interface IUpgradeLaunchGrant {
90
103
  controller: IUpgradeExpectedController;
91
104
  }
92
105
 
106
+ interface IUpgradeWorkerAcknowledgement {
107
+ version: typeof coordinationVersion;
108
+ tokenHash: string;
109
+ pid: number;
110
+ processGroupId: number;
111
+ fingerprint: string;
112
+ }
113
+
93
114
  export interface IUpgradeWorkerPayload {
94
115
  version: typeof coordinationVersion;
95
116
  token: string;
@@ -158,6 +179,7 @@ export interface IUpgradeTransactionV2 extends IUpgradeTransactionBase {
158
179
  version: typeof coordinationVersion;
159
180
  sourceVersion: string;
160
181
  targetVersion?: string;
182
+ registryUrl?: string;
161
183
  targetStartupInvoked?: true;
162
184
  }
163
185
 
@@ -173,6 +195,7 @@ export interface IUpgradeTransactionV3 extends IUpgradeTransactionBase {
173
195
  targetManagementVersion: typeof upgradePackageTransitionTarget.managementVersion;
174
196
  targetCliName: typeof upgradePackageTransitionTarget.cliName;
175
197
  targetCliRelativePath: typeof upgradePackageTransitionTarget.cliRelativePath;
198
+ recoveryTargetVersion?: string;
176
199
  packageTransitionStarted?: true;
177
200
  groupedTargetVerified?: true;
178
201
  targetPackageCommitStarted?: true;
@@ -182,6 +205,35 @@ export interface IUpgradeTransactionV3 extends IUpgradeTransactionBase {
182
205
 
183
206
  export type TUpgradeTransaction = IUpgradeTransactionV2 | IUpgradeTransactionV3;
184
207
 
208
+ export interface IUpgradeTransactionInventoryEntry {
209
+ fileName: string;
210
+ state: 'active' | 'adopting';
211
+ transaction: TUpgradeTransaction;
212
+ }
213
+
214
+ export interface IUpgradeTransactionInventory {
215
+ transactions: IUpgradeTransactionInventoryEntry[];
216
+ tokenMetadataFileNames: string[];
217
+ mutationDirectoryNames: string[];
218
+ }
219
+
220
+ export interface IAdoptOrphanedUpgradeTransactionOptions {
221
+ lock: UpgradeInstallationLock;
222
+ token: string;
223
+ expectedTokenHash: string;
224
+ expectedRevision: number;
225
+ port: number;
226
+ installedVersion: string;
227
+ registryUrl?: string;
228
+ }
229
+
230
+ interface IUpgradeTransactionAdoptionAudit {
231
+ version: 1;
232
+ adoptedAt: number;
233
+ adoptedTargetVersion: string;
234
+ previousTransaction: TUpgradeTransaction;
235
+ }
236
+
185
237
  export type TUpgradeControllerAction = 'prepare' | 'finalize';
186
238
 
187
239
  interface IUpgradeControllerActionGrant {
@@ -443,6 +495,82 @@ const assertBoundedText = (valueArg: unknown, nameArg: string, maximumBytesArg:
443
495
  return valueArg;
444
496
  };
445
497
 
498
+ const parseUpgradeSemver = (valueArg: string): IUpgradeSemver => {
499
+ const match = upgradeSemverPattern.exec(valueArg);
500
+ if (!match) throw new Error(`Invalid semantic version: ${valueArg}`);
501
+ const prerelease = match[4]?.split('.') ?? [];
502
+ if (prerelease.some((identifierArg) => /^\d+$/.test(identifierArg)
503
+ && identifierArg.length > 1
504
+ && identifierArg.startsWith('0'))) {
505
+ throw new Error(`Invalid semantic version: ${valueArg}`);
506
+ }
507
+ return {
508
+ major: match[1],
509
+ minor: match[2],
510
+ patch: match[3],
511
+ prerelease,
512
+ };
513
+ };
514
+
515
+ const compareUpgradeNumericStrings = (leftArg: string, rightArg: string): number => {
516
+ if (leftArg.length !== rightArg.length) return leftArg.length < rightArg.length ? -1 : 1;
517
+ return leftArg === rightArg ? 0 : leftArg < rightArg ? -1 : 1;
518
+ };
519
+
520
+ export const compareUpgradeSemver = (leftArg: string, rightArg: string): number => {
521
+ const left = parseUpgradeSemver(leftArg);
522
+ const right = parseUpgradeSemver(rightArg);
523
+ for (const key of ['major', 'minor', 'patch'] as const) {
524
+ const comparison = compareUpgradeNumericStrings(left[key], right[key]);
525
+ if (comparison !== 0) return comparison;
526
+ }
527
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
528
+ if (left.prerelease.length === right.prerelease.length) return 0;
529
+ return left.prerelease.length === 0 ? 1 : -1;
530
+ }
531
+ const identifierCount = Math.max(left.prerelease.length, right.prerelease.length);
532
+ for (let index = 0; index < identifierCount; index++) {
533
+ const leftIdentifier = left.prerelease[index];
534
+ const rightIdentifier = right.prerelease[index];
535
+ if (leftIdentifier === undefined || rightIdentifier === undefined) {
536
+ return leftIdentifier === rightIdentifier ? 0 : leftIdentifier === undefined ? -1 : 1;
537
+ }
538
+ if (leftIdentifier === rightIdentifier) continue;
539
+ const leftNumeric = /^\d+$/.test(leftIdentifier);
540
+ const rightNumeric = /^\d+$/.test(rightIdentifier);
541
+ if (leftNumeric && rightNumeric) {
542
+ return compareUpgradeNumericStrings(leftIdentifier, rightIdentifier);
543
+ }
544
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
545
+ return leftIdentifier < rightIdentifier ? -1 : 1;
546
+ }
547
+ return 0;
548
+ };
549
+
550
+ export const normalizeUpgradeRegistryUrl = (valueArg: unknown): string => {
551
+ const value = assertBoundedText(valueArg, 'The upgrade registry URL', 2_048);
552
+ let parsed: URL;
553
+ try {
554
+ parsed = new URL(value);
555
+ } catch (errorArg) {
556
+ throw new Error('The upgrade registry URL is invalid.', { cause: errorArg });
557
+ }
558
+ const loopbackHttp = parsed.protocol === 'http:' && new Set([
559
+ 'localhost',
560
+ '127.0.0.1',
561
+ '[::1]',
562
+ ]).has(parsed.hostname);
563
+ if (
564
+ (parsed.protocol !== 'https:' && !loopbackHttp)
565
+ || parsed.hostname.length === 0
566
+ || parsed.username.length > 0
567
+ || parsed.password.length > 0
568
+ || parsed.search.length > 0
569
+ || parsed.hash.length > 0
570
+ ) throw new Error('The upgrade registry URL is unsafe.');
571
+ return parsed.toString();
572
+ };
573
+
446
574
  const assertRuntimeId = (valueArg: unknown): IControllerRuntimeId => {
447
575
  if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
448
576
  throw new Error('The upgrade session identity is malformed.');
@@ -550,6 +678,7 @@ const parseUpgradeTransactionV2 = (valueArg: unknown): IUpgradeTransactionV2 =>
550
678
  'continueSessions', 'gracePeriodMs', 'phase', 'message', 'createdAt', 'updatedAt',
551
679
  'phaseStartedAt', 'sessions',
552
680
  ...(value.targetVersion === undefined ? [] : ['targetVersion']),
681
+ ...(value.registryUrl === undefined ? [] : ['registryUrl']),
553
682
  ...(value.targetStartupInvoked === undefined ? [] : ['targetStartupInvoked']),
554
683
  ...(value.preparationAcceptedAt === undefined ? [] : ['preparationAcceptedAt']),
555
684
  ...(value.preparationDeadlineAt === undefined ? [] : ['preparationDeadlineAt']),
@@ -696,6 +825,9 @@ const parseUpgradeTransactionV2 = (valueArg: unknown): IUpgradeTransactionV2 =>
696
825
  ...(value.targetVersion === undefined
697
826
  ? {}
698
827
  : { targetVersion: assertBoundedText(value.targetVersion, 'The upgrade target version', 128) }),
828
+ ...(value.registryUrl === undefined
829
+ ? {}
830
+ : { registryUrl: normalizeUpgradeRegistryUrl(value.registryUrl) }),
699
831
  ...(value.targetStartupInvoked === true ? { targetStartupInvoked: true } : {}),
700
832
  controllerWasRunning: value.controllerWasRunning,
701
833
  continueSessions: value.continueSessions,
@@ -737,6 +869,7 @@ const parseUpgradeTransactionV3 = (valueArg: unknown): IUpgradeTransactionV3 =>
737
869
  'targetCliName', 'targetCliRelativePath',
738
870
  'controllerWasRunning', 'continueSessions', 'gracePeriodMs', 'phase', 'message',
739
871
  'createdAt', 'updatedAt', 'phaseStartedAt', 'sessions',
872
+ ...(value.recoveryTargetVersion === undefined ? [] : ['recoveryTargetVersion']),
740
873
  ...(value.packageTransitionStarted === undefined ? [] : ['packageTransitionStarted']),
741
874
  ...(value.groupedTargetVerified === undefined ? [] : ['groupedTargetVerified']),
742
875
  ...(value.targetPackageCommitStarted === undefined ? [] : ['targetPackageCommitStarted']),
@@ -762,6 +895,14 @@ const parseUpgradeTransactionV3 = (valueArg: unknown): IUpgradeTransactionV3 =>
762
895
  || value.targetManagementVersion !== upgradePackageTransitionTarget.managementVersion
763
896
  || value.targetCliName !== upgradePackageTransitionTarget.cliName
764
897
  || value.targetCliRelativePath !== upgradePackageTransitionTarget.cliRelativePath
898
+ || (value.recoveryTargetVersion !== undefined && (
899
+ typeof value.recoveryTargetVersion !== 'string'
900
+ || compareUpgradeSemver(
901
+ value.recoveryTargetVersion,
902
+ upgradePackageTransitionTarget.version,
903
+ ) <= 0
904
+ || value.targetPackageCommitStarted !== true
905
+ ))
765
906
  || (value.packageTransitionStarted !== undefined && value.packageTransitionStarted !== true)
766
907
  || (value.groupedTargetVerified !== undefined && value.groupedTargetVerified !== true)
767
908
  || (value.targetPackageCommitStarted !== undefined
@@ -829,6 +970,15 @@ const parseUpgradeTransactionV3 = (valueArg: unknown): IUpgradeTransactionV3 =>
829
970
  targetManagementVersion: upgradePackageTransitionTarget.managementVersion,
830
971
  targetCliName: upgradePackageTransitionTarget.cliName,
831
972
  targetCliRelativePath: upgradePackageTransitionTarget.cliRelativePath,
973
+ ...(value.recoveryTargetVersion === undefined
974
+ ? {}
975
+ : {
976
+ recoveryTargetVersion: assertBoundedText(
977
+ value.recoveryTargetVersion,
978
+ 'The package-transition recovery target version',
979
+ 128,
980
+ ),
981
+ }),
832
982
  ...(value.packageTransitionStarted === true ? { packageTransitionStarted: true } : {}),
833
983
  ...(value.groupedTargetVerified === true ? { groupedTargetVerified: true } : {}),
834
984
  ...(value.targetPackageCommitStarted === true ? { targetPackageCommitStarted: true } : {}),
@@ -846,6 +996,34 @@ const parseUpgradeTransaction = (valueArg: unknown): TUpgradeTransaction => {
846
996
  : parseUpgradeTransactionV3(valueArg);
847
997
  };
848
998
 
999
+ const parseUpgradeTransactionAdoptionAudit = (
1000
+ valueArg: unknown,
1001
+ ): IUpgradeTransactionAdoptionAudit => {
1002
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
1003
+ throw new Error('Upgrade transaction adoption metadata is malformed.');
1004
+ }
1005
+ const value = valueArg as Record<string, unknown>;
1006
+ if (!exactKeys(value, [
1007
+ 'version', 'adoptedAt', 'adoptedTargetVersion', 'previousTransaction',
1008
+ ])) throw new Error('Upgrade transaction adoption metadata has unexpected fields.');
1009
+ const previousTransaction = parseUpgradeTransaction(value.previousTransaction);
1010
+ if (
1011
+ value.version !== 1
1012
+ || !Number.isSafeInteger(value.adoptedAt)
1013
+ || (value.adoptedAt as number) < previousTransaction.createdAt
1014
+ ) throw new Error('Upgrade transaction adoption metadata is invalid.');
1015
+ return {
1016
+ version: 1,
1017
+ adoptedAt: value.adoptedAt as number,
1018
+ adoptedTargetVersion: assertBoundedText(
1019
+ value.adoptedTargetVersion,
1020
+ 'The adopted upgrade target version',
1021
+ 128,
1022
+ ),
1023
+ previousTransaction,
1024
+ };
1025
+ };
1026
+
849
1027
  const parseTransactionMutationOwner = (valueArg: unknown): IUpgradeTransactionMutationOwner => {
850
1028
  if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
851
1029
  throw new Error('The upgrade transaction mutation owner is malformed.');
@@ -945,6 +1123,18 @@ export const upgradeTransactionIsStalled = (
945
1123
  return nowArg - transactionArg.phaseStartedAt > maximumPhaseDurationMs[transactionArg.phase];
946
1124
  };
947
1125
 
1126
+ export const upgradeTransactionRequiresForwardRecovery = (
1127
+ transactionArg: TUpgradeTransaction,
1128
+ ): boolean => transactionArg.version === coordinationVersion
1129
+ ? transactionArg.targetStartupInvoked === true
1130
+ : transactionArg.targetPackageCommitStarted === true;
1131
+
1132
+ export const upgradeTransactionTargetVersion = (
1133
+ transactionArg: TUpgradeTransaction,
1134
+ ): string | undefined => transactionArg.version === upgradePackageTransitionTransactionVersion
1135
+ ? transactionArg.recoveryTargetVersion ?? transactionArg.targetVersion
1136
+ : transactionArg.targetVersion;
1137
+
948
1138
  const ownerIsLive = async (ownerArg: IUpgradeProcessOwner): Promise<boolean> => {
949
1139
  if (ownerArg.uid !== assertUid()) return false;
950
1140
  const identity = await readControllerProcessIdentity(ownerArg.pid);
@@ -1357,6 +1547,104 @@ class UpgradeOwnedDirectory {
1357
1547
  export class UpgradeInstallationLock extends UpgradeOwnedDirectory {}
1358
1548
  export class UpgradeStartLease extends UpgradeOwnedDirectory {}
1359
1549
 
1550
+ export interface IVerifiedUpgradeWorkerProcessGroup {
1551
+ pid: number;
1552
+ processGroupId: number;
1553
+ fingerprint: string;
1554
+ cliPath: string;
1555
+ }
1556
+
1557
+ export const terminateVerifiedUpgradeWorkerProcessGroup = async (
1558
+ worker: IVerifiedUpgradeWorkerProcessGroup,
1559
+ ): Promise<void> => {
1560
+ const identity = await readControllerProcessIdentity(worker.pid);
1561
+ const existingMembers = worker.processGroupId === worker.pid
1562
+ ? await readProcessGroupMemberPids(worker.processGroupId)
1563
+ : [];
1564
+ if (!identity || identity.fingerprint !== worker.fingerprint) {
1565
+ if (existingMembers.length === 0) return;
1566
+ throw new Error(
1567
+ 'The stalled upgrade worker leader is unavailable; its remaining group cannot be signaled safely.',
1568
+ );
1569
+ }
1570
+ if (
1571
+ identity.processGroupId !== worker.processGroupId
1572
+ || !identity.processGroupLeader
1573
+ || !await processIdentityHasCliCommand(
1574
+ identity,
1575
+ worker.cliPath,
1576
+ '__upgrade-worker',
1577
+ { allowMissingAbsolutePath: true },
1578
+ )
1579
+ ) throw new Error('The stalled upgrade worker identity changed unexpectedly.');
1580
+ const readStableMemberSnapshot = async (): Promise<NonNullable<Awaited<
1581
+ ReturnType<typeof readControllerProcessIdentity>
1582
+ >>[]> => {
1583
+ for (let attempt = 0; attempt < 4; attempt++) {
1584
+ const processIdsBefore = await readProcessGroupMemberPids(worker.processGroupId);
1585
+ const identities = await Promise.all(
1586
+ processIdsBefore.map(async (processIdArg) => await readControllerProcessIdentity(processIdArg)),
1587
+ );
1588
+ const processIdsAfter = await readProcessGroupMemberPids(worker.processGroupId);
1589
+ if (
1590
+ processIdsBefore.length === processIdsAfter.length
1591
+ && processIdsBefore.every((processIdArg, indexArg) => processIdArg === processIdsAfter[indexArg])
1592
+ && identities.every((candidateArg) => (
1593
+ candidateArg !== null && candidateArg.processGroupId === worker.processGroupId
1594
+ ))
1595
+ && identities.some((candidateArg) => (
1596
+ candidateArg!.pid === worker.pid && candidateArg!.fingerprint === worker.fingerprint
1597
+ ))
1598
+ ) return identities as NonNullable<typeof identities[number]>[];
1599
+ }
1600
+ throw new Error('The stalled upgrade worker group could not be snapshotted safely.');
1601
+ };
1602
+ const members = await readStableMemberSnapshot();
1603
+ const signalOwnedMembers = async (signalArg: NodeJS.Signals): Promise<void> => {
1604
+ const orderedMembers = [...members].sort((leftArg, rightArg) => (
1605
+ leftArg.pid === worker.pid ? 1 : rightArg.pid === worker.pid ? -1 : 0
1606
+ ));
1607
+ for (const member of orderedMembers) {
1608
+ const current = await readControllerProcessIdentity(member.pid);
1609
+ if (!current || current.fingerprint !== member.fingerprint) continue;
1610
+ if (current.processGroupId !== worker.processGroupId) {
1611
+ throw new Error('A captured upgrade worker process escaped its owned process group.');
1612
+ }
1613
+ try {
1614
+ process.kill(member.pid, signalArg);
1615
+ } catch (errorArg) {
1616
+ if ((errorArg as NodeJS.ErrnoException).code !== 'ESRCH') throw errorArg;
1617
+ }
1618
+ }
1619
+ };
1620
+ const waitForDrain = async (): Promise<boolean> => {
1621
+ const deadline = Date.now() + workerTerminationGraceMs;
1622
+ while (Date.now() < deadline) {
1623
+ const [captured, group] = await Promise.all([
1624
+ Promise.all(members.map(async (memberArg) => {
1625
+ const current = await readControllerProcessIdentity(memberArg.pid);
1626
+ return current?.fingerprint === memberArg.fingerprint;
1627
+ })),
1628
+ readProcessGroupMemberPids(worker.processGroupId),
1629
+ ]);
1630
+ if (!captured.some(Boolean) && group.length === 0) return true;
1631
+ await new Promise((resolve) => setTimeout(resolve, 50));
1632
+ }
1633
+ const [captured, group] = await Promise.all([
1634
+ Promise.all(members.map(async (memberArg) => {
1635
+ const current = await readControllerProcessIdentity(memberArg.pid);
1636
+ return current?.fingerprint === memberArg.fingerprint;
1637
+ })),
1638
+ readProcessGroupMemberPids(worker.processGroupId),
1639
+ ]);
1640
+ return !captured.some(Boolean) && group.length === 0;
1641
+ };
1642
+ await signalOwnedMembers('SIGTERM');
1643
+ if (await waitForDrain()) return;
1644
+ await signalOwnedMembers('SIGKILL');
1645
+ if (!await waitForDrain()) throw new Error('The stalled upgrade worker group did not terminate.');
1646
+ };
1647
+
1360
1648
  export class UpgradeCoordinator {
1361
1649
  public readonly uid: number;
1362
1650
  public readonly baseDirectory: string;
@@ -1424,6 +1712,14 @@ export class UpgradeCoordinator {
1424
1712
  await this.initializationTask;
1425
1713
  }
1426
1714
 
1715
+ public async initializeExistingCanonicalState(): Promise<void> {
1716
+ if (this.usesLegacyLocation || this.usesInheritedLocation) {
1717
+ throw new Error('Canonical upgrade initialization cannot use an inherited root.');
1718
+ }
1719
+ if (!await lstatIfPresent(this.baseDirectory)) return;
1720
+ await this.init();
1721
+ }
1722
+
1427
1723
  private async performInitialization(): Promise<void> {
1428
1724
  if (!this.usesLegacyLocation && !this.usesInheritedLocation) {
1429
1725
  const home = resolveAGLHomePaths();
@@ -1438,6 +1734,86 @@ export class UpgradeCoordinator {
1438
1734
  await this.cleanupOwnershipGuardDirectories();
1439
1735
  const entries = await plugins.fs.promises.readdir(this.baseDirectory, { withFileTypes: true });
1440
1736
  for (const entry of entries) {
1737
+ if (entry.isFile() && transactionAdoptionAuditTemporaryPattern.test(entry.name)) {
1738
+ const filePath = plugins.path.join(this.baseDirectory, entry.name);
1739
+ const stats = await lstatIfPresent(filePath);
1740
+ if (!stats) continue;
1741
+ if (
1742
+ !stats.isFile()
1743
+ || stats.isSymbolicLink()
1744
+ || stats.uid !== this.uid
1745
+ || (stats.mode & 0o077) !== 0
1746
+ ) throw new Error(`Upgrade transaction adoption temporary is unsafe: ${filePath}`);
1747
+ if (Date.now() - stats.mtimeMs > initializationGraceMs) {
1748
+ await plugins.fs.promises.unlink(filePath).catch((errorArg) => {
1749
+ if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg;
1750
+ });
1751
+ }
1752
+ continue;
1753
+ }
1754
+ if (entry.isFile() && transactionAdoptionAuditPattern.test(entry.name)) {
1755
+ const filePath = plugins.path.join(this.baseDirectory, entry.name);
1756
+ const stats = await lstatIfPresent(filePath);
1757
+ if (!stats) continue;
1758
+ if (
1759
+ !stats.isFile()
1760
+ || stats.isSymbolicLink()
1761
+ || stats.uid !== this.uid
1762
+ || (stats.mode & 0o077) !== 0
1763
+ ) throw new Error(`Upgrade transaction adoption metadata is unsafe: ${filePath}`);
1764
+ parseUpgradeTransactionAdoptionAudit(await readPrivateJson(filePath));
1765
+ if (Date.now() - stats.mtimeMs > transactionRetentionMs) {
1766
+ const lockState = await this.inspectOwnedDirectory(this.upgradeLockDirectory);
1767
+ if (lockState.state === 'absent' || lockState.state === 'stale') {
1768
+ await plugins.fs.promises.unlink(filePath).catch((errorArg) => {
1769
+ if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg;
1770
+ });
1771
+ }
1772
+ }
1773
+ continue;
1774
+ }
1775
+ if (entry.isFile() && acknowledgementTemporaryPattern.test(entry.name)) {
1776
+ const filePath = plugins.path.join(this.baseDirectory, entry.name);
1777
+ const stats = await lstatIfPresent(filePath);
1778
+ if (!stats) continue;
1779
+ if (
1780
+ !stats.isFile()
1781
+ || stats.isSymbolicLink()
1782
+ || stats.uid !== this.uid
1783
+ || (stats.mode & 0o077) !== 0
1784
+ ) throw new Error(`Upgrade acknowledgement temporary is unsafe: ${filePath}`);
1785
+ if (Date.now() - stats.mtimeMs > initializationGraceMs) {
1786
+ await plugins.fs.promises.unlink(filePath).catch((errorArg) => {
1787
+ if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg;
1788
+ });
1789
+ }
1790
+ continue;
1791
+ }
1792
+ if (entry.isFile() && ownerTransferTemporaryPattern.test(entry.name)) {
1793
+ const filePath = plugins.path.join(this.baseDirectory, entry.name);
1794
+ const stats = await lstatIfPresent(filePath);
1795
+ if (!stats) continue;
1796
+ if (
1797
+ !stats.isFile()
1798
+ || stats.isSymbolicLink()
1799
+ || stats.uid !== this.uid
1800
+ || (stats.mode & 0o077) !== 0
1801
+ || stats.nlink !== 1
1802
+ ) throw new Error(`Upgrade owner-transfer temporary is unsafe: ${filePath}`);
1803
+ if (Date.now() - stats.mtimeMs > initializationGraceMs) {
1804
+ await plugins.fs.promises.unlink(filePath).catch((errorArg) => {
1805
+ if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg;
1806
+ });
1807
+ await syncDirectory(this.baseDirectory);
1808
+ }
1809
+ continue;
1810
+ }
1811
+ if (entry.name.startsWith('owner-transfer-')) {
1812
+ throw new Error(`Upgrade owner-transfer temporary has an invalid name: ${entry.name}`);
1813
+ }
1814
+ if (entry.name.startsWith('adoption-')) {
1815
+ throw new Error(`Upgrade transaction adoption metadata has an invalid name: ${entry.name}`);
1816
+ }
1441
1817
  if (!entry.isFile() || !tokenMetadataPattern.test(entry.name)) continue;
1442
1818
  const filePath = plugins.path.join(this.baseDirectory, entry.name);
1443
1819
  const stats = await lstatIfPresent(filePath);
@@ -1460,6 +1836,18 @@ export class UpgradeCoordinator {
1460
1836
  withFileTypes: true,
1461
1837
  })) {
1462
1838
  const filePath = plugins.path.join(this.transactionsDirectory, entry.name);
1839
+ if (entry.isFile() && transactionAdoptionPattern.test(entry.name)) {
1840
+ const stats = await lstatIfPresent(filePath);
1841
+ if (!stats) continue;
1842
+ if (
1843
+ !stats.isFile()
1844
+ || stats.isSymbolicLink()
1845
+ || stats.uid !== this.uid
1846
+ || (stats.mode & 0o077) !== 0
1847
+ ) throw new Error(`Upgrade transaction adoption state is unsafe: ${filePath}`);
1848
+ parseUpgradeTransaction(await readPrivateJson(filePath));
1849
+ continue;
1850
+ }
1463
1851
  if (entry.isFile() && transactionTemporaryMetadataPattern.test(entry.name)) {
1464
1852
  const stats = await lstatIfPresent(filePath);
1465
1853
  if (!stats) continue;
@@ -1490,28 +1878,145 @@ export class UpgradeCoordinator {
1490
1878
  }
1491
1879
  continue;
1492
1880
  }
1493
- if (!entry.isFile() || !transactionMetadataPattern.test(entry.name)) continue;
1494
- const stats = await lstatIfPresent(filePath);
1495
- if (!stats) continue;
1881
+ if (entry.isFile() && transactionMetadataPattern.test(entry.name)) {
1882
+ const stats = await lstatIfPresent(filePath);
1883
+ if (!stats) continue;
1884
+ if (
1885
+ !stats.isFile()
1886
+ || stats.isSymbolicLink()
1887
+ || stats.uid !== this.uid
1888
+ || (stats.mode & 0o077) !== 0
1889
+ ) throw new Error(`Upgrade transaction metadata is unsafe: ${filePath}`);
1890
+ if (Date.now() - stats.mtimeMs > transactionRetentionMs) {
1891
+ const transaction = parseUpgradeTransaction(await readPrivateJson(filePath));
1892
+ const workerIsLive = await this.transactionWorkerIsLive(transaction).catch(() => true);
1893
+ const controllerIsLive = transaction.controller
1894
+ ? await this.transactionControllerIsLive(transaction).catch(() => true)
1895
+ : false;
1896
+ if (
1897
+ transaction.terminal
1898
+ || (
1899
+ !upgradeTransactionRequiresForwardRecovery(transaction)
1900
+ && !workerIsLive
1901
+ && !controllerIsLive
1902
+ )
1903
+ ) {
1904
+ await plugins.fs.promises.unlink(filePath).catch((errorArg) => {
1905
+ if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg;
1906
+ });
1907
+ }
1908
+ }
1909
+ continue;
1910
+ }
1911
+ if (entry.name.startsWith('transaction-')) {
1912
+ throw new Error(`Upgrade transaction metadata has an invalid name: ${entry.name}`);
1913
+ }
1914
+ }
1915
+ }
1916
+
1917
+ public async inspectCanonicalTransactionInventory(): Promise<IUpgradeTransactionInventory> {
1918
+ if (this.usesLegacyLocation || this.usesInheritedLocation) {
1919
+ throw new Error('Canonical upgrade transaction inventory cannot use an inherited root.');
1920
+ }
1921
+ const empty = (): IUpgradeTransactionInventory => ({
1922
+ transactions: [],
1923
+ tokenMetadataFileNames: [],
1924
+ mutationDirectoryNames: [],
1925
+ });
1926
+ const baseStats = await lstatIfPresent(this.baseDirectory);
1927
+ if (!baseStats) return empty();
1928
+ await assertPrivateDirectory(this.baseDirectory);
1929
+
1930
+ const tokenMetadataFileNames: string[] = [];
1931
+ for (const entry of await plugins.fs.promises.readdir(this.baseDirectory, {
1932
+ withFileTypes: true,
1933
+ })) {
1934
+ if (entry.isFile() && tokenMetadataPattern.test(entry.name)) {
1935
+ const filePath = plugins.path.join(this.baseDirectory, entry.name);
1936
+ await readPrivateJson(filePath);
1937
+ tokenMetadataFileNames.push(entry.name);
1938
+ continue;
1939
+ }
1940
+ if (entry.isFile() && acknowledgementTemporaryPattern.test(entry.name)) {
1941
+ await readPrivateJson(plugins.path.join(this.baseDirectory, entry.name));
1942
+ tokenMetadataFileNames.push(entry.name);
1943
+ continue;
1944
+ }
1945
+ if (entry.isFile() && transactionAdoptionAuditPattern.test(entry.name)) {
1946
+ parseUpgradeTransactionAdoptionAudit(
1947
+ await readPrivateJson(plugins.path.join(this.baseDirectory, entry.name)),
1948
+ );
1949
+ continue;
1950
+ }
1496
1951
  if (
1497
- !stats.isFile()
1498
- || stats.isSymbolicLink()
1499
- || stats.uid !== this.uid
1500
- || (stats.mode & 0o077) !== 0
1501
- ) throw new Error(`Upgrade transaction metadata is unsafe: ${filePath}`);
1502
- if (Date.now() - stats.mtimeMs > transactionRetentionMs) {
1503
- const transaction = parseUpgradeTransaction(await readPrivateJson(filePath));
1504
- const workerIsLive = await this.transactionWorkerIsLive(transaction).catch(() => true);
1505
- const controllerIsLive = transaction.controller
1506
- ? await this.transactionControllerIsLive(transaction).catch(() => true)
1507
- : false;
1508
- if (transaction.terminal || (!workerIsLive && !controllerIsLive)) {
1509
- await plugins.fs.promises.unlink(filePath).catch((errorArg) => {
1510
- if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg;
1511
- });
1952
+ entry.name.startsWith('grant-')
1953
+ || entry.name.startsWith('ack-')
1954
+ || entry.name.startsWith('action-')
1955
+ || entry.name.startsWith('adoption-')
1956
+ ) throw new Error(`Upgrade coordination metadata has an invalid name: ${entry.name}`);
1957
+ }
1958
+
1959
+ const transactionsStats = await lstatIfPresent(this.transactionsDirectory);
1960
+ if (!transactionsStats) {
1961
+ return { transactions: [], tokenMetadataFileNames, mutationDirectoryNames: [] };
1962
+ }
1963
+ await assertPrivateDirectory(this.transactionsDirectory);
1964
+ const transactions: IUpgradeTransactionInventoryEntry[] = [];
1965
+ const mutationDirectoryNames: string[] = [];
1966
+ for (const entry of await plugins.fs.promises.readdir(this.transactionsDirectory, {
1967
+ withFileTypes: true,
1968
+ })) {
1969
+ if (entry.isDirectory() && transactionMutationDirectoryPattern.test(entry.name)) {
1970
+ mutationDirectoryNames.push(entry.name);
1971
+ continue;
1972
+ }
1973
+ if (entry.isFile() && transactionTemporaryMetadataPattern.test(entry.name)) continue;
1974
+ const active = entry.isFile() && transactionMetadataPattern.test(entry.name);
1975
+ const adoptionMatch = entry.isFile() ? transactionAdoptionPattern.exec(entry.name) : null;
1976
+ if (!active && !adoptionMatch) {
1977
+ if (entry.name.startsWith('transaction-')) {
1978
+ throw new Error(`Upgrade transaction metadata has an invalid name: ${entry.name}`);
1512
1979
  }
1980
+ continue;
1981
+ }
1982
+ const filePath = plugins.path.join(this.transactionsDirectory, entry.name);
1983
+ const before = await lstatIfPresent(filePath);
1984
+ if (!before) continue;
1985
+ if (
1986
+ !before.isFile()
1987
+ || before.isSymbolicLink()
1988
+ || before.uid !== this.uid
1989
+ || (before.mode & 0o077) !== 0
1990
+ ) throw new Error(`Upgrade transaction metadata is unsafe: ${filePath}`);
1991
+ const transaction = parseUpgradeTransaction(await readPrivateJson(filePath));
1992
+ const after = await lstatIfPresent(filePath);
1993
+ if (
1994
+ !after
1995
+ || !sameOwnedDirectoryIdentity(after, { dev: before.dev, ino: before.ino })
1996
+ || after.size !== before.size
1997
+ || after.mtimeMs !== before.mtimeMs
1998
+ ) throw new Error('Upgrade transaction metadata changed during inventory.');
1999
+ const tokenPrefix = transaction.tokenHash.slice(0, 20);
2000
+ if (active && entry.name !== `transaction-${tokenPrefix}.json`) {
2001
+ throw new Error('Upgrade transaction filename and token binding do not match.');
2002
+ }
2003
+ if (adoptionMatch && tokenPrefix !== adoptionMatch[1] && tokenPrefix !== adoptionMatch[2]) {
2004
+ throw new Error('Upgrade transaction adoption state has an invalid token binding.');
1513
2005
  }
2006
+ transactions.push({
2007
+ fileName: entry.name,
2008
+ state: active ? 'active' : 'adopting',
2009
+ transaction,
2010
+ });
1514
2011
  }
2012
+ return {
2013
+ transactions: transactions.sort((leftArg, rightArg) => (
2014
+ leftArg.transaction.createdAt - rightArg.transaction.createdAt
2015
+ || leftArg.fileName.localeCompare(rightArg.fileName)
2016
+ )),
2017
+ tokenMetadataFileNames: tokenMetadataFileNames.sort(),
2018
+ mutationDirectoryNames: mutationDirectoryNames.sort(),
2019
+ };
1515
2020
  }
1516
2021
 
1517
2022
  private ownershipGuardDirectory(directoryArg: string): string {
@@ -2207,14 +2712,24 @@ export class UpgradeCoordinator {
2207
2712
  await this.withOwnershipMutationGuard(this.upgradeLockDirectory, async () => {
2208
2713
  await optionsArg.lock.assertOwned();
2209
2714
  const ownerPath = plugins.path.join(this.upgradeLockDirectory, ownerFileName);
2210
- const temporaryPath = `${ownerPath}.transfer-${process.pid}-${plugins.crypto.randomBytes(4).toString('hex')}`;
2715
+ const temporaryPath = plugins.path.join(
2716
+ this.baseDirectory,
2717
+ `owner-transfer-${process.pid}-${plugins.crypto.randomBytes(4).toString('hex')}.json.tmp`,
2718
+ );
2211
2719
  try {
2212
2720
  await writePrivateJson(temporaryPath, owner);
2213
2721
  await plugins.fs.promises.rename(temporaryPath, ownerPath);
2214
2722
  await syncDirectory(this.upgradeLockDirectory);
2723
+ await syncDirectory(this.baseDirectory);
2215
2724
  optionsArg.lock.relinquishAfterTransfer();
2216
2725
  } finally {
2217
- await plugins.fs.promises.unlink(temporaryPath).catch(() => undefined);
2726
+ let removed = false;
2727
+ await plugins.fs.promises.unlink(temporaryPath).then(() => {
2728
+ removed = true;
2729
+ }).catch((errorArg) => {
2730
+ if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg;
2731
+ });
2732
+ if (removed) await syncDirectory(this.baseDirectory);
2218
2733
  }
2219
2734
  });
2220
2735
  }
@@ -2456,10 +2971,263 @@ export class UpgradeCoordinator {
2456
2971
  return result as T;
2457
2972
  }
2458
2973
 
2974
+ private async cleanupUncommittedAdoptionAuditsUnderLock(
2975
+ lockArg: UpgradeInstallationLock,
2976
+ ): Promise<void> {
2977
+ await lockArg.assertOwned();
2978
+ for (const entry of await plugins.fs.promises.readdir(this.baseDirectory, {
2979
+ withFileTypes: true,
2980
+ })) {
2981
+ const match = entry.isFile() ? transactionAdoptionAuditPattern.exec(entry.name) : null;
2982
+ if (!match) continue;
2983
+ const auditPath = plugins.path.join(this.baseDirectory, entry.name);
2984
+ const audit = parseUpgradeTransactionAdoptionAudit(await readPrivateJson(auditPath));
2985
+ const sourcePath = plugins.path.join(
2986
+ this.transactionsDirectory,
2987
+ `transaction-${match[1]}.json`,
2988
+ );
2989
+ const intermediatePath = `${sourcePath}.adopting-${match[2]}`;
2990
+ const targetPath = plugins.path.join(
2991
+ this.transactionsDirectory,
2992
+ `transaction-${match[2]}.json`,
2993
+ );
2994
+ await this.withOwnershipMutationGuard(sourcePath, async () => {
2995
+ await lockArg.assertOwned();
2996
+ const [sourceStats, intermediateStats, targetStats] = await Promise.all([
2997
+ lstatIfPresent(sourcePath),
2998
+ lstatIfPresent(intermediatePath),
2999
+ lstatIfPresent(targetPath),
3000
+ ]);
3001
+ if (!sourceStats || intermediateStats || targetStats) return;
3002
+ const source = parseUpgradeTransaction(await readPrivateJson(sourcePath));
3003
+ if (!plugins.util.isDeepStrictEqual(source, audit.previousTransaction)) return;
3004
+ await plugins.fs.promises.unlink(auditPath);
3005
+ await syncDirectory(this.baseDirectory);
3006
+ });
3007
+ }
3008
+ await lockArg.assertOwned();
3009
+ }
3010
+
3011
+ public async adoptOrphanedTransaction(
3012
+ optionsArg: IAdoptOrphanedUpgradeTransactionOptions,
3013
+ ): Promise<TUpgradeTransaction> {
3014
+ await optionsArg.lock.assertOwned();
3015
+ await this.waitForStartLeasesToDrain(optionsArg.lock);
3016
+ await this.cleanupUncommittedAdoptionAuditsUnderLock(optionsArg.lock);
3017
+ const inventory = await this.inspectCanonicalTransactionInventory();
3018
+ const nonterminal = inventory.transactions.filter((entryArg) => !entryArg.transaction.terminal);
3019
+ if (nonterminal.length !== 1) {
3020
+ throw new Error('Exactly one orphaned upgrade transaction is required for adoption.');
3021
+ }
3022
+ const entry = nonterminal[0];
3023
+ const transaction = entry.transaction;
3024
+ const eligibleV2 = transaction.version === coordinationVersion
3025
+ && transaction.controllerWasRunning === true
3026
+ && transaction.targetStartupInvoked === true
3027
+ && transaction.targetVersion !== undefined
3028
+ && transaction.preparationCompletedAt !== undefined
3029
+ && (transaction.phase === 'restarting' || transaction.phase === 'continuing');
3030
+ const eligibleV3 = transaction.version === upgradePackageTransitionTransactionVersion
3031
+ && transaction.targetPackageCommitStarted === true
3032
+ && (!transaction.controllerWasRunning || transaction.preparationCompletedAt !== undefined)
3033
+ && (
3034
+ transaction.phase === 'installing'
3035
+ || transaction.phase === 'restarting'
3036
+ || transaction.phase === 'continuing'
3037
+ );
3038
+ if (
3039
+ transaction.port !== optionsArg.port
3040
+ || transaction.tokenHash !== optionsArg.expectedTokenHash
3041
+ || transaction.revision !== optionsArg.expectedRevision
3042
+ || (!eligibleV2 && !eligibleV3)
3043
+ || !upgradeTransactionIsStalled(transaction)
3044
+ ) throw new Error('The retained upgrade transaction is not eligible for adoption.');
3045
+ if (transaction.version === upgradePackageTransitionTransactionVersion && optionsArg.registryUrl) {
3046
+ throw new Error(
3047
+ 'Legacy package-transition recovery uses pnpm registry configuration and cannot retain --registry.',
3048
+ );
3049
+ }
3050
+ if (await this.transactionWorkerIsLive(transaction)) {
3051
+ throw new Error('The retained upgrade worker is still running.');
3052
+ }
3053
+ if (transaction.controller && await this.transactionControllerIsLive(transaction)) {
3054
+ throw new Error('The retained upgrade controller is still running.');
3055
+ }
3056
+
3057
+ const adoptionMatch = transactionAdoptionPattern.exec(entry.fileName);
3058
+ const tokenPrefixes = new Set([
3059
+ transaction.tokenHash.slice(0, 20),
3060
+ ...(adoptionMatch ? [adoptionMatch[1], adoptionMatch[2]] : []),
3061
+ ]);
3062
+ for (const fileName of inventory.tokenMetadataFileNames) {
3063
+ if ([...tokenPrefixes].some((prefixArg) => fileName.includes(`-${prefixArg}.json`))) {
3064
+ throw new Error('The orphaned upgrade retains token-bound coordination metadata.');
3065
+ }
3066
+ }
3067
+ for (const directoryName of inventory.mutationDirectoryNames) {
3068
+ const prefix = [...tokenPrefixes].find((candidateArg) => (
3069
+ directoryName === `transaction-${candidateArg}.json.lock`
3070
+ ));
3071
+ if (!prefix) continue;
3072
+ const directory = plugins.path.join(this.transactionsDirectory, directoryName);
3073
+ const state = await this.inspectTransactionMutationDirectory(
3074
+ directory,
3075
+ transaction.tokenHash,
3076
+ );
3077
+ if (state.state === 'stale') {
3078
+ await this.reclaimStaleTransactionMutationDirectory(directory, transaction.tokenHash);
3079
+ continue;
3080
+ }
3081
+ if (state.state !== 'absent') {
3082
+ throw new Error('The orphaned upgrade transaction still has mutation ownership.');
3083
+ }
3084
+ }
3085
+
3086
+ const installedVersion = assertBoundedText(
3087
+ optionsArg.installedVersion,
3088
+ 'The installed upgrade version',
3089
+ 128,
3090
+ );
3091
+ compareUpgradeSemver(installedVersion, installedVersion);
3092
+ if (
3093
+ transaction.version === upgradePackageTransitionTransactionVersion
3094
+ && compareUpgradeSemver(
3095
+ installedVersion,
3096
+ upgradeTransactionTargetVersion(transaction)!,
3097
+ ) < 0
3098
+ ) throw new Error('The installed package-transition recovery target would downgrade AGL.');
3099
+ const registryUrl = transaction.version === coordinationVersion
3100
+ ? transaction.registryUrl ?? optionsArg.registryUrl
3101
+ : undefined;
3102
+ if (
3103
+ transaction.version === coordinationVersion
3104
+ && transaction.registryUrl !== undefined
3105
+ && optionsArg.registryUrl !== undefined
3106
+ && transaction.registryUrl !== optionsArg.registryUrl
3107
+ ) throw new Error('The orphaned upgrade registry cannot change during adoption.');
3108
+ const newTokenHash = hashToken(optionsArg.token);
3109
+ const sourcePath = plugins.path.join(this.transactionsDirectory, entry.fileName);
3110
+ const sourcePrefix = transaction.tokenHash.slice(0, 20);
3111
+ const targetPrefix = newTokenHash.slice(0, 20);
3112
+ if (sourcePrefix === targetPrefix) {
3113
+ throw new Error('The adopted upgrade token collided with the retained token.');
3114
+ }
3115
+ const intermediatePath = plugins.path.join(
3116
+ this.transactionsDirectory,
3117
+ `transaction-${sourcePrefix}.json.adopting-${targetPrefix}`,
3118
+ );
3119
+ const targetPath = plugins.path.join(
3120
+ this.transactionsDirectory,
3121
+ `transaction-${targetPrefix}.json`,
3122
+ );
3123
+ const auditPath = plugins.path.join(
3124
+ this.baseDirectory,
3125
+ `adoption-${sourcePrefix}-${targetPrefix}.json`,
3126
+ );
3127
+ const auditTemporaryPath = `${auditPath}.tmp-${process.pid}-${plugins.crypto.randomBytes(4).toString('hex')}`;
3128
+ for (const path of [intermediatePath, targetPath, auditPath, auditTemporaryPath]) {
3129
+ if (await lstatIfPresent(path)) {
3130
+ throw new Error(`Upgrade transaction adoption destination already exists: ${path}`);
3131
+ }
3132
+ }
3133
+ const now = Date.now();
3134
+ const {
3135
+ worker: _worker,
3136
+ controller: _controller,
3137
+ retainedUntil: _retainedUntil,
3138
+ ...retained
3139
+ } = transaction;
3140
+ const adopted = transaction.version === coordinationVersion
3141
+ ? parseUpgradeTransactionV2({
3142
+ ...retained,
3143
+ tokenHash: newTokenHash,
3144
+ revision: transaction.revision + 1,
3145
+ targetVersion: installedVersion,
3146
+ ...(registryUrl === undefined ? {} : { registryUrl }),
3147
+ phaseStartedAt: now,
3148
+ updatedAt: now,
3149
+ message: `Recovering orphaned upgrade ${transaction.sourceVersion} -> ${transaction.targetVersion} with installed ${installedVersion}.`,
3150
+ })
3151
+ : parseUpgradeTransactionV3({
3152
+ ...retained,
3153
+ tokenHash: newTokenHash,
3154
+ revision: transaction.revision + 1,
3155
+ ...(installedVersion === transaction.targetVersion
3156
+ ? {}
3157
+ : { recoveryTargetVersion: installedVersion }),
3158
+ phaseStartedAt: now,
3159
+ updatedAt: now,
3160
+ message: `Recovering orphaned package transition to installed ${transaction.targetPackageName}@${installedVersion}.`,
3161
+ });
3162
+ const audit = parseUpgradeTransactionAdoptionAudit({
3163
+ version: 1,
3164
+ adoptedAt: now,
3165
+ adoptedTargetVersion: installedVersion,
3166
+ previousTransaction: transaction,
3167
+ });
3168
+ const temporaryPath = `${targetPath}.tmp-${process.pid}-${plugins.crypto.randomBytes(4).toString('hex')}`;
3169
+
3170
+ await optionsArg.lock.assertOwned();
3171
+ try {
3172
+ await this.withOwnershipMutationGuard(sourcePath, async () => {
3173
+ await this.withOwnershipMutationGuard(intermediatePath, async () => {
3174
+ await this.withOwnershipMutationGuard(targetPath, async () => {
3175
+ const sourceStats = await lstatIfPresent(sourcePath);
3176
+ if (!sourceStats) throw new Error('The orphaned upgrade transaction disappeared.');
3177
+ const current = parseUpgradeTransaction(await readPrivateJson(sourcePath));
3178
+ if (
3179
+ current.tokenHash !== transaction.tokenHash
3180
+ || current.revision !== transaction.revision
3181
+ ) throw new Error('The orphaned upgrade transaction changed before adoption.');
3182
+ try {
3183
+ await writePrivateJson(auditTemporaryPath, audit);
3184
+ await plugins.fs.promises.rename(auditTemporaryPath, auditPath);
3185
+ await syncDirectory(this.baseDirectory);
3186
+ await plugins.fs.promises.rename(sourcePath, intermediatePath);
3187
+ await syncDirectory(this.transactionsDirectory);
3188
+ await writePrivateJson(temporaryPath, adopted);
3189
+ const intermediateStats = await lstatIfPresent(intermediatePath);
3190
+ if (
3191
+ !intermediateStats
3192
+ || !sameOwnedDirectoryIdentity(intermediateStats, {
3193
+ dev: sourceStats.dev,
3194
+ ino: sourceStats.ino,
3195
+ })
3196
+ ) throw new Error('The upgrade adoption source identity changed unexpectedly.');
3197
+ await plugins.fs.promises.rename(temporaryPath, intermediatePath);
3198
+ await syncDirectory(this.transactionsDirectory);
3199
+ await plugins.fs.promises.rename(intermediatePath, targetPath);
3200
+ await syncDirectory(this.transactionsDirectory);
3201
+ } finally {
3202
+ await plugins.fs.promises.unlink(auditTemporaryPath).catch(() => undefined);
3203
+ await plugins.fs.promises.unlink(temporaryPath).catch(() => undefined);
3204
+ }
3205
+ });
3206
+ });
3207
+ });
3208
+ } catch (errorArg) {
3209
+ const [sourceStats, intermediateStats, targetStats] = await Promise.all([
3210
+ lstatIfPresent(sourcePath),
3211
+ lstatIfPresent(intermediatePath),
3212
+ lstatIfPresent(targetPath),
3213
+ ]);
3214
+ if (sourceStats && !intermediateStats && !targetStats) {
3215
+ await plugins.fs.promises.unlink(auditPath).catch((cleanupErrorArg) => {
3216
+ if ((cleanupErrorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw cleanupErrorArg;
3217
+ });
3218
+ await syncDirectory(this.baseDirectory);
3219
+ }
3220
+ throw errorArg;
3221
+ }
3222
+ await optionsArg.lock.assertOwned();
3223
+ return parseUpgradeTransaction(await readPrivateJson(targetPath));
3224
+ }
3225
+
2459
3226
  public async createTransaction(optionsArg: {
2460
3227
  token: string;
2461
3228
  port: number;
2462
3229
  sourceVersion: string;
3230
+ registryUrl?: string;
2463
3231
  controllerWasRunning: boolean;
2464
3232
  continueSessions: boolean;
2465
3233
  gracePeriodMs: number;
@@ -2472,6 +3240,9 @@ export class UpgradeCoordinator {
2472
3240
  revision: 0,
2473
3241
  port: assertPort(optionsArg.port),
2474
3242
  sourceVersion: assertBoundedText(optionsArg.sourceVersion, 'The upgrade source version', 128),
3243
+ ...(optionsArg.registryUrl === undefined
3244
+ ? {}
3245
+ : { registryUrl: normalizeUpgradeRegistryUrl(optionsArg.registryUrl) }),
2475
3246
  controllerWasRunning: optionsArg.controllerWasRunning,
2476
3247
  continueSessions: optionsArg.continueSessions,
2477
3248
  gracePeriodMs: optionsArg.gracePeriodMs,
@@ -2602,7 +3373,15 @@ export class UpgradeCoordinator {
2602
3373
  throw new Error(`The upgrade package-transition checkpoint ${checkpoint} cannot regress.`);
2603
3374
  }
2604
3375
  }
3376
+ if (currentArg.recoveryTargetVersion !== next.recoveryTargetVersion) {
3377
+ throw new Error('The package-transition recovery target cannot change after adoption.');
3378
+ }
2605
3379
  }
3380
+ if (
3381
+ currentArg.version === coordinationVersion
3382
+ && next.version === coordinationVersion
3383
+ && currentArg.registryUrl !== next.registryUrl
3384
+ ) throw new Error('The upgrade registry cannot change after the transaction is created.');
2606
3385
  if (
2607
3386
  (currentArg.preparationAcceptedAt !== undefined
2608
3387
  && next.preparationAcceptedAt !== currentArg.preparationAcceptedAt)
@@ -2639,7 +3418,7 @@ export class UpgradeCoordinator {
2639
3418
  ...(current.worker?.logFilePath ? { logFilePath: current.worker.logFilePath } : {}),
2640
3419
  },
2641
3420
  phase: 'checking',
2642
- message: 'Checking the npm latest version.',
3421
+ message: 'Checking the registry latest version.',
2643
3422
  }));
2644
3423
  }
2645
3424
 
@@ -2744,44 +3523,7 @@ export class UpgradeCoordinator {
2744
3523
  public async terminateUpgradeWorkerCandidate(
2745
3524
  worker: NonNullable<TUpgradeTransaction['worker']>,
2746
3525
  ): Promise<void> {
2747
- const identity = await readControllerProcessIdentity(worker.pid);
2748
- if (identity) {
2749
- if (
2750
- identity.fingerprint !== worker.fingerprint
2751
- || identity.processGroupId !== worker.processGroupId
2752
- || !identity.processGroupLeader
2753
- || !await processIdentityHasCliCommand(
2754
- identity,
2755
- worker.cliPath,
2756
- '__upgrade-worker',
2757
- { allowMissingAbsolutePath: true },
2758
- )
2759
- ) throw new Error('The stalled upgrade worker identity changed unexpectedly.');
2760
- } else if (
2761
- worker.processGroupId !== worker.pid
2762
- || (await readProcessGroupMemberPids(worker.processGroupId)).length === 0
2763
- ) {
2764
- return;
2765
- }
2766
- const signalOwnedWorker = (signalArg: NodeJS.Signals): void => {
2767
- try {
2768
- process.kill(-worker.processGroupId, signalArg);
2769
- } catch (errorArg) {
2770
- if ((errorArg as NodeJS.ErrnoException).code !== 'ESRCH') throw errorArg;
2771
- }
2772
- };
2773
- const waitForDrain = async (): Promise<boolean> => {
2774
- const deadline = Date.now() + workerTerminationGraceMs;
2775
- while (Date.now() < deadline) {
2776
- if ((await readProcessGroupMemberPids(worker.processGroupId)).length === 0) return true;
2777
- await new Promise((resolve) => setTimeout(resolve, 50));
2778
- }
2779
- return (await readProcessGroupMemberPids(worker.processGroupId)).length === 0;
2780
- };
2781
- signalOwnedWorker('SIGTERM');
2782
- if (await waitForDrain()) return;
2783
- signalOwnedWorker('SIGKILL');
2784
- if (!await waitForDrain()) throw new Error('The stalled upgrade worker group did not terminate.');
3526
+ await terminateVerifiedUpgradeWorkerProcessGroup(worker);
2785
3527
  }
2786
3528
 
2787
3529
  public async readSanitizedUpgradeStatus(
@@ -2797,7 +3539,7 @@ export class UpgradeCoordinator {
2797
3539
  : transaction.phase;
2798
3540
  return {
2799
3541
  fromVersion: transaction.sourceVersion,
2800
- toVersion: transaction.targetVersion,
3542
+ toVersion: upgradeTransactionTargetVersion(transaction)!,
2801
3543
  phase,
2802
3544
  };
2803
3545
  }
@@ -3022,26 +3764,81 @@ export class UpgradeCoordinator {
3022
3764
  }
3023
3765
  }
3024
3766
 
3025
- public async acknowledgeWorker(tokenArg: string): Promise<void> {
3767
+ public async removeWorkerAcknowledgement(tokenArg: string): Promise<void> {
3768
+ await this.init();
3769
+ let removed = false;
3770
+ await plugins.fs.promises.unlink(this.tokenFilePath('ack', tokenArg)).then(() => {
3771
+ removed = true;
3772
+ }).catch((errorArg) => {
3773
+ if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') throw errorArg;
3774
+ });
3775
+ if (removed) await syncDirectory(this.baseDirectory);
3776
+ }
3777
+
3778
+ public async acknowledgeWorker(
3779
+ tokenArg: string,
3780
+ workerArg: Pick<IVerifiedUpgradeWorkerProcessGroup, 'pid' | 'processGroupId' | 'fingerprint'>,
3781
+ ): Promise<void> {
3026
3782
  await this.init();
3027
- const acknowledgement = {
3783
+ const identity = await readControllerProcessIdentity(workerArg.pid);
3784
+ if (
3785
+ !identity
3786
+ || identity.fingerprint !== workerArg.fingerprint
3787
+ || identity.processGroupId !== workerArg.processGroupId
3788
+ ) throw new Error('The upgrade acknowledgement worker identity is no longer current.');
3789
+ const acknowledgement: IUpgradeWorkerAcknowledgement = {
3028
3790
  version: coordinationVersion,
3029
3791
  tokenHash: hashToken(tokenArg),
3792
+ pid: identity.pid,
3793
+ processGroupId: identity.processGroupId,
3794
+ fingerprint: identity.fingerprint,
3030
3795
  };
3031
3796
  const acknowledgementPath = this.tokenFilePath('ack', tokenArg);
3797
+ const temporaryPath = `${acknowledgementPath}.tmp-${process.pid}-${plugins.crypto.randomBytes(4).toString('hex')}`;
3798
+ let published = false;
3032
3799
  try {
3033
- await writePrivateJson(acknowledgementPath, acknowledgement);
3800
+ await writePrivateJson(temporaryPath, acknowledgement);
3801
+ try {
3802
+ await plugins.fs.promises.link(temporaryPath, acknowledgementPath);
3803
+ published = true;
3804
+ await syncDirectory(this.baseDirectory);
3805
+ } catch (errorArg) {
3806
+ if ((errorArg as NodeJS.ErrnoException).code !== 'EEXIST') {
3807
+ if (published) return;
3808
+ throw errorArg;
3809
+ }
3810
+ let existing: unknown;
3811
+ try {
3812
+ existing = await readPrivateJson(acknowledgementPath);
3813
+ } catch (readErrorArg) {
3814
+ if ((readErrorArg as NodeJS.ErrnoException).code === 'ENOENT') {
3815
+ published = true;
3816
+ return;
3817
+ }
3818
+ throw readErrorArg;
3819
+ }
3820
+ if (
3821
+ !existing
3822
+ || typeof existing !== 'object'
3823
+ || Array.isArray(existing)
3824
+ || !exactKeys(existing as Record<string, unknown>, [
3825
+ 'version', 'tokenHash', 'pid', 'processGroupId', 'fingerprint',
3826
+ ])
3827
+ || (existing as Record<string, unknown>).version !== coordinationVersion
3828
+ || (existing as Record<string, unknown>).tokenHash !== acknowledgement.tokenHash
3829
+ || (existing as Record<string, unknown>).pid !== acknowledgement.pid
3830
+ || (existing as Record<string, unknown>).processGroupId !== acknowledgement.processGroupId
3831
+ || (existing as Record<string, unknown>).fingerprint !== acknowledgement.fingerprint
3832
+ ) throw new Error('The existing upgrade acknowledgement is invalid.');
3833
+ published = true;
3834
+ }
3034
3835
  } catch (errorArg) {
3035
- if ((errorArg as NodeJS.ErrnoException).code !== 'EEXIST') throw errorArg;
3036
- const existing = await readPrivateJson(acknowledgementPath);
3037
- if (
3038
- !existing
3039
- || typeof existing !== 'object'
3040
- || Array.isArray(existing)
3041
- || !exactKeys(existing as Record<string, unknown>, ['version', 'tokenHash'])
3042
- || (existing as Record<string, unknown>).version !== coordinationVersion
3043
- || (existing as Record<string, unknown>).tokenHash !== acknowledgement.tokenHash
3044
- ) throw new Error('The existing upgrade acknowledgement is invalid.');
3836
+ if (published) return;
3837
+ throw errorArg;
3838
+ } finally {
3839
+ await plugins.fs.promises.unlink(temporaryPath).catch((errorArg) => {
3840
+ if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT' && !published) throw errorArg;
3841
+ });
3045
3842
  }
3046
3843
  }
3047
3844
 
@@ -3050,6 +3847,8 @@ export class UpgradeCoordinator {
3050
3847
  timeoutMsArg = 10_000,
3051
3848
  ): Promise<void> {
3052
3849
  const token = assertToken(tokenArg);
3850
+ const identity = await readControllerProcessIdentity(process.pid);
3851
+ if (!identity) throw new Error('The upgrade worker acknowledgement identity is unavailable.');
3053
3852
  const ackPath = this.tokenFilePath('ack', token);
3054
3853
  const deadline = Date.now() + timeoutMsArg;
3055
3854
  while (Date.now() < deadline) {
@@ -3060,11 +3859,14 @@ export class UpgradeCoordinator {
3060
3859
  }
3061
3860
  const ack = value as Record<string, unknown>;
3062
3861
  if (
3063
- !exactKeys(ack, ['version', 'tokenHash'])
3862
+ !exactKeys(ack, ['version', 'tokenHash', 'pid', 'processGroupId', 'fingerprint'])
3064
3863
  || ack.version !== coordinationVersion
3065
3864
  || typeof ack.tokenHash !== 'string'
3066
3865
  || !upgradeTokenHashPattern.test(ack.tokenHash)
3067
3866
  || !tokensEqual(ack.tokenHash, hashToken(token))
3867
+ || ack.pid !== identity.pid
3868
+ || ack.processGroupId !== identity.processGroupId
3869
+ || ack.fingerprint !== identity.fingerprint
3068
3870
  ) {
3069
3871
  throw new Error(`The ${currentCliName} upgrade acknowledgement binding is invalid.`);
3070
3872
  }