@tinycloud/operations 0.6.1-beta.4 → 0.6.1-beta.5

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.
@@ -431,14 +431,18 @@ var TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIE
431
431
  var TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
432
432
  var TEST_LOCK_LIVENESS_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_LIVENESS_BARRIER_DIR";
433
433
  function processWideContext(key) {
434
- var _a3;
434
+ var _a4;
435
435
  const registry = globalThis;
436
- return registry[_a3 = Symbol.for(key)] ?? (registry[_a3] = new import_node_async_hooks.AsyncLocalStorage());
436
+ return registry[_a4 = Symbol.for(key)] ?? (registry[_a4] = new import_node_async_hooks.AsyncLocalStorage());
437
437
  }
438
438
  var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
439
439
  var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
440
+ var pinnedProfiles = processWideContext("tinycloud.operations.pinnedProfiles.v2");
441
+ var pinnedHomes = processWideContext("tinycloud.operations.pinnedHomes.v2");
440
442
  var _a;
441
- var processInstanceId = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.profileLockInstance.v1")] ?? (globalThis[_a] = (0, import_node_crypto.randomUUID)());
443
+ var canonicalHomes = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.canonicalHomes.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Map());
444
+ var _a2;
445
+ var processInstanceId = globalThis[_a2 = /* @__PURE__ */ Symbol.for("tinycloud.operations.profileLockInstance.v1")] ?? (globalThis[_a2] = (0, import_node_crypto.randomUUID)());
442
446
  var cachedProcessOrigin;
