@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.
@@ -6950,22 +6950,33 @@ var TEST_LOCK_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_PUBLISH_BARRIER_DIR";
6950
6950
  var TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
6951
6951
  var TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
6952
6952
  var TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
6953
- var TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
6954
6953
  var TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
6954
+ var TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
6955
+ var TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
6956
+ var TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
6957
+ var TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
6955
6958
  function processWideContext(key) {
6956
- var _a;
6959
+ var _a2;
6957
6960
  const registry = globalThis;
6958
- return registry[_a = Symbol.for(key)] ?? (registry[_a] = new import_node_async_hooks.AsyncLocalStorage());
6961
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new import_node_async_hooks.AsyncLocalStorage());
6959
6962
  }
6960
6963
  var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
6961
- var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v1");
6964
+ var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
6962
6965
  var ProfileLockTimeoutError = class extends Error {
6963
- constructor(profile, timeoutMs) {
6964
- super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.`);
6966
+ /** `detail` names the cause when it is not simply another process holding the lock. */
6967
+ constructor(profile, timeoutMs, detail) {
6968
+ super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.${detail ? ` ${detail}` : ""}`);
6965
6969
  this.code = "PROFILE_LOCK_TIMEOUT";
6966
6970
  this.name = "ProfileLockTimeoutError";
6967
6971
  }
6968
6972
  };
6973
+ var ProfileDeletedError = class extends Error {
6974
+ constructor(profile) {
6975
+ super(`Profile "${profile}" was deleted while this change waited for its lock, so the change was not written.`);
6976
+ this.code = "PROFILE_NOT_FOUND";
6977
+ this.name = "ProfileDeletedError";
6978
+ }
6979
+ };
6969
6980
  function tinycloudHomePath() {
6970
6981
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
6971
6982
  return (0, import_node_path.join)(home, ".tinycloud");
@@ -6976,6 +6987,9 @@ function profilesPath() {
6976
6987
  function profilePath(profile) {
6977
6988
  return (0, import_node_path.join)(profilesPath(), validateProfileName(profile));
6978
6989
  }
6990
+ function profileConfigPath(profile) {
6991
+ return (0, import_node_path.join)(profilePath(profile), "profile.json");
6992
+ }
6979
6993
  function sessionPath(profile) {
6980
6994
  return profileStorePath(profile, "session");
6981
6995
  }
@@ -6988,6 +7002,9 @@ function profileStoreMetadataPath(profile, store) {
6988
7002
  function profileLockPath(profile) {
6989
7003
  return (0, import_node_path.join)(profilePath(profile), ".lock");
6990
7004
  }
7005
+ function profileTurnLockPath(profile) {
7006
+ return (0, import_node_path.join)(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
7007
+ }
6991
7008
  async function readJson(filePath) {
6992
7009
  try {
6993
7010
  return JSON.parse(await (0, import_promises.readFile)(filePath, "utf8"));
@@ -6997,6 +7014,9 @@ async function readJson(filePath) {
6997
7014
  }
6998
7015
  }
6999
7016
  async function writeJsonAtomic(filePath, value) {
7017
+ await replaceJson(filePath, value, false);
7018
+ }
7019
+ async function replaceJson(filePath, value, durable) {
7000
7020
  const directory = (0, import_node_path.dirname)(filePath);
7001
7021
  const temporaryPath = (0, import_node_path.join)(
7002
7022
  directory,
@@ -7006,13 +7026,25 @@ async function writeJsonAtomic(filePath, value) {
7006
7026
  `;
7007
7027
  await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
7008
7028
  try {
7009
- await (0, import_promises.writeFile)(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
7029
+ if (durable) await writeFileDurably(temporaryPath, contents);
7030
+ else await (0, import_promises.writeFile)(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
7010
7031
  await (0, import_promises.rename)(temporaryPath, filePath);
7011
7032
  } catch (error) {
7012
7033
  await (0, import_promises.rm)(temporaryPath, { force: true }).catch(() => void 0);
7013
7034
  throw error;
7014
7035
  }
7015
7036
  }
7037
+ async function writeFileDurably(path, contents) {
7038
+ const handle = await (0, import_promises.open)(path, "wx", PRIVATE_FILE_MODE);
7039
+ try {
7040
+ if (contents) {
7041
+ await handle.writeFile(contents, "utf8");
7042
+ await handle.datasync();
7043
+ }
7044
+ } finally {
7045
+ await handle.close();
7046
+ }
7047
+ }
7016
7048
  async function readSession(profile) {
7017
7049
  await readStoreMetadata(profile, "session");
7018
7050
  return readJson(sessionPath(profile));
@@ -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);
7157
+ }
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);
7091
7216
  }
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);
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);