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