@tinycloud/operations 0.6.1-beta.3 → 0.6.1-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/artifacts.js CHANGED
@@ -382,15 +382,16 @@ function canonicalizeCaveats(caveats) {
382
382
  // src/state.ts
383
383
  import { randomUUID } from "crypto";
384
384
  import { AsyncLocalStorage } from "async_hooks";
385
- import { readFileSync, readlinkSync } from "fs";
385
+ import { constants as fsConstants, fstatSync, readFileSync, readlinkSync } from "fs";
386
386
  import {
387
387
  chmod,
388
388
  link,
389
- lstat,
390
389
  mkdir,
390
+ lstat,
391
391
  open,
392
392
  readFile,
393
393
  readdir,
394
+ realpath,
394
395
  rename,
395
396
  rmdir,
396
397
  rm,
@@ -420,14 +421,18 @@ var TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIE
420
421
  var TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
421
422
  var TEST_LOCK_LIVENESS_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_LIVENESS_BARRIER_DIR";
422
423
  function processWideContext(key) {
423
- var _a3;
424
+ var _a4;
424
425
  const registry = globalThis;
425
- return registry[_a3 = Symbol.for(key)] ?? (registry[_a3] = new AsyncLocalStorage());
426
+ return registry[_a4 = Symbol.for(key)] ?? (registry[_a4] = new AsyncLocalStorage());
426
427
  }
427
428
  var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
428
429
  var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
430
+ var pinnedProfiles = processWideContext("tinycloud.operations.pinnedProfiles.v2");
431
+ var pinnedHomes = processWideContext("tinycloud.operations.pinnedHomes.v2");
429
432
  var _a;
430
- var processInstanceId = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.profileLockInstance.v1")] ?? (globalThis[_a] = randomUUID());
433
+ var canonicalHomes = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.canonicalHomes.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Map());
434
+ var _a2;
435
+ var processInstanceId = globalThis[_a2 = /* @__PURE__ */ Symbol.for("tinycloud.operations.profileLockInstance.v1")] ?? (globalThis[_a2] = randomUUID());
431
436
  var cachedProcessOrigin;
432
437
  function processOrigin() {
433
438
  if (cachedProcessOrigin !== void 0) return cachedProcessOrigin;
@@ -605,9 +610,14 @@ function tinycloudHomePath() {
605
610
  function profilesPath() {
606
611
  return join(tinycloudHomePath(), "profiles");
607
612
  }
608
- function profilePath(profile) {
613
+ function namedProfilePath(profile) {
609
614
  return join(profilesPath(), validateProfileName(profile));
610
615
  }
616
+ function profilePath(profile) {
617
+ const named = namedProfilePath(profile);
618
+ const pinned = pinnedProfiles.getStore()?.get(named);
619
+ return pinned ? livePinnedPath(profile, pinned) : named;
620
+ }
611
621
  function profileConfigPath(profile) {
612
622
  return join(profilePath(profile), "profile.json");
613
623
  }
@@ -618,10 +628,12 @@ function profileStoreMetadataPath(profile, store) {
618
628
  return `${profileStorePath(profile, store)}.metadata.json`;
619
629
  }
620
630
  function profileLockPath(profile) {
621
- return join(profilePath(profile), ".lock");
631
+ return join(namedProfilePath(profile), ".lock");
622
632
  }
623
633
  function profileTurnLockPath(profile) {
624
- return join(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
634
+ const home = tinycloudHomePath();
635
+ const pinned = pinnedHomes.getStore()?.get(home);
636
+ return join(pinned ? livePinnedPath(profile, pinned) : home, "profile-locks", validateProfileName(profile));
625
637
  }
626
638
  async function readJson(filePath) {
627
639
  try {
@@ -631,6 +643,44 @@ async function readJson(filePath) {
631
643
  throw error;
632
644
  }
633
645
  }
646
+ async function readProfileJson(profile, filePath) {
647
+ const before = process.platform !== "linux" ? await lstat(filePath, { bigint: true }).catch((error) => {
648
+ if (error.code === "ENOENT") return null;
649
+ throw error;
650
+ }) : null;
651
+ if (process.platform !== "linux" && !before) return null;
652
+ if (before && !before.isFile()) throw new ProfileSymlinkError(profile);
653
+ const dev = before && exactFileId(before.dev);
654
+ const ino = before && exactFileId(before.ino);
655
+ try {
656
+ const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
657
+ try {
658
+ if (before) {
659
+ const opened = await handle.stat({ bigint: true });
660
+ if (!opened.isFile() || dev === null || ino === null || dev !== exactFileId(opened.dev) || ino !== exactFileId(opened.ino)) {
661
+ throw new ProfileSymlinkError(profile, "changed while opening its file");
662
+ }
663
+ }
664
+ const contents = await handle.readFile("utf8");
665
+ if (before) {
666
+ const named = await lstat(filePath, { bigint: true }).catch((error) => {
667
+ if (error.code === "ENOENT") return null;
668
+ throw error;
669
+ });
670
+ if (!named?.isFile() || dev !== exactFileId(named.dev) || ino !== exactFileId(named.ino)) {
671
+ throw new ProfileSymlinkError(profile, "changed while reading its file");
672
+ }
673
+ }
674
+ return JSON.parse(contents);
675
+ } finally {
676
+ await handle.close();
677
+ }
678
+ } catch (error) {
679
+ if (isErrno(error, "ENOENT")) return null;
680
+ if (isErrno(error, "ELOOP") || isErrno(error, "ENOTDIR")) throw new ProfileSymlinkError(profile);
681
+ throw error;
682
+ }
683
+ }
634
684
  async function writeJsonAtomic(filePath, value) {
635
685
  await replaceJson(filePath, value, false);
636
686
  }
@@ -667,43 +717,194 @@ async function readAuthRequests(profile) {
667
717
  return (await readProfileStore(profile, "auth-requests")).records;
668
718
  }
669
719
  async function readStoreMetadata(profile, store) {
670
- const metadata = await readJson(profileStoreMetadataPath(profile, store));
671
- if (metadata === null) return { formatVersion: 1 };
672
- if (typeof metadata !== "object" || metadata.formatVersion !== 1) {
673
- throw new TypeError(`Unsupported store format for "${store}".`);
674
- }
675
- return metadata;
720
+ return withPinnedProfileDirectory(profile, async (directory) => {
721
+ const metadata = directory ? await readProfileJson(profile, profileStoreMetadataPath(profile, store)) : null;
722
+ if (metadata === null) return { formatVersion: 1 };
723
+ if (typeof metadata !== "object" || metadata.formatVersion !== 1) {
724
+ throw new TypeError(`Unsupported store format for "${store}".`);
725
+ }
726
+ return metadata;
727
+ });
676
728
  }
677
729
  async function readProfileStore(profile, store) {
678
- const [metadata, raw] = await Promise.all([
679
- readStoreMetadata(profile, store),
680
- readJson(profileStorePath(profile, store))
681
- ]);
682
- return {
683
- formatVersion: metadata.formatVersion,
684
- records: Array.isArray(raw) ? raw : []
685
- };
730
+ return withPinnedProfileDirectory(profile, async (directory) => {
731
+ if (!directory) return { formatVersion: 1, records: [] };
732
+ const [metadata, raw] = await Promise.all([
733
+ readStoreMetadata(profile, store),
734
+ readProfileJson(profile, profileStorePath(profile, store))
735
+ ]);
736
+ return {
737
+ formatVersion: metadata.formatVersion,
738
+ records: Array.isArray(raw) ? raw : []
739
+ };
740
+ });
686
741
  }
687
- async function withProfileLock(profile, action, options = {}) {
688
- const normalizedProfile = validateProfileName(profile);
689
- const lockPath = profileLockPath(normalizedProfile);
690
- const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
691
- if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
692
- const acquired = await acquireProfileLock(normalizedProfile, options);
693
- const ownership = {
694
- lockPath,
695
- turnSlot: acquired.turnSlot,
696
- deletedWhileWaiting: acquired.deletedWhileWaiting,
697
- active: true
698
- };
742
+ var ProfileSymlinkError = class extends Error {
743
+ constructor(profile, detail = "must be a real directory, not a symlink or another file type") {
744
+ super(`Profile "${profile}" ${detail}.`);
745
+ this.code = "PROFILE_SYMLINK";
746
+ this.name = "ProfileSymlinkError";
747
+ }
748
+ };
749
+ function exactFileId(value) {
750
+ if (typeof value === "bigint") return value >= 0n ? value : null;
751
+ return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
752
+ }
753
+ function isPinnedLive(pinned) {
754
+ if (!pinned.active) return false;
699
755
  try {
700
- return await heldProfileLocks.run([...held, ownership], action);
756
+ const stats = fstatSync(pinned.fd, { bigint: true });
757
+ return exactFileId(stats.dev) === pinned.dev && exactFileId(stats.ino) === pinned.ino;
758
+ } catch {
759
+ return false;
760
+ }
761
+ }
762
+ function livePinnedPath(profile, pinned) {
763
+ if (isPinnedLive(pinned)) return pinned.path;
764
+ throw new ProfileSymlinkError(profile, "its pinned directory is no longer active or changed during use");
765
+ }
766
+ async function withPinnedProfileDirectory(profile, action, options = {}) {
767
+ const named = namedProfilePath(profile);
768
+ const existing = pinnedProfiles.getStore()?.get(named);
769
+ if (existing) return action(livePinnedPath(profile, existing));
770
+ if (process.platform !== "linux") {
771
+ if (options.create) await mkdir(named, { recursive: true, mode: PRIVATE_DIR_MODE });
772
+ const stats = await lstat(named).catch((error) => {
773
+ if (error.code === "ENOENT") return null;
774
+ throw error;
775
+ });
776
+ if (!stats) return action(null);
777
+ if (!stats.isDirectory() || stats.isSymbolicLink()) throw new ProfileSymlinkError(profile);
778
+ return action(named);
779
+ }
780
+ const home = tinycloudHomePath();
781
+ const handles = [];
782
+ const fdPath = (fd) => `/proc/self/fd/${fd}`;
783
+ const flags = fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW;
784
+ try {
785
+ if (options.create) await mkdir(home, { recursive: true, mode: PRIVATE_DIR_MODE });
786
+ const resolved = canonicalHomes.get(home) ?? await realpath(home).catch((error) => {
787
+ if (error.code === "ENOENT" && !options.create) return null;
788
+ if (error.code === "ELOOP" || error.code === "ENOTDIR") throw new ProfileSymlinkError(profile);
789
+ throw error;
790
+ });
791
+ if (!resolved) return await action(null);
792
+ const root = canonicalHomes.get(home) ?? resolved;
793
+ canonicalHomes.set(home, root);
794
+ const segments = [root, "profiles", validateProfileName(profile)];
795
+ for (let index = 0; index < segments.length; index++) {
796
+ const path = handles.length ? join(fdPath(handles[handles.length - 1].fd), segments[index]) : segments[index];
797
+ if (options.create && handles.length) {
798
+ await mkdir(path, { mode: PRIVATE_DIR_MODE }).catch((error) => {
799
+ if (error.code !== "EEXIST") throw error;
800
+ });
801
+ }
802
+ const handle = await open(path, index === 1 ? flags & ~fsConstants.O_NOFOLLOW : flags).catch((error) => {
803
+ if (error.code === "ENOENT" && !options.create) return null;
804
+ if (error.code === "ELOOP" || error.code === "ENOTDIR") throw new ProfileSymlinkError(profile);
805
+ throw error;
806
+ });
807
+ if (!handle) return await action(null);
808
+ handles.push(handle);
809
+ if (options.create) await handle.chmod(PRIVATE_DIR_MODE);
810
+ }
811
+ const opened = handles[2];
812
+ const [actual, namedStats] = await Promise.all([opened.stat({ bigint: true }), lstat(named, { bigint: true })]);
813
+ const dev = exactFileId(actual.dev);
814
+ const ino = exactFileId(actual.ino);
815
+ if (!namedStats.isDirectory() || dev === null || ino === null || dev !== exactFileId(namedStats.dev) || ino !== exactFileId(namedStats.ino)) {
816
+ throw new ProfileSymlinkError(profile, "changed while opening its directory");
817
+ }
818
+ const directory = fdPath(opened.fd);
819
+ const pinned = {
820
+ path: directory,
821
+ fd: opened.fd,
822
+ dev,
823
+ ino,
824
+ active: true
825
+ };
826
+ const homeHandle = handles[0];
827
+ const homeStats = await homeHandle.stat({ bigint: true });
828
+ const homeDev = exactFileId(homeStats.dev);
829
+ const homeIno = exactFileId(homeStats.ino);
830
+ if (homeDev === null || homeIno === null) throw new ProfileSymlinkError(profile, "has an unverified home directory identity");
831
+ const homePinned = {
832
+ path: fdPath(homeHandle.fd),
833
+ fd: homeHandle.fd,
834
+ dev: homeDev,
835
+ ino: homeIno,
836
+ active: true
837
+ };
838
+ const context = new Map(pinnedProfiles.getStore());
839
+ context.set(named, pinned);
840
+ const homes = new Map(pinnedHomes.getStore());
841
+ homes.set(home, homePinned);
842
+ try {
843
+ return await pinnedHomes.run(homes, () => pinnedProfiles.run(context, () => action(directory)));
844
+ } finally {
845
+ pinned.active = false;
846
+ homePinned.active = false;
847
+ }
701
848
  } finally {
702
- ownership.active = false;
703
- await acquired.release();
704
- if (acquired.deletedWhileWaiting) await rmdir(profilePath(normalizedProfile)).catch(() => void 0);
849
+ for (const handle of handles.reverse()) await handle.close();
705
850
  }
706
851
  }
852
+ async function assertRealProfileDirectory(profile) {
853
+ const named = namedProfilePath(profile);
854
+ const pinned = pinnedProfiles.getStore()?.get(named);
855
+ if (pinned) {
856
+ livePinnedPath(profile, pinned);
857
+ return;
858
+ }
859
+ const stats = await lstat(named).catch((error) => {
860
+ if (error.code === "ENOENT") return null;
861
+ throw error;
862
+ });
863
+ if (stats && (!stats.isDirectory() || stats.isSymbolicLink())) throw new ProfileSymlinkError(profile);
864
+ }
865
+ async function withProfileLock(profile, action, options = {}) {
866
+ const normalizedProfile = validateProfileName(profile);
867
+ const lockPath = profileLockPath(normalizedProfile);
868
+ const held = (heldProfileLocks.getStore() ?? []).filter((ownership) => ownership.active);
869
+ if (held.some((ownership) => ownership.lockPath === lockPath)) {
870
+ await assertRealProfileDirectory(normalizedProfile);
871
+ return action();
872
+ }
873
+ const named = namedProfilePath(normalizedProfile);
874
+ const inherited = pinnedProfiles.getStore()?.get(named);
875
+ if (inherited && !isPinnedLive(inherited)) {
876
+ const profiles = new Map(pinnedProfiles.getStore());
877
+ profiles.delete(named);
878
+ const homes = new Map(pinnedHomes.getStore());
879
+ homes.delete(tinycloudHomePath());
880
+ return pinnedHomes.run(homes, () => pinnedProfiles.run(profiles, () => withProfileLock(normalizedProfile, action, options)));
881
+ }
882
+ return withPinnedProfileDirectory(normalizedProfile, async (directory) => {
883
+ if (!directory) throw new ProfileDeletedError(normalizedProfile);
884
+ const acquired = await acquireProfileLock(normalizedProfile, options);
885
+ const ownership = {
886
+ lockPath,
887
+ turnSlot: acquired.turnSlot,
888
+ deletedWhileWaiting: acquired.deletedWhileWaiting,
889
+ active: true
890
+ };
891
+ try {
892
+ return await heldProfileLocks.run([...held, ownership], action);
893
+ } finally {
894
+ ownership.active = false;
895
+ await acquired.release();
896
+ if (ownership.deletedProfile || acquired.deletedWhileWaiting) {
897
+ const current = await lstat(namedProfilePath(normalizedProfile), { bigint: true }).catch(() => null);
898
+ const original = await stat(directory, { bigint: true }).catch(() => null);
899
+ const currentDev = current && exactFileId(current.dev);
900
+ const currentIno = current && exactFileId(current.ino);
901
+ if (currentDev !== null && currentIno !== null && original && currentDev === exactFileId(original.dev) && currentIno === exactFileId(original.ino)) {
902
+ await rmdir(namedProfilePath(normalizedProfile)).catch(() => void 0);
903
+ }
904
+ }
905
+ }
906
+ }, { create: !options.requireProfile });
907
+ }
707
908
  async function refuseWriteToDeletedProfile(profile) {
708
909
  const lockPath = profileLockPath(profile);
709
910
  const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
@@ -736,17 +937,15 @@ async function acquireProfileLock(profile, options) {
736
937
  retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
737
938
  };
738
939
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
739
- const lockPath = profileLockPath(profile);
940
+ const lockPath = join(profilePath(profile), ".lock");
740
941
  const turnDirectory = profileTurnLockPath(profile);
741
942
  const directory = profilePath(profile);
943
+ await assertRealProfileDirectory(profile);
742
944
  if (options.requireProfile && !await exists(profileConfigPath(profile))) throw new ProfileDeletedError(profile);
743
945
  if (!options.requireProfile) await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
946
+ await assertRealProfileDirectory(profile);
744
947
  await mkdir(dirname(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
745
- for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
746
- await chmod(path, PRIVATE_DIR_MODE).catch((error) => {
747
- if (path !== directory || !isErrno(error, "ENOENT")) throw error;
748
- });
749
- }
948
+ await chmod(directory, PRIVATE_DIR_MODE);
750
949
  const liveness = { directory: turnDirectory, held: void 0 };
751
950
  const closeAcquisitionLiveness = async () => {
752
951
  if (liveness.held) await closeLiveness(liveness.held);
@@ -766,6 +965,7 @@ async function acquireProfileLock(profile, options) {
766
965
  }
767
966
  const deletion = await readJson(join(turnDirectory, "deleted.json")).catch(() => null);
768
967
  const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
968
+ if (deletedWhileWaiting && !await exists(profileConfigPath(profile))) throw new ProfileDeletedError(profile);
769
969
  while (true) {
770
970
  const token = randomUUID();
771
971
  if (await publishProfileLock(profile, lockPath, token, options.requireProfile === true, liveness.held ?? null)) {
@@ -802,8 +1002,8 @@ async function waitOrTimeOut(profile, deadline, detail) {
802
1002
  await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
803
1003
  }
804
1004
  var TURN = /^(?:0|[1-9][0-9]*)$/;
805
- var _a2;
806
- var relinquishedTurns = globalThis[_a2 = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a2] = /* @__PURE__ */ new Set());
1005
+ var _a3;
1006
+ var relinquishedTurns = globalThis[_a3 = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a3] = /* @__PURE__ */ new Set());
807
1007
  var MARKER_RETRY_DELAYS_MS = [10, 50, 250];
808
1008
  var SETTLE_RETRY_MS = 1e3;
809
1009
  var SETTLE_RETRIES = 300;
@@ -917,7 +1117,7 @@ async function takeTurn(profile, directory, latest, liveness) {
917
1117
  if (published) await markTurn(path, token, "done");
918
1118
  return null;
919
1119
  } catch (error) {
920
- if (published) settleOwnTurn(directory, slot, token);
1120
+ if (published) await settleOwnTurn(directory, slot, token);
921
1121
  throw error;
922
1122
  }
923
1123
  }
@@ -928,17 +1128,25 @@ async function settleAbandonedTurn(directory, abandoned) {
928
1128
  }
929
1129
  await markTurn(path, abandoned.token, "done");
930
1130
  }
931
- function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
1131
+ async function settleOwnTurn(directory, slot, token) {
932
1132
  relinquishedTurns.add(token);
933
- void (async () => {
934
- try {
935
- const turn = await readTurn(directory, slot);
936
- if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
937
- relinquishedTurns.delete(token);
938
- } catch {
939
- if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
940
- }
941
- })();
1133
+ const pinned = await pinDirectory(directory).catch(() => null);
1134
+ const path = pinned?.path(".") ?? directory;
1135
+ let retries = SETTLE_RETRIES;
1136
+ const attempt = () => {
1137
+ void (async () => {
1138
+ try {
1139
+ const turn = await readTurn(path, slot);
1140
+ if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(path, turn);
1141
+ relinquishedTurns.delete(token);
1142
+ await pinned?.close();
1143
+ } catch {
1144
+ if (retries-- > 0) setTimeout(attempt, SETTLE_RETRY_MS).unref();
1145
+ else await pinned?.close();
1146
+ }
1147
+ })();
1148
+ };
1149
+ attempt();
942
1150
  }
943
1151
  async function markTurn(path, token, kind) {
944
1152
  for (let attempt = 0; ; attempt++) {
@@ -983,7 +1191,7 @@ async function releaseTurn(profile, turn, liveness) {
983
1191
  try {
984
1192
  await markTurn(join(turn.directory, String(turn.slot)), turn.token, "done");
985
1193
  } catch {
986
- settleOwnTurn(turn.directory, turn.slot, turn.token);
1194
+ await settleOwnTurn(turn.directory, turn.slot, turn.token);
987
1195
  return;
988
1196
  }
989
1197
  await collectTurns(profile, turn, liveness).catch(() => void 0);