@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.
@@ -7086,20 +7086,22 @@ import {
7086
7086
  chmod,
7087
7087
  link,
7088
7088
  mkdir,
7089
+ open,
7089
7090
  readFile,
7090
7091
  readdir,
7091
7092
  rename,
7092
7093
  rmdir,
7093
7094
  rm,
7094
7095
  stat,
7096
+ unlink,
7095
7097
  writeFile
7096
7098
  } from "fs/promises";
7097
7099
  import { homedir } from "os";
7098
7100
  import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
7099
7101
  function processWideContext(key) {
7100
- var _a;
7102
+ var _a2;
7101
7103
  const registry = globalThis;
7102
- return registry[_a = Symbol.for(key)] ?? (registry[_a] = new AsyncLocalStorage());
7104
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new AsyncLocalStorage());
7103
7105
  }
7104
7106
  function tinycloudHomePath() {
7105
7107
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
@@ -7139,6 +7141,9 @@ function profileStoreMetadataPath(profile, store) {
7139
7141
  function profileLockPath(profile) {
7140
7142
  return join(profilePath(profile), ".lock");
7141
7143
  }
7144
+ function profileTurnLockPath(profile) {
7145
+ return join(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
7146
+ }
7142
7147
  async function readJson(filePath) {
7143
7148
  try {
7144
7149
  return JSON.parse(await readFile(filePath, "utf8"));
@@ -7148,6 +7153,9 @@ async function readJson(filePath) {
7148
7153
  }
7149
7154
  }
7150
7155
  async function writeJsonAtomic(filePath, value) {
7156
+ await replaceJson(filePath, value, false);
7157
+ }
7158
+ async function replaceJson(filePath, value, durable) {
7151
7159
  const directory = dirname(filePath);
7152
7160
  const temporaryPath = join(
7153
7161
  directory,
@@ -7157,13 +7165,25 @@ async function writeJsonAtomic(filePath, value) {
7157
7165
  `;
7158
7166
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
7159
7167
  try {
7160
- await writeFile(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
7168
+ if (durable) await writeFileDurably(temporaryPath, contents);
7169
+ else await writeFile(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
7161
7170
  await rename(temporaryPath, filePath);
7162
7171
  } catch (error) {
7163
7172
  await rm(temporaryPath, { force: true }).catch(() => void 0);
7164
7173
  throw error;
7165
7174
  }
7166
7175
  }
7176
+ async function writeFileDurably(path, contents) {
7177
+ const handle = await open(path, "wx", PRIVATE_FILE_MODE);
7178
+ try {
7179
+ if (contents) {
7180
+ await handle.writeFile(contents, "utf8");
7181
+ await handle.datasync();
7182
+ }
7183
+ } finally {
7184
+ await handle.close();
7185
+ }
7186
+ }
7167
7187
  async function readProfile(profile) {
7168
7188
  return readJson(profileConfigPath(profile));
7169
7189
  }
@@ -7200,15 +7220,28 @@ async function withProfileLock(profile, action, options = {}) {
7200
7220
  const lockPath = profileLockPath(normalizedProfile);
7201
7221
  const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
7202
7222
  if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
7203
- const release = await acquireProfileLock(normalizedProfile, options);
7204
- const ownership = { lockPath, active: true };
7223
+ const acquired = await acquireProfileLock(normalizedProfile, options);
7224
+ const ownership = {
7225
+ lockPath,
7226
+ turnSlot: acquired.turnSlot,
7227
+ deletedWhileWaiting: acquired.deletedWhileWaiting,
7228
+ active: true
7229
+ };
7205
7230
  try {
7206
7231
  return await heldProfileLocks.run([...held, ownership], action);
7207
7232
  } finally {
7208
7233
  ownership.active = false;
7209
- await release();
7234
+ await acquired.release();
7235
+ if (acquired.deletedWhileWaiting) await rmdir(profilePath(normalizedProfile)).catch(() => void 0);
7210
7236
  }
7211
7237
  }
7238
+ async function refuseWriteToDeletedProfile(profile) {
7239
+ const lockPath = profileLockPath(profile);
7240
+ const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
7241
+ if (!ownership?.deletedWhileWaiting) return;
7242
+ if (await exists(profileConfigPath(profile))) return;
7243
+ throw new ProfileDeletedError(profile);
7244
+ }
7212
7245
  async function updateProfileStore(profile, store, update, options = {}) {
7213
7246
  return withProfileLock(
7214
7247
  profile,
@@ -7217,6 +7250,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
7217
7250
  );
7218
7251
  }
7219
7252
  async function updateProfileStoreWhileLocked(profile, store, update) {
7253
+ await refuseWriteToDeletedProfile(profile);
7220
7254
  const current = await readProfileStore(profile, store);
7221
7255
  const next = await update(current.records);
7222
7256
  await writeJsonAtomic(profileStorePath(profile, store), next.records);
@@ -7227,32 +7261,271 @@ async function writeFormatOneMetadata(profile, store) {
7227
7261
  await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
7228
7262
  }
7229
7263
  async function acquireProfileLock(profile, options) {
7230
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
7231
- const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
7264
+ const deadline = {
7265
+ startedAt: Date.now(),
7266
+ timeoutMs: options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
7267
+ retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
7268
+ };
7232
7269
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
7233
- const startedAt = Date.now();
7234
7270
  const lockPath = profileLockPath(profile);
7271
+ const turnDirectory = profileTurnLockPath(profile);
7235
7272
  const directory = profilePath(profile);
7236
7273
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
7274
+ await mkdir(dirname(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
7237
7275
  for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
7238
7276
  await chmod(path, PRIVATE_DIR_MODE).catch((error) => {
7239
7277
  if (path !== directory || !isErrno(error, "ENOENT")) throw error;
7240
7278
  });
7241
7279
  }
7242
- while (true) {
7243
- const token = randomUUID();
7244
- if (await publishProfileLock(profile, lockPath, token)) {
7245
- return async () => {
7246
- await releaseProfileLock(profile, lockPath, token);
7247
- };
7280
+ const { turn, firstWaitedTurn } = await acquireTurn(profile, turnDirectory, deadline);
7281
+ try {
7282
+ const deletion = await readJson(join(turnDirectory, "deleted.json")).catch(() => null);
7283
+ const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
7284
+ while (true) {
7285
+ const token = randomUUID();
7286
+ if (await publishProfileLock(profile, lockPath, token)) {
7287
+ return {
7288
+ deletedWhileWaiting,
7289
+ turnSlot: turn.slot,
7290
+ release: async () => {
7291
+ try {
7292
+ await releaseProfileLock(profile, lockPath, token);
7293
+ } finally {
7294
+ await releaseTurn(profile, turn);
7295
+ }
7296
+ }
7297
+ };
7298
+ }
7299
+ await signalTestLockContention(profile);
7300
+ if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
7301
+ await waitOrTimeOut(profile, deadline);
7302
+ }
7303
+ } catch (error) {
7304
+ await releaseTurn(profile, turn);
7305
+ throw error;
7306
+ }
7307
+ }
7308
+ function checkDeadline(profile, deadline, detail) {
7309
+ if (Date.now() - deadline.startedAt >= deadline.timeoutMs) {
7310
+ throw new ProfileLockTimeoutError(profile, deadline.timeoutMs, detail);
7311
+ }
7312
+ }
7313
+ async function waitOrTimeOut(profile, deadline, detail) {
7314
+ checkDeadline(profile, deadline, detail);
7315
+ await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
7316
+ }
7317
+ async function acquireTurn(profile, directory, deadline) {
7318
+ let firstWaitedTurn;
7319
+ let problem;
7320
+ 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.`;
7321
+ for (let first = true; ; first = false) {
7322
+ if (!first) checkDeadline(profile, deadline, problem);
7323
+ problem = void 0;
7324
+ const latest = await readLatestTurn(directory);
7325
+ if (latest === "missing") {
7326
+ await createTurnDirectory(directory);
7327
+ continue;
7328
+ }
7329
+ if (latest === "empty") {
7330
+ problem = damaged("has no turns");
7331
+ await waitOrTimeOut(profile, deadline, problem);
7332
+ continue;
7333
+ }
7334
+ if (latest === null) continue;
7335
+ if (typeof latest === "number") {
7336
+ problem = damaged(`has an unreadable turn ${latest}`);
7337
+ await waitOrTimeOut(profile, deadline, problem);
7338
+ continue;
7339
+ }
7340
+ firstWaitedTurn ?? (firstWaitedTurn = latest.done ? latest.slot + 1 : latest.slot);
7341
+ if (latest.held && latest.done) {
7342
+ const turn = await takeTurn(profile, directory, latest);
7343
+ if (turn !== null) return { turn, firstWaitedTurn };
7344
+ await waitOrTimeOut(profile, deadline);
7345
+ } else if (latest.done) {
7346
+ problem = damaged(`ends with turn ${latest.slot}, which was never granted`);
7347
+ await waitOrTimeOut(profile, deadline, problem);
7348
+ } else if (latest.pid === process.pid ? relinquishedTurns.has(latest.token) : !isProcessAlive(latest.pid)) {
7349
+ await waitForTestBarrier(TEST_LOCK_TURN_SETTLE_BARRIER_DIR, profile);
7350
+ await settleAbandonedTurn(directory, latest);
7351
+ relinquishedTurns.delete(latest.token);
7352
+ } else {
7353
+ await signalTestLockContention(profile);
7354
+ await waitOrTimeOut(profile, deadline);
7355
+ }
7356
+ }
7357
+ }
7358
+ async function readLatestTurn(directory) {
7359
+ let names;
7360
+ try {
7361
+ names = await readdir(directory);
7362
+ } catch (error) {
7363
+ if (isErrno(error, "ENOENT")) return "missing";
7364
+ throw error;
7365
+ }
7366
+ const slots = names.filter((name) => TURN.test(name)).map(Number);
7367
+ if (slots.length === 0) return "empty";
7368
+ const slot = Math.max(...slots);
7369
+ const turn = await readTurn(directory, slot);
7370
+ return turn === "damaged" ? slot : turn;
7371
+ }
7372
+ async function readTurn(directory, slot) {
7373
+ const path = join(directory, String(slot));
7374
+ let text;
7375
+ try {
7376
+ text = await readFile(join(path, "owner.json"), "utf8");
7377
+ } catch (error) {
7378
+ if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTDIR")) throw error;
7379
+ return await exists(path) ? "damaged" : null;
7380
+ }
7381
+ let owner;
7382
+ try {
7383
+ owner = JSON.parse(text);
7384
+ } catch {
7385
+ return "damaged";
7386
+ }
7387
+ if (owner === null || typeof owner !== "object" || typeof owner.token !== "string" || typeof owner.pid !== "number") {
7388
+ return "damaged";
7389
+ }
7390
+ const done = await exists(join(path, `${owner.token}.done`));
7391
+ const held = await exists(join(path, `${owner.token}.held`));
7392
+ return {
7393
+ slot,
7394
+ token: owner.token,
7395
+ pid: owner.pid,
7396
+ after: typeof owner.after === "string" ? owner.after : null,
7397
+ held,
7398
+ done
7399
+ };
7400
+ }
7401
+ async function isFinishedTurn(directory, slot, token) {
7402
+ const turn = await readTurn(directory, slot);
7403
+ if (turn === "damaged") throw new Error(`Turn ${slot} of the turn lock ${directory} is unreadable.`);
7404
+ return turn !== null && turn.token === token && turn.held && turn.done;
7405
+ }
7406
+ async function takeTurn(profile, directory, latest) {
7407
+ const token = randomUUID();
7408
+ const slot = latest.slot + 1;
7409
+ const path = join(directory, String(slot));
7410
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token };
7411
+ let published = false;
7412
+ try {
7413
+ published = await publishDirectory(
7414
+ join(directory, `.stage-${process.pid}-${token}`),
7415
+ path,
7416
+ { "owner.json": `${JSON.stringify(owner, null, 2)}
7417
+ ` },
7418
+ () => waitForTestBarrier(TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, profile)
7419
+ );
7420
+ if (published && await isFinishedTurn(directory, latest.slot, latest.token) && await markTurn(path, token, "held")) {
7421
+ return { directory, slot, token };
7422
+ }
7423
+ if (published) await markTurn(path, token, "done");
7424
+ return null;
7425
+ } catch (error) {
7426
+ if (published) settleOwnTurn(directory, slot, token);
7427
+ throw error;
7428
+ }
7429
+ }
7430
+ async function settleAbandonedTurn(directory, abandoned) {
7431
+ const path = join(directory, String(abandoned.slot));
7432
+ if (!abandoned.held && abandoned.after !== null && await isFinishedTurn(directory, abandoned.slot - 1, abandoned.after)) {
7433
+ await markTurn(path, abandoned.token, "held");
7434
+ }
7435
+ await markTurn(path, abandoned.token, "done");
7436
+ }
7437
+ function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
7438
+ relinquishedTurns.add(token);
7439
+ void (async () => {
7440
+ try {
7441
+ const turn = await readTurn(directory, slot);
7442
+ if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
7443
+ relinquishedTurns.delete(token);
7444
+ } catch {
7445
+ if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
7446
+ }
7447
+ })();
7448
+ }
7449
+ async function markTurn(path, token, kind) {
7450
+ for (let attempt = 0; ; attempt++) {
7451
+ try {
7452
+ await writeFile(join(path, `${token}.${kind}`), "", { mode: PRIVATE_FILE_MODE, flag: "wx" });
7453
+ return true;
7454
+ } catch (error) {
7455
+ if (isErrno(error, "EEXIST")) return true;
7456
+ if (isErrno(error, "ENOENT")) return false;
7457
+ if (attempt >= MARKER_RETRY_DELAYS_MS.length) throw error;
7458
+ await sleep(MARKER_RETRY_DELAYS_MS[attempt]);
7459
+ }
7460
+ }
7461
+ }
7462
+ async function createTurnDirectory(directory) {
7463
+ const token = randomUUID();
7464
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null };
7465
+ await publishDirectory(join(dirname(directory), `.stage-${basename(directory)}-${process.pid}-${token}`), directory, {
7466
+ "0/owner.json": `${JSON.stringify(owner, null, 2)}
7467
+ `,
7468
+ [`0/${token}.held`]: "",
7469
+ [`0/${token}.done`]: ""
7470
+ });
7471
+ }
7472
+ async function publishDirectory(stage, target, files, beforeRename) {
7473
+ try {
7474
+ for (const [name, contents] of Object.entries(files)) {
7475
+ await mkdir(dirname(join(stage, name)), { recursive: true, mode: PRIVATE_DIR_MODE });
7476
+ await writeFileDurably(join(stage, name), contents);
7477
+ }
7478
+ await beforeRename?.();
7479
+ await rename(stage, target);
7480
+ return true;
7481
+ } catch (error) {
7482
+ if (["ENOTEMPTY", "EEXIST", "ENOENT"].some((code) => isErrno(error, code)) || await exists(target)) return false;
7483
+ throw error;
7484
+ } finally {
7485
+ await rm(stage, { recursive: true, force: true }).catch(() => void 0);
7486
+ }
7487
+ }
7488
+ async function releaseTurn(profile, turn) {
7489
+ try {
7490
+ await markTurn(join(turn.directory, String(turn.slot)), turn.token, "done");
7491
+ } catch {
7492
+ settleOwnTurn(turn.directory, turn.slot, turn.token);
7493
+ return;
7494
+ }
7495
+ await collectTurns(profile, turn).catch(() => void 0);
7496
+ }
7497
+ function isAbandonedTurnEntry(name) {
7498
+ if (name.startsWith(".trash-")) return true;
7499
+ const writer = /^\.(?:stage|deleted\.json)[-.]([0-9]+)[-.]/.exec(name)?.[1];
7500
+ return writer !== void 0 && !isProcessAlive(Number(writer));
7501
+ }
7502
+ function isAbandonedTurnDirectoryStage(name) {
7503
+ 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];
7504
+ return writer !== void 0 && !isProcessAlive(Number(writer));
7505
+ }
7506
+ async function collectTurns(profile, turn) {
7507
+ for (const [directory, abandoned] of [
7508
+ [turn.directory, isAbandonedTurnEntry],
7509
+ [dirname(turn.directory), isAbandonedTurnDirectoryStage]
7510
+ ]) {
7511
+ for (const name of (await readdir(directory)).filter(abandoned)) {
7512
+ await rm(join(directory, name), { recursive: true, force: true }).catch(() => void 0);
7513
+ }
7514
+ }
7515
+ const older = (await readdir(turn.directory)).filter((name) => TURN.test(name)).map(Number).filter((slot) => slot < turn.slot).sort((left, right) => left - right);
7516
+ for (const slot of older) {
7517
+ const candidate = await readTurn(turn.directory, slot);
7518
+ if (candidate === null || candidate === "damaged" || !candidate.done) continue;
7519
+ if (candidate.held) {
7520
+ if (await exists(join(turn.directory, String(slot - 1)))) continue;
7521
+ const next = await readTurn(turn.directory, slot + 1);
7522
+ if (next === null || next === "damaged" || !next.held || !next.done) continue;
7248
7523
  }
7249
- await signalTestLockContention(profile);
7250
- if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
7251
- const elapsedMs = Date.now() - startedAt;
7252
- if (elapsedMs >= timeoutMs) {
7253
- throw new ProfileLockTimeoutError(profile, timeoutMs);
7524
+ await waitForTestBarrier(TEST_LOCK_TURN_COLLECT_BARRIER_DIR, profile);
7525
+ const trash = join(turn.directory, `.trash-${randomUUID()}`);
7526
+ if (await rename(join(turn.directory, String(slot)), trash).then(() => true, () => false)) {
7527
+ await rm(trash, { recursive: true, force: true }).catch(() => void 0);
7254
7528
  }
7255
- await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
7256
7529
  }
7257
7530
  }
7258
7531
  async function publishProfileLock(profile, lockPath, token) {
@@ -7265,12 +7538,18 @@ async function publishProfileLock(profile, lockPath, token) {
7265
7538
  return false;
7266
7539
  }
7267
7540
  await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
7541
+ const ownerPath = join(lockPath, "owner.json");
7268
7542
  const staged = join(dirname(lockPath), `.lock-owner-${token}.tmp`);
7269
- const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token };
7543
+ const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }, null, 2)}
7544
+ `;
7270
7545
  try {
7271
- await writeFile(staged, `${JSON.stringify(owner, null, 2)}
7272
- `, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7273
- await link(staged, join(lockPath, "owner.json"));
7546
+ await writeFile(staged, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7547
+ try {
7548
+ await link(staged, ownerPath);
7549
+ } catch (error) {
7550
+ if (!lacksHardLinks(error)) throw error;
7551
+ await writeFile(ownerPath, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7552
+ }
7274
7553
  return true;
7275
7554
  } catch (error) {
7276
7555
  if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
@@ -7280,6 +7559,9 @@ async function publishProfileLock(profile, lockPath, token) {
7280
7559
  await rm(staged, { force: true }).catch(() => void 0);
7281
7560
  }
7282
7561
  }
7562
+ function lacksHardLinks(error) {
7563
+ return typeof error === "object" && error !== null && "code" in error && NO_HARD_LINK_ERRORS.has(String(error.code));
7564
+ }
7283
7565
  async function releaseProfileLock(profile, lockPath, token) {
7284
7566
  const ownerPath = join(lockPath, "owner.json");
7285
7567
  const owner = await readJson(ownerPath).catch(() => null);
@@ -7303,9 +7585,10 @@ async function signalTestLockContention(profile) {
7303
7585
  `, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
