@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.
@@ -7083,9 +7083,9 @@ var init_authority = __esm({
7083
7083
 
7084
7084
  // src/state.ts
7085
7085
  function processWideContext(key) {
7086
- var _a;
7086
+ var _a2;
7087
7087
  const registry = globalThis;
7088
- return registry[_a = Symbol.for(key)] ?? (registry[_a] = new import_node_async_hooks.AsyncLocalStorage());
7088
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new import_node_async_hooks.AsyncLocalStorage());
7089
7089
  }
7090
7090
  function tinycloudHomePath() {
7091
7091
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
@@ -7125,6 +7125,9 @@ function profileStoreMetadataPath(profile, store) {
7125
7125
  function profileLockPath(profile) {
7126
7126
  return (0, import_node_path.join)(profilePath(profile), ".lock");
7127
7127
  }
7128
+ function profileTurnLockPath(profile) {
7129
+ return (0, import_node_path.join)(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
7130
+ }
7128
7131
  async function readJson(filePath) {
7129
7132
  try {
7130
7133
  return JSON.parse(await (0, import_promises.readFile)(filePath, "utf8"));
@@ -7134,6 +7137,9 @@ async function readJson(filePath) {
7134
7137
  }
7135
7138
  }
7136
7139
  async function writeJsonAtomic(filePath, value) {
7140
+ await replaceJson(filePath, value, false);
7141
+ }
7142
+ async function replaceJson(filePath, value, durable) {
7137
7143
  const directory = (0, import_node_path.dirname)(filePath);
7138
7144
  const temporaryPath = (0, import_node_path.join)(
7139
7145
  directory,
@@ -7143,13 +7149,25 @@ async function writeJsonAtomic(filePath, value) {
7143
7149
  `;
7144
7150
  await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
7145
7151
  try {
7146
- await (0, import_promises.writeFile)(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
7152
+ if (durable) await writeFileDurably(temporaryPath, contents);
7153
+ else await (0, import_promises.writeFile)(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
7147
7154
  await (0, import_promises.rename)(temporaryPath, filePath);
7148
7155
  } catch (error) {
7149
7156
  await (0, import_promises.rm)(temporaryPath, { force: true }).catch(() => void 0);
7150
7157
  throw error;
7151
7158
  }
7152
7159
  }
7160
+ async function writeFileDurably(path, contents) {
7161
+ const handle = await (0, import_promises.open)(path, "wx", PRIVATE_FILE_MODE);
7162
+ try {
7163
+ if (contents) {
7164
+ await handle.writeFile(contents, "utf8");
7165
+ await handle.datasync();
7166
+ }
7167
+ } finally {
7168
+ await handle.close();
7169
+ }
7170
+ }
7153
7171
  async function readProfile(profile) {
7154
7172
  return readJson(profileConfigPath(profile));
7155
7173
  }
@@ -7186,15 +7204,28 @@ async function withProfileLock(profile, action, options = {}) {
7186
7204
  const lockPath = profileLockPath(normalizedProfile);
7187
7205
  const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
7188
7206
  if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
7189
- const release = await acquireProfileLock(normalizedProfile, options);
7190
- const ownership = { lockPath, active: true };
7207
+ const acquired = await acquireProfileLock(normalizedProfile, options);
7208
+ const ownership = {
7209
+ lockPath,
7210
+ turnSlot: acquired.turnSlot,
7211
+ deletedWhileWaiting: acquired.deletedWhileWaiting,
7212
+ active: true
7213
+ };
7191
7214
  try {
7192
7215
  return await heldProfileLocks.run([...held, ownership], action);
7193
7216
  } finally {
7194
7217
  ownership.active = false;
7195
- await release();
7218
+ await acquired.release();
7219
+ if (acquired.deletedWhileWaiting) await (0, import_promises.rmdir)(profilePath(normalizedProfile)).catch(() => void 0);
7196
7220
  }
7197
7221
  }
7222
+ async function refuseWriteToDeletedProfile(profile) {
7223
+ const lockPath = profileLockPath(profile);
7224
+ const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
7225
+ if (!ownership?.deletedWhileWaiting) return;
7226
+ if (await exists(profileConfigPath(profile))) return;
7227
+ throw new ProfileDeletedError(profile);
7228
+ }
7198
7229
  async function updateProfileStore(profile, store, update, options = {}) {
7199
7230
  return withProfileLock(
7200
7231
  profile,
@@ -7203,6 +7234,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
7203
7234
  );
7204
7235
  }
7205
7236
  async function updateProfileStoreWhileLocked(profile, store, update) {
7237
+ await refuseWriteToDeletedProfile(profile);
7206
7238
  const current = await readProfileStore(profile, store);
7207
7239
  const next = await update(current.records);
7208
7240
  await writeJsonAtomic(profileStorePath(profile, store), next.records);
@@ -7213,32 +7245,271 @@ async function writeFormatOneMetadata(profile, store) {
7213
7245
  await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
7214
7246
  }
7215
7247
  async function acquireProfileLock(profile, options) {
7216
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
7217
- const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
7248
+ const deadline = {
7249
+ startedAt: Date.now(),
7250
+ timeoutMs: options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
7251
+ retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
7252
+ };
7218
7253
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
7219
- const startedAt = Date.now();
7220
7254
  const lockPath = profileLockPath(profile);
7255
+ const turnDirectory = profileTurnLockPath(profile);
7221
7256
  const directory = profilePath(profile);
7222
7257
  await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
7258
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
7223
7259
  for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
7224
7260
  await (0, import_promises.chmod)(path, PRIVATE_DIR_MODE).catch((error) => {
7225
7261
  if (path !== directory || !isErrno(error, "ENOENT")) throw error;
7226
7262
  });
7227
7263
  }
7228
- while (true) {
7229
- const token = (0, import_node_crypto.randomUUID)();
7230
- if (await publishProfileLock(profile, lockPath, token)) {
7231
- return async () => {
7232
- await releaseProfileLock(profile, lockPath, token);
7233
- };
7264
+ const { turn, firstWaitedTurn } = await acquireTurn(profile, turnDirectory, deadline);
7265
+ try {
7266
+ const deletion = await readJson((0, import_node_path.join)(turnDirectory, "deleted.json")).catch(() => null);
7267
+ const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
7268
+ while (true) {
7269
+ const token = (0, import_node_crypto.randomUUID)();
7270
+ if (await publishProfileLock(profile, lockPath, token)) {
7271
+ return {
7272
+ deletedWhileWaiting,
7273
+ turnSlot: turn.slot,
7274
+ release: async () => {
7275
+ try {
7276
+ await releaseProfileLock(profile, lockPath, token);
7277
+ } finally {
7278
+ await releaseTurn(profile, turn);
7279
+ }
7280
+ }
7281
+ };
7282
+ }
7283
+ await signalTestLockContention(profile);
7284
+ if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
7285
+ await waitOrTimeOut(profile, deadline);
7286
+ }
7287
+ } catch (error) {
7288
+ await releaseTurn(profile, turn);
7289
+ throw error;
7290
+ }
7291
+ }
7292
+ function checkDeadline(profile, deadline, detail) {
7293
+ if (Date.now() - deadline.startedAt >= deadline.timeoutMs) {
7294
+ throw new ProfileLockTimeoutError(profile, deadline.timeoutMs, detail);
7295
+ }
7296
+ }
7297
+ async function waitOrTimeOut(profile, deadline, detail) {
7298
+ checkDeadline(profile, deadline, detail);
7299
+ await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
7300
+ }
7301
+ async function acquireTurn(profile, directory, deadline) {
7302
+ let firstWaitedTurn;
7303
+ let problem;
7304
+ 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.`;
7305
+ for (let first = true; ; first = false) {
7306
+ if (!first) checkDeadline(profile, deadline, problem);
7307
+ problem = void 0;
7308
+ const latest = await readLatestTurn(directory);
7309
+ if (latest === "missing") {
7310
+ await createTurnDirectory(directory);
7311
+ continue;
7312
+ }
7313
+ if (latest === "empty") {
7314
+ problem = damaged("has no turns");
7315
+ await waitOrTimeOut(profile, deadline, problem);
7316
+ continue;
7317
+ }
7318
+ if (latest === null) continue;
7319
+ if (typeof latest === "number") {
7320
+ problem = damaged(`has an unreadable turn ${latest}`);
7321
+ await waitOrTimeOut(profile, deadline, problem);
7322
+ continue;
7323
+ }
7324
+ firstWaitedTurn ?? (firstWaitedTurn = latest.done ? latest.slot + 1 : latest.slot);
7325
+ if (latest.held && latest.done) {
7326
+ const turn = await takeTurn(profile, directory, latest);
7327
+ if (turn !== null) return { turn, firstWaitedTurn };
7328
+ await waitOrTimeOut(profile, deadline);
7329
+ } else if (latest.done) {
7330
+ problem = damaged(`ends with turn ${latest.slot}, which was never granted`);
7331
+ await waitOrTimeOut(profile, deadline, problem);
7332
+ } else if (latest.pid === process.pid ? relinquishedTurns.has(latest.token) : !isProcessAlive(latest.pid)) {
7333
+ await waitForTestBarrier(TEST_LOCK_TURN_SETTLE_BARRIER_DIR, profile);
7334
+ await settleAbandonedTurn(directory, latest);
7335
+ relinquishedTurns.delete(latest.token);
7336
+ } else {
7337
+ await signalTestLockContention(profile);
7338
+ await waitOrTimeOut(profile, deadline);
7339
+ }
7340
+ }
7341
+ }
7342
+ async function readLatestTurn(directory) {
7343
+ let names;
7344
+ try {
7345
+ names = await (0, import_promises.readdir)(directory);
7346
+ } catch (error) {
7347
+ if (isErrno(error, "ENOENT")) return "missing";
7348
+ throw error;
7349
+ }
7350
+ const slots = names.filter((name) => TURN.test(name)).map(Number);
7351
+ if (slots.length === 0) return "empty";
7352
+ const slot = Math.max(...slots);
7353
+ const turn = await readTurn(directory, slot);
7354
+ return turn === "damaged" ? slot : turn;
7355
+ }
7356
+ async function readTurn(directory, slot) {
7357
+ const path = (0, import_node_path.join)(directory, String(slot));
7358
+ let text;
7359
+ try {
7360
+ text = await (0, import_promises.readFile)((0, import_node_path.join)(path, "owner.json"), "utf8");
7361
+ } catch (error) {
7362
+ if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTDIR")) throw error;
7363
+ return await exists(path) ? "damaged" : null;
7364
+ }
7365
+ let owner;
7366
+ try {
7367
+ owner = JSON.parse(text);
7368
+ } catch {
7369
+ return "damaged";
7370
+ }
7371
+ if (owner === null || typeof owner !== "object" || typeof owner.token !== "string" || typeof owner.pid !== "number") {
7372
+ return "damaged";
7373
+ }
7374
+ const done = await exists((0, import_node_path.join)(path, `${owner.token}.done`));
7375
+ const held = await exists((0, import_node_path.join)(path, `${owner.token}.held`));
7376
+ return {
7377
+ slot,
7378
+ token: owner.token,
7379
+ pid: owner.pid,
7380
+ after: typeof owner.after === "string" ? owner.after : null,
7381
+ held,
7382
+ done
7383
+ };
7384
+ }
7385
+ async function isFinishedTurn(directory, slot, token) {
7386
+ const turn = await readTurn(directory, slot);
7387
+ if (turn === "damaged") throw new Error(`Turn ${slot} of the turn lock ${directory} is unreadable.`);
7388
+ return turn !== null && turn.token === token && turn.held && turn.done;
7389
+ }
7390
+ async function takeTurn(profile, directory, latest) {
7391
+ const token = (0, import_node_crypto.randomUUID)();
7392
+ const slot = latest.slot + 1;
7393
+ const path = (0, import_node_path.join)(directory, String(slot));
7394
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token };
7395
+ let published = false;
7396
+ try {
7397
+ published = await publishDirectory(
7398
+ (0, import_node_path.join)(directory, `.stage-${process.pid}-${token}`),
7399
+ path,
7400
+ { "owner.json": `${JSON.stringify(owner, null, 2)}
7401
+ ` },
7402
+ () => waitForTestBarrier(TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, profile)
7403
+ );
7404
+ if (published && await isFinishedTurn(directory, latest.slot, latest.token) && await markTurn(path, token, "held")) {
7405
+ return { directory, slot, token };
7406
+ }
7407
+ if (published) await markTurn(path, token, "done");
7408
+ return null;
7409
+ } catch (error) {
7410
+ if (published) settleOwnTurn(directory, slot, token);
7411
+ throw error;
7412
+ }
7413
+ }
7414
+ async function settleAbandonedTurn(directory, abandoned) {
7415
+ const path = (0, import_node_path.join)(directory, String(abandoned.slot));
7416
+ if (!abandoned.held && abandoned.after !== null && await isFinishedTurn(directory, abandoned.slot - 1, abandoned.after)) {
7417
+ await markTurn(path, abandoned.token, "held");
7418
+ }
7419
+ await markTurn(path, abandoned.token, "done");
7420
+ }
7421
+ function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
7422
+ relinquishedTurns.add(token);
7423
+ void (async () => {
7424
+ try {
7425
+ const turn = await readTurn(directory, slot);
7426
+ if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
7427
+ relinquishedTurns.delete(token);
7428
+ } catch {
7429
+ if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
7430
+ }
7431
+ })();
7432
+ }
7433
+ async function markTurn(path, token, kind) {
7434
+ for (let attempt = 0; ; attempt++) {
7435
+ try {
7436
+ await (0, import_promises.writeFile)((0, import_node_path.join)(path, `${token}.${kind}`), "", { mode: PRIVATE_FILE_MODE, flag: "wx" });
7437
+ return true;
7438
+ } catch (error) {
7439
+ if (isErrno(error, "EEXIST")) return true;
7440
+ if (isErrno(error, "ENOENT")) return false;
7441
+ if (attempt >= MARKER_RETRY_DELAYS_MS.length) throw error;
7442
+ await sleep(MARKER_RETRY_DELAYS_MS[attempt]);
7443
+ }
7444
+ }
7445
+ }
7446
+ async function createTurnDirectory(directory) {
7447
+ const token = (0, import_node_crypto.randomUUID)();
7448
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null };
7449
+ await publishDirectory((0, import_node_path.join)((0, import_node_path.dirname)(directory), `.stage-${(0, import_node_path.basename)(directory)}-${process.pid}-${token}`), directory, {
7450
+ "0/owner.json": `${JSON.stringify(owner, null, 2)}
7451
+ `,
7452
+ [`0/${token}.held`]: "",
7453
+ [`0/${token}.done`]: ""
7454
+ });
7455
+ }
7456
+ async function publishDirectory(stage, target, files, beforeRename) {
7457
+ try {
7458
+ for (const [name, contents] of Object.entries(files)) {
7459
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)((0, import_node_path.join)(stage, name)), { recursive: true, mode: PRIVATE_DIR_MODE });
7460
+ await writeFileDurably((0, import_node_path.join)(stage, name), contents);
7461
+ }
7462
+ await beforeRename?.();
7463
+ await (0, import_promises.rename)(stage, target);
7464
+ return true;
7465
+ } catch (error) {
7466
+ if (["ENOTEMPTY", "EEXIST", "ENOENT"].some((code) => isErrno(error, code)) || await exists(target)) return false;
7467
+ throw error;
7468
+ } finally {
7469
+ await (0, import_promises.rm)(stage, { recursive: true, force: true }).catch(() => void 0);
7470
+ }
7471
+ }
7472
+ async function releaseTurn(profile, turn) {
7473
+ try {
7474
+ await markTurn((0, import_node_path.join)(turn.directory, String(turn.slot)), turn.token, "done");
7475
+ } catch {
7476
+ settleOwnTurn(turn.directory, turn.slot, turn.token);
7477
+ return;
7478
+ }
7479
+ await collectTurns(profile, turn).catch(() => void 0);
7480
+ }
7481
+ function isAbandonedTurnEntry(name) {
7482
+ if (name.startsWith(".trash-")) return true;
7483
+ const writer = /^\.(?:stage|deleted\.json)[-.]([0-9]+)[-.]/.exec(name)?.[1];
7484
+ return writer !== void 0 && !isProcessAlive(Number(writer));
7485
+ }
7486
+ function isAbandonedTurnDirectoryStage(name) {
7487
+ 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];
7488
+ return writer !== void 0 && !isProcessAlive(Number(writer));
7489
+ }
7490
+ async function collectTurns(profile, turn) {
7491
+ for (const [directory, abandoned] of [
7492
+ [turn.directory, isAbandonedTurnEntry],
7493
+ [(0, import_node_path.dirname)(turn.directory), isAbandonedTurnDirectoryStage]
7494
+ ]) {
7495
+ for (const name of (await (0, import_promises.readdir)(directory)).filter(abandoned)) {
7496
+ await (0, import_promises.rm)((0, import_node_path.join)(directory, name), { recursive: true, force: true }).catch(() => void 0);
7497
+ }
7498
+ }
7499
+ 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);
7500
+ for (const slot of older) {
7501
+ const candidate = await readTurn(turn.directory, slot);
7502
+ if (candidate === null || candidate === "damaged" || !candidate.done) continue;
7503
+ if (candidate.held) {
7504
+ if (await exists((0, import_node_path.join)(turn.directory, String(slot - 1)))) continue;
7505
+ const next = await readTurn(turn.directory, slot + 1);
7506
+ if (next === null || next === "damaged" || !next.held || !next.done) continue;
7234
7507
  }
7235
- await signalTestLockContention(profile);
7236
- if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
7237
- const elapsedMs = Date.now() - startedAt;
7238
- if (elapsedMs >= timeoutMs) {
7239
- throw new ProfileLockTimeoutError(profile, timeoutMs);
7508
+ await waitForTestBarrier(TEST_LOCK_TURN_COLLECT_BARRIER_DIR, profile);
7509
+ const trash = (0, import_node_path.join)(turn.directory, `.trash-${(0, import_node_crypto.randomUUID)()}`);
7510
+ if (await (0, import_promises.rename)((0, import_node_path.join)(turn.directory, String(slot)), trash).then(() => true, () => false)) {
7511
+ await (0, import_promises.rm)(trash, { recursive: true, force: true }).catch(() => void 0);
7240
7512
  }
7241
- await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
7242
7513
  }
7243
7514
  }
7244
7515
  async function publishProfileLock(profile, lockPath, token) {
@@ -7251,12 +7522,18 @@ async function publishProfileLock(profile, lockPath, token) {
7251
7522
  return false;
7252
7523
  }
7253
7524
  await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
7525
+ const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
7254
7526
  const staged = (0, import_node_path.join)((0, import_node_path.dirname)(lockPath), `.lock-owner-${token}.tmp`);
7255
- const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token };
7527
+ const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }, null, 2)}
7528
+ `;
7256
7529
  try {
7257
- await (0, import_promises.writeFile)(staged, `${JSON.stringify(owner, null, 2)}
7258
- `, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7259
- await (0, import_promises.link)(staged, (0, import_node_path.join)(lockPath, "owner.json"));
7530
+ await (0, import_promises.writeFile)(staged, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7531
+ try {
7532
+ await (0, import_promises.link)(staged, ownerPath);
7533
+ } catch (error) {
7534
+ if (!lacksHardLinks(error)) throw error;
7535
+ await (0, import_promises.writeFile)(ownerPath, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
7536
+ }
7260
7537
  return true;
7261
7538
  } catch (error) {
7262
7539
  if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
@@ -7266,6 +7543,9 @@ async function publishProfileLock(profile, lockPath, token) {
7266
7543
  await (0, import_promises.rm)(staged, { force: true }).catch(() => void 0);
7267
7544
  }
7268
7545
  }
7546
+ function lacksHardLinks(error) {
7547
+ return typeof error === "object" && error !== null && "code" in error && NO_HARD_LINK_ERRORS.has(String(error.code));
7548
+ }
7269
7549
  async function releaseProfileLock(profile, lockPath, token) {
7270
7550
  const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
7271
7551
  const owner = await readJson(ownerPath).catch(() => null);
@@ -7289,9 +7569,10 @@ async function signalTestLockContention(profile) {
7289
7569
  `, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
