@tinycloud/operations 0.3.4-beta.6 → 0.4.0-beta.8

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
@@ -6921,12 +6921,14 @@ import {
6921
6921
  chmod,
6922
6922
  link,
6923
6923
  mkdir,
6924
+ open,
6924
6925
  readFile,
6925
6926
  readdir,
6926
6927
  rename,
6927
6928
  rmdir,
6928
6929
  rm,
6929
6930
  stat,
6931
+ unlink,
6930
6932
  writeFile
6931
6933
  } from "fs/promises";
6932
6934
  import { homedir } from "os";
@@ -6943,17 +6945,33 @@ var TEST_LOCK_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_PUBLISH_BARRIER_DIR";
6943
6945
  var TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
6944
6946
  var TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
6945
6947
  var TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
6946
- var TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
6947
6948
  var TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
6948
- var invocationStateRoot = new AsyncLocalStorage();
6949
- var heldProfileLocks = new AsyncLocalStorage();
6949
+ var TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
6950
+ var TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
6951
+ var TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
6952
+ var TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
6953
+ function processWideContext(key) {
6954
+ var _a2;
6955
+ const registry = globalThis;
6956
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new AsyncLocalStorage());
6957
+ }
6958
+ var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
6959
+ var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
6950
6960
  var ProfileLockTimeoutError = class extends Error {
6951
- constructor(profile, timeoutMs) {
6952
- super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.`);
6961
+ /** `detail` names the cause when it is not simply another process holding the lock. */
6962
+ constructor(profile, timeoutMs, detail) {
6963
+ super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.${detail ? ` ${detail}` : ""}`);
6953
6964
  this.code = "PROFILE_LOCK_TIMEOUT";
6954
6965
  this.name = "ProfileLockTimeoutError";
6955
6966
  }
6956
6967
  };