7304
7586
  }
7305
7587
  async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7588
+ let aged;
7306
7589
  try {
7307
7590
  const { mtimeMs } = await stat(lockPath);
7308
- if (Date.now() - mtimeMs < staleAfterMs) return false;
7591
+ aged = Date.now() - mtimeMs >= staleAfterMs;
7309
7592
  } catch {
7310
7593
  return false;
7311
7594
  }
@@ -7314,7 +7597,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7314
7597
  try {
7315
7598
  const entries = await readdir(lockPath);
7316
7599
  if (entries.includes("owner.json")) return false;
7317
- if (recoveryFenced(checkedAt, staleAfterMs)) return false;
7600
+ const onlyOrphanedClaims = entries.length > 0 && entries.every((entry) => RECOVERY_CLAIM.test(entry));
7601
+ if (!onlyOrphanedClaims && (!aged || recoveryFenced(checkedAt, staleAfterMs))) return false;
7602
+ for (const name of entries.filter((entry) => RECOVERY_CLAIM.test(entry))) {
7603
+ const claimPath = join(lockPath, name);
7604
+ if (await hasLiveRecoveryOwner(claimPath)) {
7605
+ if (!await exists(join(lockPath, "owner.json"))) await rename(claimPath, join(lockPath, "owner.json"));
7606
+ return false;
7607
+ }
7608
+ await rm(claimPath, { force: true });
7609
+ }
7318
7610
  for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
7319
7611
  await rm(join(lockPath, name), { force: true });
7320
7612
  }