7290
7570
  }
7291
7571
  async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7572
+ let aged;
7292
7573
  try {
7293
7574
  const { mtimeMs } = await (0, import_promises.stat)(lockPath);
7294
- if (Date.now() - mtimeMs < staleAfterMs) return false;
7575
+ aged = Date.now() - mtimeMs >= staleAfterMs;
7295
7576
  } catch {
7296
7577
  return false;
7297
7578
  }
@@ -7300,7 +7581,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7300
7581
  try {
7301
7582
  const entries = await (0, import_promises.readdir)(lockPath);
7302
7583
  if (entries.includes("owner.json")) return false;
7303
- if (recoveryFenced(checkedAt, staleAfterMs)) return false;
7584
+ const onlyOrphanedClaims = entries.length > 0 && entries.every((entry) => RECOVERY_CLAIM.test(entry));
7585
+ if (!onlyOrphanedClaims && (!aged || recoveryFenced(checkedAt, staleAfterMs))) return false;
7586
+ for (const name of entries.filter((entry) => RECOVERY_CLAIM.test(entry))) {
7587
+ const claimPath = (0, import_node_path.join)(lockPath, name);
7588
+ if (await hasLiveRecoveryOwner(claimPath)) {
7589
+ 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"));
7590
+ return false;
7591
+ }
7592
+ await (0, import_promises.rm)(claimPath, { force: true });
7593
+ }
7304
7594
  for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
7305
7595
  await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
7306
7596
  }