443
447
  function processOrigin() {
444
448
  if (cachedProcessOrigin !== void 0) return cachedProcessOrigin;
@@ -616,9 +620,14 @@ function tinycloudHomePath() {
616
620
  function profilesPath() {
617
621
  return (0, import_node_path.join)(tinycloudHomePath(), "profiles");
618
622
  }
619
- function profilePath(profile) {
623
+ function namedProfilePath(profile) {
620
624
  return (0, import_node_path.join)(profilesPath(), validateProfileName(profile));
621
625
  }
626
+ function profilePath(profile) {
627
+ const named = namedProfilePath(profile);
628
+ const pinned = pinnedProfiles.getStore()?.get(named);
629
+ return pinned ? livePinnedPath(profile, pinned) : named;
630
+ }
622
631
  function profileConfigPath(profile) {
623
632
  return (0, import_node_path.join)(profilePath(profile), "profile.json");
624
633
  }
@@ -629,10 +638,12 @@ function profileStoreMetadataPath(profile, store) {
629
638
  return `${profileStorePath(profile, store)}.metadata.json`;
630
639
  }
631
640
  function profileLockPath(profile) {
632
- return (0, import_node_path.join)(profilePath(profile), ".lock");
641
+ return (0, import_node_path.join)(namedProfilePath(profile), ".lock");
633
642
  }
634
643
  function profileTurnLockPath(profile) {
635
- return (0, import_node_path.join)(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
644
+ const home = tinycloudHomePath();
645
+ const pinned = pinnedHomes.getStore()?.get(home);
646
+ return (0, import_node_path.join)(pinned ? livePinnedPath(profile, pinned) : home, "profile-locks", validateProfileName(profile));
636
647
  }
637
648
  async function readJson(filePath) {
638
649
  try {
@@ -642,6 +653,44 @@ async function readJson(filePath) {
642
653
  throw error;
643
654
  }
644
655
  }
656
+ async function readProfileJson(profile, filePath) {
657
+ const before = process.platform !== "linux" ? await (0, import_promises.lstat)(filePath, { bigint: true }).catch((error) => {
658
+ if (error.code === "ENOENT") return null;
659
+ throw error;
660
+ }) : null;
661
+ if (process.platform !== "linux" && !before) return null;
662
+ if (before && !before.isFile()) throw new ProfileSymlinkError(profile);
663
+ const dev = before && exactFileId(before.dev);
664
+ const ino = before && exactFileId(before.ino);
665
+ try {
666
+ const handle = await (0, import_promises.open)(filePath, import_node_fs.constants.O_RDONLY | (import_node_fs.constants.O_NOFOLLOW ?? 0));
667
+ try {
668
+ if (before) {
669
+ const opened = await handle.stat({ bigint: true });
670
+ if (!opened.isFile() || dev === null || ino === null || dev !== exactFileId(opened.dev) || ino !== exactFileId(opened.ino)) {
671
+ throw new ProfileSymlinkError(profile, "changed while opening its file");
672
+ }
673
+ }
674
+ const contents = await handle.readFile("utf8");
675
+ if (before) {
676
+ const named = await (0, import_promises.lstat)(filePath, { bigint: true }).catch((error) => {
677
+ if (error.code === "ENOENT") return null;
678
+ throw error;
679
+ });
680
+ if (!named?.isFile() || dev !== exactFileId(named.dev) || ino !== exactFileId(named.ino)) {
681
+ throw new ProfileSymlinkError(profile, "changed while reading its file");
682
+ }
683
+ }
684
+ return JSON.parse(contents);
685
+ } finally {
686
+ await handle.close();
687
+ }
688
+ } catch (error) {
689
+ if (isErrno(error, "ENOENT")) return null;
690
+ if (isErrno(error, "ELOOP") || isErrno(error, "ENOTDIR")) throw new ProfileSymlinkError(profile);
691
+ throw error;
692
+ }
693
+ }
645
694
  async function writeJsonAtomic(filePath, value) {
646
695
  await replaceJson(filePath, value, false);
647
696
  }
@@ -678,43 +727,194 @@ async function readAuthRequests(profile) {
678
727
  return (await readProfileStore(profile, "auth-requests")).records;
679
728
  }
680
729
  async function readStoreMetadata(profile, store) {
681
- const metadata = await readJson(profileStoreMetadataPath(profile, store));
682
- if (metadata === null) return { formatVersion: 1 };
683
- if (typeof metadata !== "object" || metadata.formatVersion !== 1) {
684
- throw new TypeError(`Unsupported store format for "${store}".`);
685
- }
686
- return metadata;
730
+ return withPinnedProfileDirectory(profile, async (directory) => {
731
+ const metadata = directory ? await readProfileJson(profile, profileStoreMetadataPath(profile, store)) : null;
732
+ if (metadata === null) return { formatVersion: 1 };
733
+ if (typeof metadata !== "object" || metadata.formatVersion !== 1) {
734
+ throw new TypeError(`Unsupported store format for "${store}".`);
735
+ }
736
+ return metadata;
737
+ });
687
738
  }
688
739
  async function readProfileStore(profile, store) {
689
- const [metadata, raw] = await Promise.all([
690
- readStoreMetadata(profile, store),
691
- readJson(profileStorePath(profile, store))
692
- ]);
693
- return {
694
- formatVersion: metadata.formatVersion,
695
- records: Array.isArray(raw) ? raw : []
696
- };
740
+ return withPinnedProfileDirectory(profile, async (directory) => {
741
+ if (!directory) return { formatVersion: 1, records: [] };
742
+ const [metadata, raw] = await Promise.all([
743
+ readStoreMetadata(profile, store),
744
+ readProfileJson(profile, profileStorePath(profile, store))
745
+ ]);
746
+ return {
747
+ formatVersion: metadata.formatVersion,
748
+ records: Array.isArray(raw) ? raw : []
749
+ };
750
+ });
697
751
  }
698
- async function withProfileLock(profile, action, options = {}) {
699
- const normalizedProfile = validateProfileName(profile);
700
- const lockPath = profileLockPath(normalizedProfile);
701
- const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
702
- if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
703
- const acquired = await acquireProfileLock(normalizedProfile, options);
704
- const ownership = {
705
- lockPath,
706
- turnSlot: acquired.turnSlot,
707
- deletedWhileWaiting: acquired.deletedWhileWaiting,
708
- active: true
709
- };
752
+ var ProfileSymlinkError = class extends Error {
753
+ constructor(profile, detail = "must be a real directory, not a symlink or another file type") {
754
+ super(`Profile "${profile}" ${detail}.`);
755
+ this.code = "PROFILE_SYMLINK";
756
+ this.name = "ProfileSymlinkError";
757
+ }
758
+ };
759
+ function exactFileId(value) {
760
+ if (typeof value === "bigint") return value >= 0n ? value : null;
761
+ return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
762
+ }
763
+ function isPinnedLive(pinned) {
764
+ if (!pinned.active) return false;
710
765
  try {
711
- return await heldProfileLocks.run([...held, ownership], action);
766
+ const stats = (0, import_node_fs.fstatSync)(pinned.fd, { bigint: true });
767
+ return exactFileId(stats.dev) === pinned.dev && exactFileId(stats.ino) === pinned.ino;
768
+ } catch {
769
+ return false;
770
+ }
771
+ }
772
+ function livePinnedPath(profile, pinned) {
773
+ if (isPinnedLive(pinned)) return pinned.path;
774
+ throw new ProfileSymlinkError(profile, "its pinned directory is no longer active or changed during use");
775
+ }
776
+ async function withPinnedProfileDirectory(profile, action, options = {}) {
777
+ const named = namedProfilePath(profile);
778
+ const existing = pinnedProfiles.getStore()?.get(named);
779
+ if (existing) return action(livePinnedPath(profile, existing));
780
+ if (process.platform !== "linux") {
781
+ if (options.create) await (0, import_promises.mkdir)(named, { recursive: true, mode: PRIVATE_DIR_MODE });
782
+ const stats = await (0, import_promises.lstat)(named).catch((error) => {
783
+ if (error.code === "ENOENT") return null;
784
+ throw error;
785
+ });
786
+ if (!stats) return action(null);
787
+ if (!stats.isDirectory() || stats.isSymbolicLink()) throw new ProfileSymlinkError(profile);
788
+ return action(named);
789
+ }
790
+ const home = tinycloudHomePath();
791
+ const handles = [];
792
+ const fdPath = (fd) => `/proc/self/fd/${fd}`;
793
+ const flags = import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_DIRECTORY | import_node_fs.constants.O_NOFOLLOW;
794
+ try {
795
+ if (options.create) await (0, import_promises.mkdir)(home, { recursive: true, mode: PRIVATE_DIR_MODE });
796
+ const resolved = canonicalHomes.get(home) ?? await (0, import_promises.realpath)(home).catch((error) => {
797
+ if (error.code === "ENOENT" && !options.create) return null;
798
+ if (error.code === "ELOOP" || error.code === "ENOTDIR") throw new ProfileSymlinkError(profile);
799
+ throw error;
800
+ });
801
+ if (!resolved) return await action(null);
802
+ const root = canonicalHomes.get(home) ?? resolved;
803
+ canonicalHomes.set(home, root);
804
+ const segments = [root, "profiles", validateProfileName(profile)];
805
+ for (let index = 0; index < segments.length; index++) {
806
+ const path = handles.length ? (0, import_node_path.join)(fdPath(handles[handles.length - 1].fd), segments[index]) : segments[index];
807
+ if (options.create && handles.length) {
808
+ await (0, import_promises.mkdir)(path, { mode: PRIVATE_DIR_MODE }).catch((error) => {
809
+ if (error.code !== "EEXIST") throw error;
810
+ });
811
+ }
812
+ const handle = await (0, import_promises.open)(path, index === 1 ? flags & ~import_node_fs.constants.O_NOFOLLOW : flags).catch((error) => {
813
+ if (error.code === "ENOENT" && !options.create) return null;
814
+ if (error.code === "ELOOP" || error.code === "ENOTDIR") throw new ProfileSymlinkError(profile);
815
+ throw error;
816
+ });
817
+ if (!handle) return await action(null);
818
+ handles.push(handle);
819
+ if (options.create) await handle.chmod(PRIVATE_DIR_MODE);
820
+ }
821
+ const opened = handles[2];
822
+ const [actual, namedStats] = await Promise.all([opened.stat({ bigint: true }), (0, import_promises.lstat)(named, { bigint: true })]);
823
+ const dev = exactFileId(actual.dev);
824
+ const ino = exactFileId(actual.ino);
825
+ if (!namedStats.isDirectory() || dev === null || ino === null || dev !== exactFileId(namedStats.dev) || ino !== exactFileId(namedStats.ino)) {
826
+ throw new ProfileSymlinkError(profile, "changed while opening its directory");
827
+ }
828
+ const directory = fdPath(opened.fd);
829
+ const pinned = {
830
+ path: directory,
831
+ fd: opened.fd,
832
+ dev,
833
+ ino,
834
+ active: true
835
+ };
836
+ const homeHandle = handles[0];
837
+ const homeStats = await homeHandle.stat({ bigint: true });
838
+ const homeDev = exactFileId(homeStats.dev);
839
+ const homeIno = exactFileId(homeStats.ino);
840
+ if (homeDev === null || homeIno === null) throw new ProfileSymlinkError(profile, "has an unverified home directory identity");
841
+ const homePinned = {
842
+ path: fdPath(homeHandle.fd),
843
+ fd: homeHandle.fd,
844
+ dev: homeDev,
845
+ ino: homeIno,
846
+ active: true
847
+ };
848
+ const context = new Map(pinnedProfiles.getStore());
849
+ context.set(named, pinned);
850
+ const homes = new Map(pinnedHomes.getStore());
851
+ homes.set(home, homePinned);
852
+ try {
853
+ return await pinnedHomes.run(homes, () => pinnedProfiles.run(context, () => action(directory)));
854
+ } finally {
855
+ pinned.active = false;
856
+ homePinned.active = false;
857
+ }
712
858
  } finally {
713
- ownership.active = false;
714
- await acquired.release();
715
- if (acquired.deletedWhileWaiting) await (0, import_promises.rmdir)(profilePath(normalizedProfile)).catch(() => void 0);
859
+ for (const handle of handles.reverse()) await handle.close();
716
860
  }
717
861
  }
862
+ async function assertRealProfileDirectory(profile) {
863
+ const named = namedProfilePath(profile);
864
+ const pinned = pinnedProfiles.getStore()?.get(named);
865
+ if (pinned) {
866
+ livePinnedPath(profile, pinned);
867
+ return;
868
+ }
869
+ const stats = await (0, import_promises.lstat)(named).catch((error) => {
870
+ if (error.code === "ENOENT") return null;
871
+ throw error;
872
+ });
873
+ if (stats && (!stats.isDirectory() || stats.isSymbolicLink())) throw new ProfileSymlinkError(profile);
874
+ }
875
+ async function withProfileLock(profile, action, options = {}) {
876
+ const normalizedProfile = validateProfileName(profile);
877
+ const lockPath = profileLockPath(normalizedProfile);
878
+ const held = (heldProfileLocks.getStore() ?? []).filter((ownership) => ownership.active);
879
+ if (held.some((ownership) => ownership.lockPath === lockPath)) {
880
+ await assertRealProfileDirectory(normalizedProfile);
881
+ return action();
882
+ }
883
+ const named = namedProfilePath(normalizedProfile);
884
+ const inherited = pinnedProfiles.getStore()?.get(named);
885
+ if (inherited && !isPinnedLive(inherited)) {
886
+ const profiles = new Map(pinnedProfiles.getStore());
887
+ profiles.delete(named);
888
+ const homes = new Map(pinnedHomes.getStore());
889
+ homes.delete(tinycloudHomePath());
890
+ return pinnedHomes.run(homes, () => pinnedProfiles.run(profiles, () => withProfileLock(normalizedProfile, action, options)));
891
+ }
892
+ return withPinnedProfileDirectory(normalizedProfile, async (directory) => {
893
+ if (!directory) throw new ProfileDeletedError(normalizedProfile);
894
+ const acquired = await acquireProfileLock(normalizedProfile, options);
895
+ const ownership = {
896
+ lockPath,
897
+ turnSlot: acquired.turnSlot,
898
+ deletedWhileWaiting: acquired.deletedWhileWaiting,
899
+ active: true
900
+ };
901
+ try {
902
+ return await heldProfileLocks.run([...held, ownership], action);
903
+ } finally {
904
+ ownership.active = false;
905
+ await acquired.release();
906
+ if (ownership.deletedProfile || acquired.deletedWhileWaiting) {
907
+ const current = await (0, import_promises.lstat)(namedProfilePath(normalizedProfile), { bigint: true }).catch(() => null);
908
+ const original = await (0, import_promises.stat)(directory, { bigint: true }).catch(() => null);
909
+ const currentDev = current && exactFileId(current.dev);
910
+ const currentIno = current && exactFileId(current.ino);
911
+ if (currentDev !== null && currentIno !== null && original && currentDev === exactFileId(original.dev) && currentIno === exactFileId(original.ino)) {
912
+ await (0, import_promises.rmdir)(namedProfilePath(normalizedProfile)).catch(() => void 0);
913
+ }
914
+ }
915
+ }
916
+ }, { create: !options.requireProfile });
917
+ }
718
918
  async function refuseWriteToDeletedProfile(profile) {
719
919
  const lockPath = profileLockPath(profile);
720
920
  const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
@@ -747,17 +947,15 @@ async function acquireProfileLock(profile, options) {
747
947
  retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
748
948
  };
749
949
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
750
- const lockPath = profileLockPath(profile);
950
+ const lockPath = (0, import_node_path.join)(profilePath(profile), ".lock");
751
951
  const turnDirectory = profileTurnLockPath(profile);
752
952
  const directory = profilePath(profile);
953
+ await assertRealProfileDirectory(profile);
753
954
  if (options.requireProfile && !await exists(profileConfigPath(profile))) throw new ProfileDeletedError(profile);
754
955
  if (!options.requireProfile) await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
956
+ await assertRealProfileDirectory(profile);
755
957
  await (0, import_promises.mkdir)((0, import_node_path.dirname)(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
756
- for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
757
- await (0, import_promises.chmod)(path, PRIVATE_DIR_MODE).catch((error) => {
758
- if (path !== directory || !isErrno(error, "ENOENT")) throw error;
759
- });
760
- }
958
+ await (0, import_promises.chmod)(directory, PRIVATE_DIR_MODE);
761
959
  const liveness = { directory: turnDirectory, held: void 0 };
762
960
  const closeAcquisitionLiveness = async () => {
763
961
  if (liveness.held) await closeLiveness(liveness.held);
@@ -777,6 +975,7 @@ async function acquireProfileLock(profile, options) {
777
975
  }
778
976
  const deletion = await readJson((0, import_node_path.join)(turnDirectory, "deleted.json")).catch(() => null);
779
977
  const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
978
+ if (deletedWhileWaiting && !await exists(profileConfigPath(profile))) throw new ProfileDeletedError(profile);
780
979
  while (true) {
781
980
  const token = (0, import_node_crypto.randomUUID)();
782
981
  if (await publishProfileLock(profile, lockPath, token, options.requireProfile === true, liveness.held ?? null)) {
@@ -813,8 +1012,8 @@ async function waitOrTimeOut(profile, deadline, detail) {
813
1012
  await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
814
1013
  }
815
1014
  var TURN = /^(?:0|[1-9][0-9]*)$/;
816
- var _a2;
817
- var relinquishedTurns = globalThis[_a2 = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a2] = /* @__PURE__ */ new Set());
1015
+ var _a3;
1016
+ var relinquishedTurns = globalThis[_a3 = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a3] = /* @__PURE__ */ new Set());
818
1017
  var MARKER_RETRY_DELAYS_MS = [10, 50, 250];
819
1018
  var SETTLE_RETRY_MS = 1e3;
820
1019
  var SETTLE_RETRIES = 300;
@@ -928,7 +1127,7 @@ async function takeTurn(profile, directory, latest, liveness) {
928
1127
  if (published) await markTurn(path, token, "done");
929
1128
  return null;
930
1129
  } catch (error) {
931
- if (published) settleOwnTurn(directory, slot, token);
1130
+ if (published) await settleOwnTurn(directory, slot, token);
932
1131
  throw error;
933
1132
  }
934
1133
  }
@@ -939,17 +1138,25 @@ async function settleAbandonedTurn(directory, abandoned) {
939
1138
  }
940
1139
  await markTurn(path, abandoned.token, "done");
941
1140
  }
942
- function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
1141
+ async function settleOwnTurn(directory, slot, token) {
943
1142
  relinquishedTurns.add(token);
944
- void (async () => {
945
- try {
946
- const turn = await readTurn(directory, slot);
947
- if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
948
- relinquishedTurns.delete(token);
949
- } catch {
950
- if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
951
- }
952
- })();
1143
+ const pinned = await pinDirectory(directory).catch(() => null);
1144
+ const path = pinned?.path(".") ?? directory;
1145
+ let retries = SETTLE_RETRIES;
1146
+ const attempt = () => {
1147
+ void (async () => {
1148
+ try {
1149
+ const turn = await readTurn(path, slot);
1150
+ if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(path, turn);
1151
+ relinquishedTurns.delete(token);
1152
+ await pinned?.close();
1153
+ } catch {
1154
+ if (retries-- > 0) setTimeout(attempt, SETTLE_RETRY_MS).unref();
1155
+ else await pinned?.close();
1156
+ }
1157
+ })();
1158
+ };
1159
+ attempt();
953
1160
  }
954
1161
  async function markTurn(path, token, kind) {
955
1162
  for (let attempt = 0; ; attempt++) {
@@ -994,7 +1201,7 @@ async function releaseTurn(profile, turn, liveness) {
994
1201
  try {
995
1202
  await markTurn((0, import_node_path.join)(turn.directory, String(turn.slot)), turn.token, "done");
996
1203
  } catch {
997
- settleOwnTurn(turn.directory, turn.slot, turn.token);
1204
+ await settleOwnTurn(turn.directory, turn.slot, turn.token);
998
1205
  return;
999
1206
  }
1000
1207
  await collectTurns(profile, turn, liveness).catch(() => void 0);