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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -188,6 +188,7 @@ var require_decrypt_transport_response_error = __commonJS({
188
188
  var artifacts_exports = {};
189
189
  __export(artifacts_exports, {
190
190
  DelegationImportArtifactSchema: () => DelegationImportArtifactSchema,
191
+ DelegationRequestBindingSchema: () => DelegationRequestBindingSchema,
191
192
  PermissionEntrySchema: () => PermissionEntrySchema,
192
193
  PermissionRequestArtifactSchema: () => PermissionRequestArtifactSchema,
193
194
  buildPermissionRequestArtifact: () => buildPermissionRequestArtifact,
@@ -6956,17 +6957,33 @@ var TEST_LOCK_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_PUBLISH_BARRIER_DIR";
6956
6957
  var TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
6957
6958
  var TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
6958
6959
  var TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
6959
- var TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
6960
6960
  var TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
6961
- var invocationStateRoot = new import_node_async_hooks.AsyncLocalStorage();
6962
- var heldProfileLocks = new import_node_async_hooks.AsyncLocalStorage();
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";
6965
+ function processWideContext(key) {
6966
+ var _a2;
6967
+ const registry = globalThis;
6968
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new import_node_async_hooks.AsyncLocalStorage());
6969
+ }
6970
+ var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
6971
+ var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
6963
6972
  var ProfileLockTimeoutError = class extends Error {
6964
- constructor(profile, timeoutMs) {
6965
- 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}` : ""}`);
6966
6976
  this.code = "PROFILE_LOCK_TIMEOUT";
6967
6977
  this.name = "ProfileLockTimeoutError";
6968
6978
  }
6969
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
+ };
6970
6987
  function tinycloudHomePath() {
6971
6988
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
6972
6989
  return (0, import_node_path.join)(home, ".tinycloud");
@@ -6977,6 +6994,9 @@ function profilesPath() {
6977
6994
  function profilePath(profile) {
6978
6995
  return (0, import_node_path.join)(profilesPath(), validateProfileName(profile));
6979
6996
  }
6997
+ function profileConfigPath(profile) {
6998
+ return (0, import_node_path.join)(profilePath(profile), "profile.json");
6999
+ }
6980
7000
  function profileStorePath(profile, store) {
6981
7001
  return (0, import_node_path.join)(profilePath(profile), `${store}.json`);
6982
7002
  }
@@ -6986,6 +7006,9 @@ function profileStoreMetadataPath(profile, store) {
6986
7006
  function profileLockPath(profile) {
6987
7007
  return (0, import_node_path.join)(profilePath(profile), ".lock");
6988
7008
  }
7009
+ function profileTurnLockPath(profile) {
7010
+ return (0, import_node_path.join)(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
7011
+ }
6989
7012
  async function readJson(filePath) {
6990
7013
  try {
6991
7014
  return JSON.parse(await (0, import_promises.readFile)(filePath, "utf8"));
@@ -6995,6 +7018,9 @@ async function readJson(filePath) {
6995
7018
  }
6996
7019
  }
6997
7020
  async function writeJsonAtomic(filePath, value) {
7021
+ await replaceJson(filePath, value, false);
7022
+ }
7023
+ async function replaceJson(filePath, value, durable) {
6998
7024
  const directory = (0, import_node_path.dirname)(filePath);
6999
7025
  const temporaryPath = (0, import_node_path.join)(
7000
7026
  directory,
@@ -7004,13 +7030,25 @@ async function writeJsonAtomic(filePath, value) {
7004
7030
  `;
7005
7031
  await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
7006
7032
  try {
7007
- 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 });
7008
7035
  await (0, import_promises.rename)(temporaryPath, filePath);
7009
7036
  } catch (error) {
7010
7037
  await (0, import_promises.rm)(temporaryPath, { force: true }).catch(() => void 0);
7011
7038
  throw error;
7012
7039
  }
7013
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
+ }
7014
7052
  async function readAuthRequests(profile) {
7015
7053
  return (await readProfileStore(profile, "auth-requests")).records;
7016
7054
  }
@@ -7037,15 +7075,28 @@ async function withProfileLock(profile, action, options = {}) {
7037
7075
  const lockPath = profileLockPath(normalizedProfile);
7038
7076
  const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
7039
7077
  if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
7040
- const release = await acquireProfileLock(normalizedProfile, options);
7041
- 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
+ };
7042
7085
  try {
7043
7086
  return await heldProfileLocks.run([...held, ownership], action);
7044
7087
  } finally {
7045
7088
  ownership.active = false;
7046
- await release();
7089
+ await acquired.release();
7090
+ if (acquired.deletedWhileWaiting) await (0, import_promises.rmdir)(profilePath(normalizedProfile)).catch(() => void 0);
7047
7091
  }
7048
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
+ }
7049
7100
  async function updateProfileStore(profile, store, update, options = {}) {
7050
7101
  return withProfileLock(
7051
7102
  profile,
@@ -7054,6 +7105,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
7054
7105
  );
7055
7106
  }
7056
7107
  async function updateProfileStoreWhileLocked(profile, store, update) {
7108
+ await refuseWriteToDeletedProfile(profile);
7057
7109
  const current = await readProfileStore(profile, store);
7058
7110
  const next = await update(current.records);
7059
7111
  await writeJsonAtomic(profileStorePath(profile, store), next.records);
@@ -7064,32 +7116,277 @@ async function writeFormatOneMetadata(profile, store) {
7064
7116
  await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
7065
7117
  }
7066
7118
  async function acquireProfileLock(profile, options) {
7067
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
7068
- 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
+ };
7069
7124
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
7070
- const startedAt = Date.now();
7071
7125
  const lockPath = profileLockPath(profile);
7126
+ const turnDirectory = profileTurnLockPath(profile);
7072
7127
  const directory = profilePath(profile);
7073
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 });
7074
7130
  for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
7075
7131
  await (0, import_promises.chmod)(path, PRIVATE_DIR_MODE).catch((error) => {
7076
7132
  if (path !== directory || !isErrno(error, "ENOENT")) throw error;
7077
7133
  });
7078
7134
  }
7079
- while (true) {
7080
- const token = (0, import_node_crypto.randomUUID)();
7081
- if (await publishProfileLock(profile, lockPath, token)) {
7082
- return async () => {
7083
- await releaseProfileLock(profile, lockPath, token);
7084
- };
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);
7085
7157
  }
7086
- await signalTestLockContention(profile);
7087
- if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
7088
- const elapsedMs = Date.now() - startedAt;
7089
- if (elapsedMs >= timeoutMs) {
7090
- 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);
7091
7389
  }
7092
- await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
7093
7390
  }
7094
7391
  }
7095
7392
  async function publishProfileLock(profile, lockPath, token) {
@@ -7102,12 +7399,18 @@ async function publishProfileLock(profile, lockPath, token) {
7102
7399
  return false;
7103
7400
  }
7104
7401
  await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
7402
+ const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
7105
7403
  const staged = (0, import_node_path.join)((0, import_node_path.dirname)(lockPath), `.lock-owner-${token}.tmp`);
7106
- 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
+ `;
7107
7406
  try {
7108
- await (0, import_promises.writeFile)(staged, `${JSON.stringify(owner, null, 2)}
7109
- `, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7110
- 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
+ }
7111
7414
  return true;
7112
7415
  } catch (error) {
7113
7416
  if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
@@ -7117,6 +7420,10 @@ async function publishProfileLock(profile, lockPath, token) {
7117
7420
  await (0, import_promises.rm)(staged, { force: true }).catch(() => void 0);
7118
7421
  }
7119
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
+ }
7120
7427
  async function releaseProfileLock(profile, lockPath, token) {
7121
7428
  const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
7122
7429
  const owner = await readJson(ownerPath).catch(() => null);
@@ -7140,9 +7447,10 @@ async function signalTestLockContention(profile) {
7140
7447
  `, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
7141
7448
  }
7142
7449
  async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7450
+ let aged;
7143
7451
  try {
7144
7452
  const { mtimeMs } = await (0, import_promises.stat)(lockPath);
7145
- if (Date.now() - mtimeMs < staleAfterMs) return false;
7453
+ aged = Date.now() - mtimeMs >= staleAfterMs;
7146
7454
  } catch {
7147
7455
  return false;
7148
7456
  }
@@ -7151,7 +7459,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7151
7459
  try {
7152
7460
  const entries = await (0, import_promises.readdir)(lockPath);
7153
7461
  if (entries.includes("owner.json")) return false;
7154
- 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
+ }
7155
7472
  for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
7156
7473
  await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
7157
7474
  }
@@ -7162,10 +7479,28 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7162
7479
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7163
7480
  return true;
7164
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
+ }
7165
7499
  function recoveryFenced(since, staleAfterMs) {
7166
7500
  return performance.now() - since >= staleAfterMs / 2;
7167
7501
  }
7168
7502
  var ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
7503
+ var RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
7169
7504
  var STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
7170
7505
  async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
7171
7506
  const directory = (0, import_node_path.dirname)(lockPath);
@@ -7178,37 +7513,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
7178
7513
  }
7179
7514
  async function recoverStaleLock(profile, lockPath, staleAfterMs) {
7180
7515
  const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
7181
- const owner = await readJson(ownerPath).catch(() => null);
7182
- if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
7183
- if (!isStaleOwner(owner, staleAfterMs)) return false;
7184
- 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;
7185
7519
  await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
7186
- 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;
7187
7522
  try {
7188
7523
  await (0, import_promises.link)(ownerPath, claimPath);
7189
- } catch {
7190
- 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
+ }
7191
7532
  }
7192
- const claimedAt = performance.now();
7193
- await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
7194
- const claimed = await readJson(claimPath).catch(() => null);
7195
- 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);
7196
7535
  await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
7197
- if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
7198
- await (0, import_promises.rm)(claimPath, { force: true });
7199
- 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
+ }
7200
7542
  return false;
7201
7543
  }
7202
- if (sameInstance) {
7203
- await (0, import_promises.rm)(ownerPath, { force: true });
7204
- 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 });
7205
7549
  }
7206
- await (0, import_promises.rm)(claimPath, { force: true });
7207
- await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
7208
- if (!sameInstance) return false;
7550
+ if (await (0, import_promises.unlink)(claimPath).then(() => true, () => false)) await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
7209
7551
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7210
7552
  return true;
7211
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
+ }
7212
7567
  async function waitForTestBarrier(environmentName, profile) {
7213
7568
  if (process.env.NODE_ENV !== "test") return;
7214
7569
  const barrierDirectory = process.env[environmentName];
@@ -7235,6 +7590,15 @@ function isStaleOwner(owner, staleAfterMs) {
7235
7590
  }
7236
7591
  return !isProcessAlive(owner.pid);
7237
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
+ }
7238
7602
  function isProcessAlive(pid) {
7239
7603
  try {
7240
7604
  process.kill(pid, 0);
@@ -7323,6 +7687,7 @@ var DelegationImportArtifactSchema = import_zod2.z.object({
7323
7687
  expiry: import_zod2.z.string().datetime({ offset: true }).optional(),
7324
7688
  prompted: import_zod2.z.boolean().optional()
7325
7689
  }).strict();
7690
+ var DelegationRequestBindingSchema = PermissionRequestArtifactSchema.pick({ requestId: true, requested: true }).strict();
7326
7691
  function validatePermissionRequestArtifact(value) {
7327
7692
  return PermissionRequestArtifactSchema.parse(value);
7328
7693
  }
@@ -7536,6 +7901,7 @@ function defaultRequestId(now) {
7536
7901
  // Annotate the CommonJS export names for ESM import in node:
7537
7902
  0 && (module.exports = {
7538
7903
  DelegationImportArtifactSchema,
7904
+ DelegationRequestBindingSchema,
7539
7905
  PermissionEntrySchema,
7540
7906
  PermissionRequestArtifactSchema,
7541
7907
  buildPermissionRequestArtifact,