@tinycloud/operations 0.4.0-beta.7 → 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,22 +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";
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";
6948
6953
  function processWideContext(key) {
6949
- var _a;
6954
+ var _a2;
6950
6955
  const registry = globalThis;
6951
- return registry[_a = Symbol.for(key)] ?? (registry[_a] = new AsyncLocalStorage());
6956
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new AsyncLocalStorage());
6952
6957
  }
6953
6958
  var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
6954
- var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v1");
6959
+ var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
6955
6960
  var ProfileLockTimeoutError = class extends Error {
6956
- constructor(profile, timeoutMs) {
6957
- 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}` : ""}`);
6958
6964
  this.code = "PROFILE_LOCK_TIMEOUT";
6959
6965
  this.name = "ProfileLockTimeoutError";
6960
6966
  }
6961
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
+ };
6962
6975
  function tinycloudHomePath() {
6963
6976
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
6964
6977
  return join(home, ".tinycloud");
@@ -6969,6 +6982,9 @@ function profilesPath() {
6969
6982
  function profilePath(profile) {
6970
6983
  return join(profilesPath(), validateProfileName(profile));
6971
6984
  }
6985
+ function profileConfigPath(profile) {
6986
+ return join(profilePath(profile), "profile.json");
6987
+ }
6972
6988
  function profileStorePath(profile, store) {
6973
6989
  return join(profilePath(profile), `${store}.json`);
6974
6990
  }
@@ -6978,6 +6994,9 @@ function profileStoreMetadataPath(profile, store) {
6978
6994
  function profileLockPath(profile) {
6979
6995
  return join(profilePath(profile), ".lock");
6980
6996
  }
6997
+ function profileTurnLockPath(profile) {
6998
+ return join(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
6999
+ }
6981
7000
  async function readJson(filePath) {
6982
7001
  try {
6983
7002
  return JSON.parse(await readFile(filePath, "utf8"));
@@ -6987,6 +7006,9 @@ async function readJson(filePath) {
6987
7006
  }
6988
7007
  }
6989
7008
  async function writeJsonAtomic(filePath, value) {
7009
+ await replaceJson(filePath, value, false);
7010
+ }
7011
+ async function replaceJson(filePath, value, durable) {
6990
7012
  const directory = dirname(filePath);
6991
7013
  const temporaryPath = join(
6992
7014
  directory,
@@ -6996,13 +7018,25 @@ async function writeJsonAtomic(filePath, value) {
6996
7018
  `;
6997
7019
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
6998
7020
  try {
6999
- 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 });
7000
7023
  await rename(temporaryPath, filePath);
7001
7024
  } catch (error) {
7002
7025
  await rm(temporaryPath, { force: true }).catch(() => void 0);
7003
7026
  throw error;
7004
7027
  }
7005
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
+ }
7006
7040
  async function readAuthRequests(profile) {
7007
7041
  return (await readProfileStore(profile, "auth-requests")).records;
7008
7042
  }
@@ -7029,15 +7063,28 @@ async function withProfileLock(profile, action, options = {}) {
7029
7063
  const lockPath = profileLockPath(normalizedProfile);
7030
7064
  const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
7031
7065
  if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
7032
- const release = await acquireProfileLock(normalizedProfile, options);
7033
- 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
+ };
7034
7073
  try {
7035
7074
  return await heldProfileLocks.run([...held, ownership], action);
7036
7075
  } finally {
7037
7076
  ownership.active = false;
7038
- await release();
7077
+ await acquired.release();
7078
+ if (acquired.deletedWhileWaiting) await rmdir(profilePath(normalizedProfile)).catch(() => void 0);
7039
7079
  }
7040
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
+ }
7041
7088
  async function updateProfileStore(profile, store, update, options = {}) {
7042
7089
  return withProfileLock(
7043
7090
  profile,
@@ -7046,6 +7093,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
7046
7093
  );
7047
7094
  }
7048
7095
  async function updateProfileStoreWhileLocked(profile, store, update) {
7096
+ await refuseWriteToDeletedProfile(profile);
7049
7097
  const current = await readProfileStore(profile, store);
7050
7098
  const next = await update(current.records);
7051
7099
  await writeJsonAtomic(profileStorePath(profile, store), next.records);
@@ -7056,32 +7104,277 @@ async function writeFormatOneMetadata(profile, store) {
7056
7104
  await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
7057
7105
  }
7058
7106
  async function acquireProfileLock(profile, options) {
7059
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
7060
- 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
+ };
7061
7112
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
7062
- const startedAt = Date.now();
7063
7113
  const lockPath = profileLockPath(profile);
7114
+ const turnDirectory = profileTurnLockPath(profile);
7064
7115
  const directory = profilePath(profile);
7065
7116
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
7117
+ await mkdir(dirname(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
7066
7118
  for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
7067
7119
  await chmod(path, PRIVATE_DIR_MODE).catch((error) => {
7068
7120
  if (path !== directory || !isErrno(error, "ENOENT")) throw error;
7069
7121
  });
7070
7122
  }
7071
- while (true) {
7072
- const token = randomUUID();
7073
- if (await publishProfileLock(profile, lockPath, token)) {
7074
- return async () => {
7075
- await releaseProfileLock(profile, lockPath, token);
7076
- };
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);
7077
7145
  }
7078
- await signalTestLockContention(profile);
7079
- if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
7080
- const elapsedMs = Date.now() - startedAt;
7081
- if (elapsedMs >= timeoutMs) {
7082
- 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);
7083
7377
  }
7084
- await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
7085
7378
  }
7086
7379
  }
7087
7380
  async function publishProfileLock(profile, lockPath, token) {
@@ -7094,12 +7387,18 @@ async function publishProfileLock(profile, lockPath, token) {
7094
7387
  return false;
7095
7388
  }
7096
7389
  await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
7390
+ const ownerPath = join(lockPath, "owner.json");
7097
7391
  const staged = join(dirname(lockPath), `.lock-owner-${token}.tmp`);
7098
- 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
+ `;
7099
7394
  try {
7100
- await writeFile(staged, `${JSON.stringify(owner, null, 2)}
7101
- `, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7102
- 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
+ }
7103
7402
  return true;
7104
7403
  } catch (error) {
7105
7404
  if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
@@ -7109,6 +7408,10 @@ async function publishProfileLock(profile, lockPath, token) {
7109
7408
  await rm(staged, { force: true }).catch(() => void 0);
7110
7409
  }
7111
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
+ }
7112
7415
  async function releaseProfileLock(profile, lockPath, token) {
7113
7416
  const ownerPath = join(lockPath, "owner.json");
7114
7417
  const owner = await readJson(ownerPath).catch(() => null);
@@ -7132,9 +7435,10 @@ async function signalTestLockContention(profile) {
7132
7435
  `, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
7133
7436
  }
7134
7437
  async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7438
+ let aged;
7135
7439
  try {
7136
7440
  const { mtimeMs } = await stat(lockPath);
7137
- if (Date.now() - mtimeMs < staleAfterMs) return false;
7441
+ aged = Date.now() - mtimeMs >= staleAfterMs;
7138
7442
  } catch {
7139
7443
  return false;
7140
7444
  }
@@ -7143,7 +7447,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7143
7447
  try {
7144
7448
  const entries = await readdir(lockPath);
7145
7449
  if (entries.includes("owner.json")) return false;
7146
- 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
+ }
7147
7460
  for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
7148
7461
  await rm(join(lockPath, name), { force: true });
7149
7462
  }
@@ -7154,10 +7467,28 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7154
7467
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7155
7468
  return true;
7156
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
+ }
7157
7487
  function recoveryFenced(since, staleAfterMs) {
7158
7488
  return performance.now() - since >= staleAfterMs / 2;
7159
7489
  }
7160
7490
  var ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
7491
+ var RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
7161
7492
  var STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
7162
7493
  async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
7163
7494
  const directory = dirname(lockPath);
@@ -7170,37 +7501,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
7170
7501
  }
7171
7502
  async function recoverStaleLock(profile, lockPath, staleAfterMs) {
7172
7503
  const ownerPath = join(lockPath, "owner.json");
7173
- const owner = await readJson(ownerPath).catch(() => null);
7174
- if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
7175
- if (!isStaleOwner(owner, staleAfterMs)) return false;
7176
- 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;
7177
7507
  await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
7178
- const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);
7508
+ const claimPath = join(lockPath, `.recover-${randomUUID()}.json`);
7509
+ let moved = false;
7179
7510
  try {
7180
7511
  await link(ownerPath, claimPath);
7181
- } catch {
7182
- 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
+ }
7183
7520
  }
7184
- const claimedAt = performance.now();
7185
- await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
7186
- const claimed = await readJson(claimPath).catch(() => null);
7187
- 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);
7188
7523
  await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
7189
- if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
7190
- await rm(claimPath, { force: true });
7191
- 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
+ }
7192
7530
  return false;
7193
7531
  }
7194
- if (sameInstance) {
7195
- await rm(ownerPath, { force: true });
7196
- 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 });
7197
7537
  }
7198
- await rm(claimPath, { force: true });
7199
- await rmdir(lockPath).catch(() => void 0);
7200
- if (!sameInstance) return false;
7538
+ if (await unlink(claimPath).then(() => true, () => false)) await rmdir(lockPath).catch(() => void 0);
7201
7539
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7202
7540
  return true;
7203
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
+ }
7204
7555
  async function waitForTestBarrier(environmentName, profile) {
7205
7556
  if (process.env.NODE_ENV !== "test") return;
7206
7557
  const barrierDirectory = process.env[environmentName];
@@ -7227,6 +7578,15 @@ function isStaleOwner(owner, staleAfterMs) {
7227
7578
  }
7228
7579
  return !isProcessAlive(owner.pid);
7229
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
+ }
7230
7590
  function isProcessAlive(pid) {
7231
7591
  try {
7232
7592
  process.kill(pid, 0);