@@ -7311,6 +7601,23 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
7311
7601
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7312
7602
  return true;
7313
7603
  }
7604
+ async function hasLiveRecoveryOwner(claimPath) {
7605
+ let contents;
7606
+ try {
7607
+ contents = await (0, import_promises.readFile)(claimPath, "utf8");
7608
+ } catch (error) {
7609
+ if (isErrno(error, "ENOENT")) return false;
7610
+ throw error;
7611
+ }
7612
+ let owner;
7613
+ try {
7614
+ owner = JSON.parse(contents);
7615
+ } catch {
7616
+ return false;
7617
+ }
7618
+ const pid = typeof owner === "object" && owner !== null && "pid" in owner ? owner.pid : null;
7619
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && isProcessAlive(pid);
7620
+ }
7314
7621
  function recoveryFenced(since, staleAfterMs) {
7315
7622
  return performance.now() - since >= staleAfterMs / 2;
7316
7623
  }
@@ -7325,37 +7632,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
7325
7632
  }
7326
7633
  async function recoverStaleLock(profile, lockPath, staleAfterMs) {
7327
7634
  const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
7328
- const owner = await readJson(ownerPath).catch(() => null);
7329
- if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
7330
- if (!isStaleOwner(owner, staleAfterMs)) return false;
7331
- const observedToken = typeof owner?.token === "string" && owner.token.length > 0 ? owner.token : "legacy";
7635
+ const observed = await (0, import_promises.readFile)(ownerPath, "utf8").catch(() => null);
7636
+ if (observed === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
7637
+ if (!await isAbandonedOwner(observed, ownerPath, staleAfterMs)) return false;
7332
7638
  await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
7333
- const claimPath = (0, import_node_path.join)(lockPath, `.stale-${(0, import_node_crypto.randomUUID)()}.json`);
7639
+ const claimPath = (0, import_node_path.join)(lockPath, `.recover-${(0, import_node_crypto.randomUUID)()}.json`);
7640
+ let moved = false;
7334
7641
  try {
7335
7642
  await (0, import_promises.link)(ownerPath, claimPath);
7336
- } catch {
7337
- return false;
7643
+ } catch (error) {
7644
+ if (!lacksHardLinks(error)) return false;
7645
+ try {
7646
+ await (0, import_promises.rename)(ownerPath, claimPath);
7647
+ moved = true;
7648
+ } catch {
7649
+ return false;
7650
+ }
7338
7651
  }
7339
- const claimedAt = performance.now();
7340
- await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
7341
- const claimed = await readJson(claimPath).catch(() => null);
7342
- const sameInstance = claimed !== null && (observedToken === "legacy" ? claimed.token === void 0 : claimed.token === observedToken);
7652
+ if (moved) await waitForTestBarrier(TEST_LOCK_MOVED_BARRIER_DIR, profile);
7653
+ const sameInstance = await (0, import_promises.readFile)(claimPath, "utf8").then((claimed) => claimed === observed, () => false);
7343
7654
  await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
7344
- if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
7345
- await (0, import_promises.rm)(claimPath, { force: true });
7346
- await waitForTestBarrier(TEST_LOCK_FENCED_BARRIER_DIR, profile);
7655
+ if (!sameInstance) {
7656
+ if (moved) {
7657
+ await (0, import_promises.rename)(claimPath, ownerPath).catch(() => void 0);
7658
+ } else {
7659
+ await (0, import_promises.unlink)(claimPath).catch(() => void 0);
7660
+ }
7347
7661
  return false;
7348
7662
  }
7349
- if (sameInstance) {
7350
- await (0, import_promises.rm)(ownerPath, { force: true });
7351
- await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
7663
+ if (!moved) await (0, import_promises.rm)(ownerPath, { force: true });
7664
+ await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
7665
+ await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
7666
+ for (const name of (await (0, import_promises.readdir)(lockPath).catch(() => [])).filter((entry) => RECOVERY_CLAIM.test(entry))) {
7667
+ if ((0, import_node_path.join)(lockPath, name) !== claimPath) await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
7352
7668
  }
7353
- await (0, import_promises.rm)(claimPath, { force: true });
7354
- await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
7355
- if (!sameInstance) return false;
7669
+ if (await (0, import_promises.unlink)(claimPath).then(() => true, () => false)) await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
7356
7670
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
7357
7671
  return true;
7358
7672
  }
7673
+ async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
7674
+ let owner;
7675
+ try {
7676
+ owner = JSON.parse(observed);
7677
+ } catch {
7678
+ owner = null;
7679
+ }
7680
+ if (typeof owner === "object" && owner !== null && "pid" in owner) {
7681
+ return isStaleOwner(owner, staleAfterMs);
7682
+ }
7683
+ const modifiedAt = await (0, import_promises.stat)(ownerPath).then(({ mtimeMs }) => mtimeMs, () => Number.NaN);
7684
+ return Date.now() - modifiedAt >= staleAfterMs;
7685
+ }
7359
7686
  async function waitForTestBarrier(environmentName, profile) {
7360
7687
  if (process.env.NODE_ENV !== "test") return;
7361
7688
  const barrierDirectory = process.env[environmentName];
@@ -7382,6 +7709,15 @@ function isStaleOwner(owner, staleAfterMs) {
7382
7709
  }
7383
7710
  return !isProcessAlive(owner.pid);
7384
7711
  }
7712
+ async function exists(path) {
7713
+ try {
7714
+ await (0, import_promises.stat)(path);
7715
+ return true;
7716
+ } catch (error) {
7717
+ if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) return false;
7718
+ throw error;
7719
+ }
7720
+ }
7385
7721
  function isProcessAlive(pid) {
7386
7722
  try {
7387
7723
  process.kill(pid, 0);
@@ -7402,7 +7738,7 @@ function isErrno(error, code) {
7402
7738
  function sleep(milliseconds) {
7403
7739
  return new Promise((resolve2) => setTimeout(resolve2, milliseconds));
7404
7740
  }
7405
- var import_node_crypto, import_node_async_hooks, import_promises, import_node_os, import_node_path, 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;
7741
+ var import_node_crypto, import_node_async_hooks, import_promises, import_node_os, import_node_path, 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;
7406
7742
  var init_state = __esm({
7407
7743
  "src/state.ts"() {
7408
7744
  "use strict";
@@ -7424,18 +7760,36 @@ var init_state = __esm({
7424
7760
  TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
7425
7761
  TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
7426
7762
  TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
7427
- TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
7428
7763
  TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
7764
+ TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
7765
+ TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
7766
+ TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
7767
+ TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
7429
7768
  invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
7430
- heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v1");
7769
+ heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
7431
7770
  ProfileLockTimeoutError = class extends Error {
7432
- constructor(profile, timeoutMs) {
7433
- super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.`);
7771
+ /** `detail` names the cause when it is not simply another process holding the lock. */
7772
+ constructor(profile, timeoutMs, detail) {
7773
+ super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.${detail ? ` ${detail}` : ""}`);
7434
7774
  this.code = "PROFILE_LOCK_TIMEOUT";
7435
7775
  this.name = "ProfileLockTimeoutError";
7436
7776
  }
7437
7777
  };
7778
+ ProfileDeletedError = class extends Error {
7779
+ constructor(profile) {
7780
+ super(`Profile "${profile}" was deleted while this change waited for its lock, so the change was not written.`);
7781
+ this.code = "PROFILE_NOT_FOUND";
7782
+ this.name = "ProfileDeletedError";
7783
+ }
7784
+ };
7785
+ TURN = /^(?:0|[1-9][0-9]*)$/;
7786
+ relinquishedTurns = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Set());
7787
+ MARKER_RETRY_DELAYS_MS = [10, 50, 250];
7788
+ SETTLE_RETRY_MS = 1e3;
7789
+ SETTLE_RETRIES = 300;
7790
+ NO_HARD_LINK_ERRORS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EISDIR", "EMLINK"]);
7438
7791
  ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
7792
+ RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
7439
7793
  STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
7440
7794
  }
7441
7795
  });