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