6968
+ var ProfileDeletedError = class extends Error {
6969
+ constructor(profile) {
6970
+ super(`Profile "${profile}" was deleted while this change waited for its lock, so the change was not written.`);
6971
+ this.code = "PROFILE_NOT_FOUND";
6972
+ this.name = "ProfileDeletedError";
6973
+ }
6974
+ };
6957
6975
  function tinycloudHomePath() {
6958
6976
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
6959
6977
  return join(home, ".tinycloud");
@@ -6964,6 +6982,9 @@ function profilesPath() {
6964
6982
  function profilePath(profile) {
6965
6983
  return join(profilesPath(), validateProfileName(profile));
6966
6984
  }
6985
+ function profileConfigPath(profile) {
6986
+ return join(profilePath(profile), "profile.json");
6987
+ }
6967
6988
  function profileStorePath(profile, store) {
6968
6989
  return join(profilePath(profile), `${store}.json`);
6969
6990
  }
@@ -6973,6 +6994,9 @@ function profileStoreMetadataPath(profile, store) {
6973
6994
  function profileLockPath(profile) {
6974
6995
  return join(profilePath(profile), ".lock");
6975
6996
  }
6997
+ function profileTurnLockPath(profile) {
6998
+ return join(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
6999
+ }
6976
7000
  async function readJson(filePath) {
6977
7001
  try {
6978
7002
  return JSON.parse(await readFile(filePath, "utf8"));
@@ -6982,6 +7006,9 @@ async function readJson(filePath) {
6982
7006
  }
6983
7007
  }
6984
7008
  async function writeJsonAtomic(filePath, value) {
7009
+ await replaceJson(filePath, value, false);
7010
+ }
7011
+ async function replaceJson(filePath, value, durable) {
6985
7012
  const directory = dirname(filePath);
6986
7013
  const temporaryPath = join(
6987
7014
  directory,
@@ -6991,13 +7018,25 @@ async function writeJsonAtomic(filePath, value) {
6991
7018
  `;
6992
7019
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
6993
7020
  try {
6994
- await writeFile(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
7021
+ if (durable) await writeFileDurably(temporaryPath, contents);
7022
+ else await writeFile(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
6995
7023
  await rename(temporaryPath, filePath);
6996
7024
  } catch (error) {
6997
7025
  await rm(temporaryPath, { force: true }).catch(() => void 0);
6998
7026
  throw error;
6999
7027
  }
7000
7028
  }
7029
+ async function writeFileDurably(path, contents) {
7030
+ const handle = await open(path, "wx", PRIVATE_FILE_MODE);
7031
+ try {
7032
+ if (contents) {
7033
+ await handle.writeFile(contents, "utf8");
7034
+ await handle.datasync();
7035
+ }
7036
+ } finally {
7037
+ await handle.close();
7038
+ }
7039
+ }
7001
7040
  async function readAuthRequests(profile) {
7002
7041
  return (await readProfileStore(profile, "auth-requests")).records;
7003
7042
  }
@@ -7024,15 +7063,28 @@ async function withProfileLock(profile, action, options = {}) {
7024
7063
  const lockPath = profileLockPath(normalizedProfile);
7025
7064
  const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
7026
7065
  if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
7027
- const release = await acquireProfileLock(normalizedProfile, options);
7028
- const ownership = { lockPath, active: true };
7066
+ const acquired = await acquireProfileLock(normalizedProfile, options);
7067
+ const ownership = {
7068
+ lockPath,
7069
+ turnSlot: acquired.turnSlot,
7070
+ deletedWhileWaiting: acquired.deletedWhileWaiting,
7071
+ active: true
7072
+ };
7029
7073
  try {
7030
7074
  return await heldProfileLocks.run([...held, ownership], action);
7031
7075
  } finally {
7032
7076
  ownership.active = false;
7033
- await release();
7077
+ await acquired.release();
7078
+ if (acquired.deletedWhileWaiting) await rmdir(profilePath(normalizedProfile)).catch(() => void 0);
7034
7079
  }
7035
7080
  }
7081
+ async function refuseWriteToDeletedProfile(profile) {
7082
+ const lockPath = profileLockPath(profile);
7083
+ const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
7084
+ if (!ownership?.deletedWhileWaiting) return;
7085
+ if (await exists(profileConfigPath(profile))) return;
7086
+ throw new ProfileDeletedError(profile);
7087
+ }
7036
7088
  async function updateProfileStore(profile, store, update, options = {}) {
7037
7089
  return withProfileLock(
7038
7090
  profile,
@@ -7041,6 +7093,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
7041
7093
  );
7042
7094
  }
7043
7095
  async function updateProfileStoreWhileLocked(profile, store, update) {
7096
+ await refuseWriteToDeletedProfile(profile);
7044
7097
  const current = await readProfileStore(profile, store);
7045
7098
  const next = await update(current.records);
7046
7099
  await writeJsonAtomic(profileStorePath(profile, store), next.records);
@@ -7051,32 +7104,277 @@ async function writeFormatOneMetadata(profile, store) {
7051
7104
  await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
7052
7105
  }
7053
7106
  async function acquireProfileLock(profile, options) {
7054
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
7055
- const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
7107
+ const deadline = {
7108
+ startedAt: Date.now(),
7109
+ timeoutMs: options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
7110
+ retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
7111
+ };
7056
7112
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
7057
- const startedAt = Date.now();
7058
7113
  const lockPath = profileLockPath(profile);
7114
+ const turnDirectory = profileTurnLockPath(profile);
7059
7115
  const directory = profilePath(profile);
7060
7116
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
7117
+ await mkdir(dirname(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
7061
7118
  for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
7062
7119
  await chmod(path, PRIVATE_DIR_MODE).catch((error) => {
7063
7120
  if (path !== directory || !isErrno(error, "ENOENT")) throw error;
7064
7121
  });
7065
7122
  }
7066
- while (true) {
7067
- const token = randomUUID();
7068
- if (await publishProfileLock(profile, lockPath, token)) {
7069
- return async () => {
7070
- await releaseProfileLock(profile, lockPath, token);
7071
- };
7123
+ const { turn, firstWaitedTurn } = await acquireTurn(profile, turnDirectory, deadline);
7124
+ try {
7125
+ const deletion = await readJson(join(turnDirectory, "deleted.json")).catch(() => null);
7126
+ const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
7127
+ while (true) {
7128
+ const token = randomUUID();
7129
+ if (await publishProfileLock(profile, lockPath, token)) {
7130
+ return {
7131
+ deletedWhileWaiting,
7132
+ turnSlot: turn.slot,
7133
+ release: async () => {
7134
+ try {
7135
+ await releaseProfileLock(profile, lockPath, token);
7136
+ } finally {
7137
+ await releaseTurn(profile, turn);
7138
+ }
7139
+ }
7140
+ };
7141
+ }
7142
+ await signalTestLockContention(profile);
7143
+ if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
7144
+ await waitOrTimeOut(profile, deadline);
7072
7145
  }
7073
- await signalTestLockContention(profile);
7074
- if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
7075
- const elapsedMs = Date.now() - startedAt;
7076
- if (elapsedMs >= timeoutMs) {
7077
- throw new ProfileLockTimeoutError(profile, timeoutMs);
7146
+ } catch (error) {
7147
+ await releaseTurn(profile, turn);
7148
+ throw error;
7149
+ }
7150
+ }
7151
+ function checkDeadline(profile, deadline, detail) {
7152
+ if (Date.now() - deadline.startedAt >= deadline.timeoutMs) {
7153
+ throw new ProfileLockTimeoutError(profile, deadline.timeoutMs, detail);
7154
+ }
7155
+ }
7156
+ async function waitOrTimeOut(profile, deadline, detail) {
7157
+ checkDeadline(profile, deadline, detail);
7158
+ await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
7159
+ }
7160
+ var TURN = /^(?:0|[1-9][0-9]*)$/;
7161
+ var _a;
7162
+ var relinquishedTurns = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Set());
7163
+ var MARKER_RETRY_DELAYS_MS = [10, 50, 250];
7164
+ var SETTLE_RETRY_MS = 1e3;
7165
+ var SETTLE_RETRIES = 300;
7166
+ async function acquireTurn(profile, directory, deadline) {
7167
+ let firstWaitedTurn;
7168
+ let problem;
7169
+ const damaged = (what) => `The turn lock ${directory} ${what}, so no process can take it. If no tc or MCP process is using profile "${profile}", remove that directory.`;
7170
+ for (let first = true; ; first = false) {
7171
+ if (!first) checkDeadline(profile, deadline, problem);
7172
+ problem = void 0;
7173
+ const latest = await readLatestTurn(directory);
7174
+ if (latest === "missing") {
7175
+ await createTurnDirectory(directory);
7176
+ continue;
7177
+ }
7178
+ if (latest === "empty") {
7179
+ problem = damaged("has no turns");
7180
+ await waitOrTimeOut(profile, deadline, problem);
7181
+ continue;
7182
+ }
7183
+ if (latest === null) continue;
7184
+ if (typeof latest === "number") {
7185
+ problem = damaged(`has an unreadable turn ${latest}`);
7186
+ await waitOrTimeOut(profile, deadline, problem);
7187
+ continue;
7188
+ }
7189
+ firstWaitedTurn ?? (firstWaitedTurn = latest.done ? latest.slot + 1 : latest.slot);
7190
+ if (latest.held && latest.done) {
7191
+ const turn = await takeTurn(profile, directory, latest);
7192
+ if (turn !== null) return { turn, firstWaitedTurn };
7193
+ await waitOrTimeOut(profile, deadline);
7194
+ } else if (latest.done) {
7195
+ problem = damaged(`ends with turn ${latest.slot}, which was never granted`);
7196
+ await waitOrTimeOut(profile, deadline, problem);
7197
+ } else if (latest.pid === process.pid ? relinquishedTurns.has(latest.token) : !isProcessAlive(latest.pid)) {
7198
+ await waitForTestBarrier(TEST_LOCK_TURN_SETTLE_BARRIER_DIR, profile);
7199
+ await settleAbandonedTurn(directory, latest);
7200
+ relinquishedTurns.delete(latest.token);
7201
+ } else {
7202
+ await signalTestLockContention(profile);
7203
+ await waitOrTimeOut(profile, deadline);
7204
+ }
7205
+ }
7206
+ }
7207
+ async function readLatestTurn(directory) {
7208
+ let names;
7209
+ try {
7210
+ names = await readdir(directory);
7211
+ } catch (error) {
7212
+ if (isErrno(error, "ENOENT")) return "missing";
7213
+ throw error;
7214
+ }
7215
+ const slots = names.filter((name) => TURN.test(name)).map(Number);
7216
+ if (slots.length === 0) return "empty";
7217
+ const slot = Math.max(...slots);
7218
+ const turn = await readTurn(directory, slot);
7219
+ return turn === "damaged" ? slot : turn;
7220
+ }
7221
+ async function readTurn(directory, slot) {
7222
+ const path = join(directory, String(slot));
7223
+ let text;
7224
+ try {
7225
+ text = await readFile(join(path, "owner.json"), "utf8");
7226
+ } catch (error) {
7227
+ if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTDIR")) throw error;
7228
+ return await exists(path) ? "damaged" : null;
7229
+ }
7230
+ let owner;
7231
+ try {
7232
+ owner = JSON.parse(text);
7233
+ } catch {
7234
+ return "damaged";
7235
+ }
7236
+ if (owner === null || typeof owner !== "object" || typeof owner.token !== "string" || typeof owner.pid !== "number") {
7237
+ return "damaged";
7238
+ }
7239
+ const done = await exists(join(path, `${owner.token}.done`));
7240
+ const held = await exists(join(path, `${owner.token}.held`));
7241
+ return {
7242
+ slot,
7243
+ token: owner.token,
7244
+ pid: owner.pid,
7245
+ after: typeof owner.after === "string" ? owner.after : null,
7246
+ held,
7247
+ done
7248
+ };
7249
+ }
7250
+ async function isFinishedTurn(directory, slot, token) {
7251
+ const turn = await readTurn(directory, slot);
7252
+ if (turn === "damaged") throw new Error(`Turn ${slot} of the turn lock ${directory} is unreadable.`);
7253
+ return turn !== null && turn.token === token && turn.held && turn.done;
7254
+ }
7255
+ async function takeTurn(profile, directory, latest) {
7256
+ const token = randomUUID();
7257
+ const slot = latest.slot + 1;
7258
+ const path = join(directory, String(slot));
7259
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token };
7260
+ let published = false;
7261
+ try {
7262
+ published = await publishDirectory(
7263
+ join(directory, `.stage-${process.pid}-${token}`),
7264
+ path,
7265
+ { "owner.json": `${JSON.stringify(owner, null, 2)}
7266
+ ` },
7267
+ () => waitForTestBarrier(TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, profile)
7268
+ );
7269
+ if (published && await isFinishedTurn(directory, latest.slot, latest.token) && await markTurn(path, token, "held")) {
7270
+ return { directory, slot, token };
7271
+ }
7272
+ if (published) await markTurn(path, token, "done");
7273
+ return null;
7274
+ } catch (error) {
7275
+ if (published) settleOwnTurn(directory, slot, token);
7276
+ throw error;
7277
+ }
7278
+ }
7279
+ async function settleAbandonedTurn(directory, abandoned) {
7280
+ const path = join(directory, String(abandoned.slot));
7281
+ if (!abandoned.held && abandoned.after !== null && await isFinishedTurn(directory, abandoned.slot - 1, abandoned.after)) {
7282
+ await markTurn(path, abandoned.token, "held");
7283
+ }
7284
+ await markTurn(path, abandoned.token, "done");
7285
+ }
7286
+ function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
7287
+ relinquishedTurns.add(token);
7288
+ void (async () => {
7289
+ try {
7290
+ const turn = await readTurn(directory, slot);
7291
+ if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
7292
+ relinquishedTurns.delete(token);
7293
+ } catch {
7294
+ if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
7295
+ }
7296
+ })();
7297
+ }
7298
+ async function markTurn(path, token, kind) {
7299
+ for (let attempt = 0; ; attempt++) {
7300
+ try {
7301
+ await writeFile(join(path, `${token}.${kind}`), "", { mode: PRIVATE_FILE_MODE, flag: "wx" });
7302
+ return true;
7303
+ } catch (error) {
7304
+ if (isErrno(error, "EEXIST")) return true;
7305
+ if (isErrno(error, "ENOENT")) return false;
7306
+ if (attempt >= MARKER_RETRY_DELAYS_MS.length) throw error;
7307
+ await sleep(MARKER_RETRY_DELAYS_MS[attempt]);
7308
+ }
7309
+ }
7310
+ }
7311
+ async function createTurnDirectory(directory) {
7312
+ const token = randomUUID();
7313
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null };
7314
+ await publishDirectory(join(dirname(directory), `.stage-${basename(directory)}-${process.pid}-${token}`), directory, {
7315
+ "0/owner.json": `${JSON.stringify(owner, null, 2)}
7316
+ `,
7317
+ [`0/${token}.held`]: "",
7318
+ [`0/${token}.done`]: ""
7319
+ });
7320
+ }
7321
+ async function publishDirectory(stage, target, files, beforeRename) {
7322
+ try {
7323
+ for (const [name, contents] of Object.entries(files)) {
7324
+ await mkdir(dirname(join(stage, name)), { recursive: true, mode: PRIVATE_DIR_MODE });
7325
+ await writeFileDurably(join(stage, name), contents);
7326
+ }
7327
+ await beforeRename?.();
7328
+ await rename(stage, target);
7329
+ return true;
7330
+ } catch (error) {
7331
+ if (["ENOTEMPTY", "EEXIST", "ENOENT"].some((code) => isErrno(error, code)) || await exists(target)) return false;
7332
+ throw error;
7333
+ } finally {
7334
+ await rm(stage, { recursive: true, force: true }).catch(() => void 0);
7335
+ }
7336
+ }
7337
+ async function releaseTurn(profile, turn) {
7338
+ try {
7339
+ await markTurn(join(turn.directory, String(turn.slot)), turn.token, "done");
7340
+ } catch {
7341
+ settleOwnTurn(turn.directory, turn.slot, turn.token);
7342
+ return;
7343
+ }
7344
+ await collectTurns(profile, turn).catch(() => void 0);
7345
+ }
7346
+ function isAbandonedTurnEntry(name) {
7347
+ if (name.startsWith(".trash-")) return true;
7348
+ const writer = /^\.(?:stage|deleted\.json)[-.]([0-9]+)[-.]/.exec(name)?.[1];
7349
+ return writer !== void 0 && !isProcessAlive(Number(writer));
7350
+ }
7351
+ function isAbandonedTurnDirectoryStage(name) {
7352
+ const writer = /^\.stage-.+-([0-9]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.exec(name)?.[1];
7353
+ return writer !== void 0 && !isProcessAlive(Number(writer));
7354
+ }
7355
+ async function collectTurns(profile, turn) {
7356
+ for (const [directory, abandoned] of [
7357
+ [turn.directory, isAbandonedTurnEntry],
7358
+ [dirname(turn.directory), isAbandonedTurnDirectoryStage]
7359
+ ]) {
7360
+ for (const name of (await readdir(directory)).filter(abandoned)) {
7361
+ await rm(join(directory, name), { recursive: true, force: true }).catch(() => void 0);
7362
+ }
7363
+ }
7364
+ const older = (await readdir(turn.directory)).filter((name) => TURN.test(name)).map(Number).filter((slot) => slot < turn.slot).sort((left, right) => left - right);
7365
+ for (const slot of older) {
7366
+ const candidate = await readTurn(turn.directory, slot);
7367
+ if (candidate === null || candidate === "damaged" || !candidate.done) continue;
7368
+ if (candidate.held) {
7369
+ if (await exists(join(turn.directory, String(slot - 1)))) continue;
7370
+ const next = await readTurn(turn.directory, slot + 1);
7371
+ if (next === null || next === "damaged" || !next.held || !next.done) continue;
7372
+ }
7373
+ await waitForTestBarrier(TEST_LOCK_TURN_COLLECT_BARRIER_DIR, profile);
7374
+ const trash = join(turn.directory, `.trash-${randomUUID()}`);
7375
+ if (await rename(join(turn.directory, String(slot)), trash).then(() => true, () => false)) {
7376
+ await rm(trash, { recursive: true, force: true }).catch(() => void 0);
7078
7377
  }
7079
- await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
7080
7378
  }
7081
7379
  }
7082
7380
  async function publishProfileLock(profile, lockPath, token) {
@@ -7089,12 +7387,18 @@ async function publishProfileLock(profile, lockPath, token) {
7089
7387
  return false;
7090
7388
  }
7091
7389
  await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
7390
+ const ownerPath = join(lockPath, "owner.json");
7092
7391
  const staged = join(dirname(lockPath), `.lock-owner-${token}.tmp`);
7093
- const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token };
7392
+ const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }, null, 2)}
7393
+ `;
7094
7394
  try {
7095
- await writeFile(staged, `${JSON.stringify(owner, null, 2)}
7096
- `, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7097
- await link(staged, join(lockPath, "owner.json"));
7395
+ await writeFile(staged, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7396
+ try {
7397
+ await link(staged, ownerPath);
7398
+ } catch (error) {
7399
+ if (!lacksHardLinks(error)) throw error;
7400
+ await writeFile(ownerPath, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7401
+ }
7098
7402
  return true;
7099
7403
  } catch (error) {
7100
7404
  if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
@@ -7104,6 +7408,10 @@ async function publishProfileLock(profile, lockPath, token) {
7104
7408
  await rm(staged, { force: true }).catch(() => void 0);
7105
7409
  }
7106
7410
  }
7411
+ var NO_HARD_LINK_ERRORS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EISDIR", "EMLINK"]);
7412
+ function lacksHardLinks(error) {
7413
+ return typeof error === "object" && error !== null && "code" in error && NO_HARD_LINK_ERRORS.has(String(error.code));
7414
+ }
7107
7415
  async function releaseProfileLock(profile, lockPath, token) {
7108
7416
  const ownerPath = join(lockPath, "owner.json");
7109
7417
  const owner = await readJson(ownerPath).catch(() => null);
@@ -7127,9 +7435,10 @@ async function signalTestLockContention(profile) {
7127
7435
  `, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
7128
7436
  }
7129
7437
  async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7438
+ let aged;
7130
7439
  try {
7131
7440
  const { mtimeMs } = await stat(lockPath);
7132
- if (Date.now() - mtimeMs < staleAfterMs) return false;
7441
+ aged = Date.now() - mtimeMs >= staleAfterMs;
7133
7442
  } catch {
7134
7443
  return false;
7135
7444
  }
@@ -7138,7 +7447,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7138
7447
  try {
7139
7448
  const entries = await readdir(lockPath);
7140
7449
  if (entries.includes("owner.json")) return false;
7141
- if (recoveryFenced(checkedAt, staleAfterMs)) return false;
7450
+ const onlyOrphanedClaims = entries.length > 0 && entries.every((entry) => RECOVERY_CLAIM.test(entry));
7451
+ if (!onlyOrphanedClaims && (!aged || recoveryFenced(checkedAt, staleAfterMs))) return false;
7452
+ for (const name of entries.filter((entry) => RECOVERY_CLAIM.test(entry))) {
7453
+ const claimPath = join(lockPath, name);
7454
+ if (await hasLiveRecoveryOwner(claimPath)) {
7455
+ if (!await exists(join(lockPath, "owner.json"))) await rename(claimPath, join(lockPath, "owner.json"));
7456
+ return false;
7457
+ }
7458
+ await rm(claimPath, { force: true });
7459
+ }
7142
7460
  for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
7143
7461
  await rm(join(lockPath, name), { force: true });
7144
7462
  }
@@ -7149,10 +7467,28 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7149
7467
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7150
7468
  return true;
7151
7469
  }
7470
+ async function hasLiveRecoveryOwner(claimPath) {
7471
+ let contents;
7472
+ try {
7473
+ contents = await readFile(claimPath, "utf8");
7474
+ } catch (error) {
7475
+ if (isErrno(error, "ENOENT")) return false;
7476
+ throw error;
7477
+ }
7478
+ let owner;
7479
+ try {
7480
+ owner = JSON.parse(contents);
7481
+ } catch {
7482
+ return false;
7483
+ }
7484
+ const pid = typeof owner === "object" && owner !== null && "pid" in owner ? owner.pid : null;
7485
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && isProcessAlive(pid);
7486
+ }
7152
7487
  function recoveryFenced(since, staleAfterMs) {
7153
7488
  return performance.now() - since >= staleAfterMs / 2;
7154
7489
  }
7155
7490
  var ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
7491
+ var RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
7156
7492
  var STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
7157
7493
  async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
7158
7494
  const directory = dirname(lockPath);
@@ -7165,37 +7501,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
7165
7501
  }
7166
7502
  async function recoverStaleLock(profile, lockPath, staleAfterMs) {
7167
7503
  const ownerPath = join(lockPath, "owner.json");
7168
- const owner = await readJson(ownerPath).catch(() => null);
7169
- if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
7170
- if (!isStaleOwner(owner, staleAfterMs)) return false;
7171
- const observedToken = typeof owner?.token === "string" && owner.token.length > 0 ? owner.token : "legacy";
7504
+ const observed = await readFile(ownerPath, "utf8").catch(() => null);
7505
+ if (observed === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
7506
+ if (!await isAbandonedOwner(observed, ownerPath, staleAfterMs)) return false;
7172
7507
  await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
7173
- const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);
7508
+ const claimPath = join(lockPath, `.recover-${randomUUID()}.json`);
7509
+ let moved = false;
7174
7510
  try {
7175
7511
  await link(ownerPath, claimPath);
7176
- } catch {
7177
- return false;
7512
+ } catch (error) {
7513
+ if (!lacksHardLinks(error)) return false;
7514
+ try {
7515
+ await rename(ownerPath, claimPath);
7516
+ moved = true;
7517
+ } catch {
7518
+ return false;
7519
+ }
7178
7520
  }
7179
- const claimedAt = performance.now();
7180
- await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
7181
- const claimed = await readJson(claimPath).catch(() => null);
7182
- const sameInstance = claimed !== null && (observedToken === "legacy" ? claimed.token === void 0 : claimed.token === observedToken);
7521
+ if (moved) await waitForTestBarrier(TEST_LOCK_MOVED_BARRIER_DIR, profile);
7522
+ const sameInstance = await readFile(claimPath, "utf8").then((claimed) => claimed === observed, () => false);
7183
7523
  await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
7184
- if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
7185
- await rm(claimPath, { force: true });
7186
- await waitForTestBarrier(TEST_LOCK_FENCED_BARRIER_DIR, profile);
7524
+ if (!sameInstance) {
7525
+ if (moved) {
7526
+ await rename(claimPath, ownerPath).catch(() => void 0);
7527
+ } else {
7528
+ await unlink(claimPath).catch(() => void 0);
7529
+ }
7187
7530
  return false;
7188
7531
  }
7189
- if (sameInstance) {
7190
- await rm(ownerPath, { force: true });
7191
- await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
7532
+ if (!moved) await rm(ownerPath, { force: true });
7533
+ await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
7534
+ await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
7535
+ for (const name of (await readdir(lockPath).catch(() => [])).filter((entry) => RECOVERY_CLAIM.test(entry))) {
7536
+ if (join(lockPath, name) !== claimPath) await rm(join(lockPath, name), { force: true });
7192
7537
  }
7193
- await rm(claimPath, { force: true });
7194
- await rmdir(lockPath).catch(() => void 0);
7195
- if (!sameInstance) return false;
7538
+ if (await unlink(claimPath).then(() => true, () => false)) await rmdir(lockPath).catch(() => void 0);
7196
7539
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7197
7540
  return true;
7198
7541
  }
7542
+ async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
7543
+ let owner;
7544
+ try {
7545
+ owner = JSON.parse(observed);
7546
+ } catch {
7547
+ owner = null;
7548
+ }
7549
+ if (typeof owner === "object" && owner !== null && "pid" in owner) {
7550
+ return isStaleOwner(owner, staleAfterMs);
7551
+ }
7552
+ const modifiedAt = await stat(ownerPath).then(({ mtimeMs }) => mtimeMs, () => Number.NaN);
7553
+ return Date.now() - modifiedAt >= staleAfterMs;
7554
+ }
7199
7555
  async function waitForTestBarrier(environmentName, profile) {
7200
7556
  if (process.env.NODE_ENV !== "test") return;
7201
7557
  const barrierDirectory = process.env[environmentName];
@@ -7222,6 +7578,15 @@ function isStaleOwner(owner, staleAfterMs) {
7222
7578
  }
7223
7579
  return !isProcessAlive(owner.pid);
7224
7580
  }
7581
+ async function exists(path) {
7582
+ try {
7583
+ await stat(path);
7584
+ return true;
7585
+ } catch (error) {
7586
+ if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) return false;
7587
+ throw error;
7588
+ }
7589
+ }
7225
7590
  function isProcessAlive(pid) {
7226
7591
  try {
7227
7592
  process.kill(pid, 0);
@@ -7310,6 +7675,7 @@ var DelegationImportArtifactSchema = z2.object({
7310
7675
  expiry: z2.string().datetime({ offset: true }).optional(),
7311
7676
  prompted: z2.boolean().optional()
7312
7677
  }).strict();
7678
+ var DelegationRequestBindingSchema = PermissionRequestArtifactSchema.pick({ requestId: true, requested: true }).strict();
7313
7679
  function validatePermissionRequestArtifact(value) {
7314
7680
  return PermissionRequestArtifactSchema.parse(value);
7315
7681
  }
@@ -7522,6 +7888,7 @@ function defaultRequestId(now) {
7522
7888
  }
7523
7889
  export {
7524
7890
  DelegationImportArtifactSchema,
7891
+ DelegationRequestBindingSchema,
7525
7892
  PermissionEntrySchema,
7526
7893
  PermissionRequestArtifactSchema,
7527
7894
  buildPermissionRequestArtifact,