@@ -7325,6 +7617,23 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7325
7617
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7326
7618
  return true;
7327
7619
  }
7620
+ async function hasLiveRecoveryOwner(claimPath) {
7621
+ let contents;
7622
+ try {
7623
+ contents = await readFile(claimPath, "utf8");
7624
+ } catch (error) {
7625
+ if (isErrno(error, "ENOENT")) return false;
7626
+ throw error;
7627
+ }
7628
+ let owner;
7629
+ try {
7630
+ owner = JSON.parse(contents);
7631
+ } catch {
7632
+ return false;
7633
+ }
7634
+ const pid = typeof owner === "object" && owner !== null && "pid" in owner ? owner.pid : null;
7635
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && isProcessAlive(pid);
7636
+ }
7328
7637
  function recoveryFenced(since, staleAfterMs) {
7329
7638
  return performance.now() - since >= staleAfterMs / 2;
7330
7639
  }
@@ -7339,37 +7648,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
7339
7648
  }
7340
7649
  async function recoverStaleLock(profile, lockPath, staleAfterMs) {
7341
7650
  const ownerPath = join(lockPath, "owner.json");
7342
- const owner = await readJson(ownerPath).catch(() => null);
7343
- if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
7344
- if (!isStaleOwner(owner, staleAfterMs)) return false;
7345
- const observedToken = typeof owner?.token === "string" && owner.token.length > 0 ? owner.token : "legacy";
7651
+ const observed = await readFile(ownerPath, "utf8").catch(() => null);
7652
+ if (observed === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
7653
+ if (!await isAbandonedOwner(observed, ownerPath, staleAfterMs)) return false;
7346
7654
  await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
7347
- const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);
7655
+ const claimPath = join(lockPath, `.recover-${randomUUID()}.json`);
7656
+ let moved = false;
7348
7657
  try {
7349
7658
  await link(ownerPath, claimPath);
7350
- } catch {
7351
- return false;
7659
+ } catch (error) {
7660
+ if (!lacksHardLinks(error)) return false;
7661
+ try {
7662
+ await rename(ownerPath, claimPath);
7663
+ moved = true;
7664
+ } catch {
7665
+ return false;
7666
+ }
7352
7667
  }
7353
- const claimedAt = performance.now();
7354
- await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
7355
- const claimed = await readJson(claimPath).catch(() => null);
7356
- const sameInstance = claimed !== null && (observedToken === "legacy" ? claimed.token === void 0 : claimed.token === observedToken);
7668
+ if (moved) await waitForTestBarrier(TEST_LOCK_MOVED_BARRIER_DIR, profile);
7669
+ const sameInstance = await readFile(claimPath, "utf8").then((claimed) => claimed === observed, () => false);
7357
7670
  await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
7358
- if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
7359
- await rm(claimPath, { force: true });
7360
- await waitForTestBarrier(TEST_LOCK_FENCED_BARRIER_DIR, profile);
7671
+ if (!sameInstance) {
7672
+ if (moved) {
7673
+ await rename(claimPath, ownerPath).catch(() => void 0);
7674
+ } else {
7675
+ await unlink(claimPath).catch(() => void 0);
7676
+ }
7361
7677
  return false;
7362
7678
  }
7363
- if (sameInstance) {
7364
- await rm(ownerPath, { force: true });
7365
- await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
7679
+ if (!moved) await rm(ownerPath, { force: true });
7680
+ await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
7681
+ await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
7682
+ for (const name of (await readdir(lockPath).catch(() => [])).filter((entry) => RECOVERY_CLAIM.test(entry))) {
7683
+ if (join(lockPath, name) !== claimPath) await rm(join(lockPath, name), { force: true });
7366
7684
  }
7367
- await rm(claimPath, { force: true });
7368
- await rmdir(lockPath).catch(() => void 0);
7369
- if (!sameInstance) return false;
7685
+ if (await unlink(claimPath).then(() => true, () => false)) await rmdir(lockPath).catch(() => void 0);
7370
7686
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7371
7687
  return true;
7372
7688
  }
7689
+ async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
7690
+ let owner;
7691
+ try {
7692
+ owner = JSON.parse(observed);
7693
+ } catch {
7694
+ owner = null;
7695
+ }
7696
+ if (typeof owner === "object" && owner !== null && "pid" in owner) {
7697
+ return isStaleOwner(owner, staleAfterMs);
7698
+ }
7699
+ const modifiedAt = await stat(ownerPath).then(({ mtimeMs }) => mtimeMs, () => Number.NaN);
7700
+ return Date.now() - modifiedAt >= staleAfterMs;
7701
+ }
7373
7702
  async function waitForTestBarrier(environmentName, profile) {
7374
7703
  if (process.env.NODE_ENV !== "test") return;
7375
7704
  const barrierDirectory = process.env[environmentName];
@@ -7396,6 +7725,15 @@ function isStaleOwner(owner, staleAfterMs) {
7396
7725
  }
7397
7726
  return !isProcessAlive(owner.pid);
7398
7727
  }
7728
+ async function exists(path) {
7729
+ try {
7730
+ await stat(path);
7731
+ return true;
7732
+ } catch (error) {
7733
+ if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) return false;
7734
+ throw error;
7735
+ }
7736
+ }
7399
7737
  function isProcessAlive(pid) {
7400
7738
  try {
7401
7739
  process.kill(pid, 0);
@@ -7416,7 +7754,7 @@ function isErrno(error, code) {
7416
7754
  function sleep(milliseconds) {
7417
7755
  return new Promise((resolve2) => setTimeout(resolve2, milliseconds));
7418
7756
  }
7419
- var DEFAULT_PROFILE, DEFAULT_LOCK_TIMEOUT_MS, DEFAULT_LOCK_RETRY_MS, DEFAULT_STALE_LOCK_MS, TEST_LOCK_CONTENTION_SIGNAL_PATH, PRIVATE_FILE_MODE, PRIVATE_DIR_MODE, TEST_LOCK_RECOVERY_BARRIER_DIR, TEST_LOCK_OWNERLESS_BARRIER_DIR, TEST_LOCK_PUBLISH_BARRIER_DIR, TEST_LOCK_RELEASE_BARRIER_DIR, TEST_LOCK_CLAIM_BARRIER_DIR, TEST_LOCK_CLAIMED_BARRIER_DIR, TEST_LOCK_FENCED_BARRIER_DIR, TEST_LOCK_VERIFIED_BARRIER_DIR, invocationStateRoot, heldProfileLocks, ProfileLockTimeoutError, ABANDONED_CLAIM, STAGED_OWNER;
7757
+ var DEFAULT_PROFILE, DEFAULT_LOCK_TIMEOUT_MS, DEFAULT_LOCK_RETRY_MS, DEFAULT_STALE_LOCK_MS, TEST_LOCK_CONTENTION_SIGNAL_PATH, PRIVATE_FILE_MODE, PRIVATE_DIR_MODE, TEST_LOCK_RECOVERY_BARRIER_DIR, TEST_LOCK_OWNERLESS_BARRIER_DIR, TEST_LOCK_PUBLISH_BARRIER_DIR, TEST_LOCK_RELEASE_BARRIER_DIR, TEST_LOCK_CLAIM_BARRIER_DIR, TEST_LOCK_CLAIMED_BARRIER_DIR, TEST_LOCK_VERIFIED_BARRIER_DIR, TEST_LOCK_MOVED_BARRIER_DIR, TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, TEST_LOCK_TURN_SETTLE_BARRIER_DIR, TEST_LOCK_TURN_COLLECT_BARRIER_DIR, invocationStateRoot, heldProfileLocks, ProfileLockTimeoutError, ProfileDeletedError, TURN, _a, relinquishedTurns, MARKER_RETRY_DELAYS_MS, SETTLE_RETRY_MS, SETTLE_RETRIES, NO_HARD_LINK_ERRORS, ABANDONED_CLAIM, RECOVERY_CLAIM, STAGED_OWNER;
7420
7758
  var init_state = __esm({
7421
7759
  "src/state.ts"() {
7422
7760
  "use strict";
@@ -7433,18 +7771,36 @@ var init_state = __esm({
7433
7771
  TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
7434
7772
  TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
7435
7773
  TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
7436
- TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
7437
7774
  TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
7775
+ TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
7776
+ TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
7777
+ TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
7778
+ TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
7438
7779
  invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
7439
- heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v1");
7780
+ heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
7440
7781
  ProfileLockTimeoutError = class extends Error {
7441
- constructor(profile, timeoutMs) {
7442
- super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.`);
7782
+ /** `detail` names the cause when it is not simply another process holding the lock. */
7783
+ constructor(profile, timeoutMs, detail) {
7784
+ super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.${detail ? ` ${detail}` : ""}`);
7443
7785
  this.code = "PROFILE_LOCK_TIMEOUT";
7444
7786
  this.name = "ProfileLockTimeoutError";
7445
7787
  }
7446
7788
  };
7789
+ ProfileDeletedError = class extends Error {
7790
+ constructor(profile) {
7791
+ super(`Profile "${profile}" was deleted while this change waited for its lock, so the change was not written.`);
7792
+ this.code = "PROFILE_NOT_FOUND";
7793
+ this.name = "ProfileDeletedError";
7794
+ }
7795
+ };
7796
+ TURN = /^(?:0|[1-9][0-9]*)$/;
7797
+ relinquishedTurns = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Set());
7798
+ MARKER_RETRY_DELAYS_MS = [10, 50, 250];
7799
+ SETTLE_RETRY_MS = 1e3;
7800
+ SETTLE_RETRIES = 300;
7801
+ NO_HARD_LINK_ERRORS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EISDIR", "EMLINK"]);
7447
7802
  ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
7803
+ RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
7448
7804
  STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
7449
7805
  }
7450
7806
  });