@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.
package/dist/state.cjs CHANGED
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var state_exports = {};
22
22
  __export(state_exports, {
23
23
  DEFAULT_PROFILE: () => DEFAULT_PROFILE,
24
+ ProfileDeletedError: () => ProfileDeletedError,
24
25
  ProfileLockTimeoutError: () => ProfileLockTimeoutError,
25
26
  additionalDelegationsPath: () => additionalDelegationsPath,
26
27
  authRequestsPath: () => authRequestsPath,
@@ -30,6 +31,7 @@ __export(state_exports, {
30
31
  profilePath: () => profilePath,
31
32
  profileStoreMetadataPath: () => profileStoreMetadataPath,
32
33
  profileStorePath: () => profileStorePath,
34
+ profileTurnLockPath: () => profileTurnLockPath,
33
35
  profilesPath: () => profilesPath,
34
36
  readAdditionalDelegations: () => readAdditionalDelegations,
35
37
  readAuthRequests: () => readAuthRequests,
@@ -38,6 +40,8 @@ __export(state_exports, {
38
40
  readProfileStore: () => readProfileStore,
39
41
  readSession: () => readSession,
40
42
  readStoreMetadata: () => readStoreMetadata,
43
+ recordProfileDeletion: () => recordProfileDeletion,
44
+ refuseWriteToDeletedProfile: () => refuseWriteToDeletedProfile,
41
45
  removeSession: () => removeSession,
42
46
  sessionPath: () => sessionPath,
43
47
  tinycloudConfigPath: () => tinycloudConfigPath,
@@ -68,17 +72,33 @@ var TEST_LOCK_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_PUBLISH_BARRIER_DIR";
68
72
  var TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
69
73
  var TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
70
74
  var TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
71
- var TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
72
75
  var TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
73
- var invocationStateRoot = new import_node_async_hooks.AsyncLocalStorage();
74
- var heldProfileLocks = new import_node_async_hooks.AsyncLocalStorage();
76
+ var TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
77
+ var TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
78
+ var TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
79
+ var TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
80
+ function processWideContext(key) {
81
+ var _a2;
82
+ const registry = globalThis;
83
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new import_node_async_hooks.AsyncLocalStorage());
84
+ }
85
+ var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
86
+ var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
75
87
  var ProfileLockTimeoutError = class extends Error {
76
- constructor(profile, timeoutMs) {
77
- super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.`);
88
+ /** `detail` names the cause when it is not simply another process holding the lock. */
89
+ constructor(profile, timeoutMs, detail) {
90
+ super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.${detail ? ` ${detail}` : ""}`);
78
91
  this.code = "PROFILE_LOCK_TIMEOUT";
79
92
  this.name = "ProfileLockTimeoutError";
80
93
  }
81
94
  };
95
+ var ProfileDeletedError = class extends Error {
96
+ constructor(profile) {
97
+ super(`Profile "${profile}" was deleted while this change waited for its lock, so the change was not written.`);
98
+ this.code = "PROFILE_NOT_FOUND";
99
+ this.name = "ProfileDeletedError";
100
+ }
101
+ };
82
102
  function tinycloudHomePath() {
83
103
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
84
104
  return (0, import_node_path.join)(home, ".tinycloud");
@@ -123,6 +143,9 @@ function profileLockPath(profile) {
123
143
  function profileLockMetadataPath(profile) {
124
144
  return (0, import_node_path.join)(profileLockPath(profile), "owner.json");
125
145
  }
146
+ function profileTurnLockPath(profile) {
147
+ return (0, import_node_path.join)(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
148
+ }
126
149
  async function readJson(filePath) {
127
150
  try {
128
151
  return JSON.parse(await (0, import_promises.readFile)(filePath, "utf8"));
@@ -132,6 +155,9 @@ async function readJson(filePath) {
132
155
  }
133
156
  }
134
157
  async function writeJsonAtomic(filePath, value) {
158
+ await replaceJson(filePath, value, false);
159
+ }
160
+ async function replaceJson(filePath, value, durable) {
135
161
  const directory = (0, import_node_path.dirname)(filePath);
136
162
  const temporaryPath = (0, import_node_path.join)(
137
163
  directory,
@@ -141,13 +167,25 @@ async function writeJsonAtomic(filePath, value) {
141
167
  `;
142
168
  await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
143
169
  try {
144
- await (0, import_promises.writeFile)(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
170
+ if (durable) await writeFileDurably(temporaryPath, contents);
171
+ else await (0, import_promises.writeFile)(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
145
172
  await (0, import_promises.rename)(temporaryPath, filePath);
146
173
  } catch (error) {
147
174
  await (0, import_promises.rm)(temporaryPath, { force: true }).catch(() => void 0);
148
175
  throw error;
149
176
  }
150
177
  }
178
+ async function writeFileDurably(path, contents) {
179
+ const handle = await (0, import_promises.open)(path, "wx", PRIVATE_FILE_MODE);
180
+ try {
181
+ if (contents) {
182
+ await handle.writeFile(contents, "utf8");
183
+ await handle.datasync();
184
+ }
185
+ } finally {
186
+ await handle.close();
187
+ }
188
+ }
151
189
  async function readProfile(profile) {
152
190
  return readJson(profileConfigPath(profile));
153
191
  }
@@ -184,18 +222,38 @@ async function withProfileLock(profile, action, options = {}) {
184
222
  const lockPath = profileLockPath(normalizedProfile);
185
223
  const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
186
224
  if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
187
- const release = await acquireProfileLock(normalizedProfile, options);
188
- const ownership = { lockPath, active: true };
225
+ const acquired = await acquireProfileLock(normalizedProfile, options);
226
+ const ownership = {
227
+ lockPath,
228
+ turnSlot: acquired.turnSlot,
229
+ deletedWhileWaiting: acquired.deletedWhileWaiting,
230
+ active: true
231
+ };
189
232
  try {
190
233
  return await heldProfileLocks.run([...held, ownership], action);
191
234
  } finally {
192
235
  ownership.active = false;
193
- await release();
236
+ await acquired.release();
237
+ if (acquired.deletedWhileWaiting) await (0, import_promises.rmdir)(profilePath(normalizedProfile)).catch(() => void 0);
194
238
  }
195
239
  }
240
+ async function recordProfileDeletion(profile) {
241
+ const lockPath = profileLockPath(profile);
242
+ const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
243
+ if (!ownership) throw new Error(`Recording the deletion of profile "${profile}" requires holding its lock.`);
244
+ await replaceJson((0, import_node_path.join)(profileTurnLockPath(profile), "deleted.json"), { slot: ownership.turnSlot }, true);
245
+ }
246
+ async function refuseWriteToDeletedProfile(profile) {
247
+ const lockPath = profileLockPath(profile);
248
+ const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
249
+ if (!ownership?.deletedWhileWaiting) return;
250
+ if (await exists(profileConfigPath(profile))) return;
251
+ throw new ProfileDeletedError(profile);
252
+ }
196
253
  async function upsertProfileRecord(profile, store, key, record, getKey, options = {}) {
197
254
  if (!key) throw new TypeError("A non-empty record key is required.");
198
255
  return withProfileLock(profile, async () => {
256
+ await refuseWriteToDeletedProfile(profile);
199
257
  const current = (await readProfileStore(profile, store)).records;
200
258
  const next = current.filter((candidate) => getKey(candidate) !== key);
201
259
  next.push(record);
@@ -212,6 +270,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
212
270
  );
213
271
  }
214
272
  async function updateProfileStoreWhileLocked(profile, store, update) {
273
+ await refuseWriteToDeletedProfile(profile);
215
274
  const current = await readProfileStore(profile, store);
216
275
  const next = await update(current.records);
217
276
  await writeJsonAtomic(profileStorePath(profile, store), next.records);
@@ -220,6 +279,7 @@ async function updateProfileStoreWhileLocked(profile, store, update) {
220
279
  }
221
280
  async function writeSession(profile, session, options = {}) {
222
281
  await withProfileLock(profile, async () => {
282
+ await refuseWriteToDeletedProfile(profile);
223
283
  await readStoreMetadata(profile, "session");
224
284
  await writeJsonAtomic(sessionPath(profile), session);
225
285
  await writeFormatOneMetadata(profile, "session");
@@ -235,32 +295,277 @@ async function writeFormatOneMetadata(profile, store) {
235
295
  await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
236
296
  }
237
297
  async function acquireProfileLock(profile, options) {
238
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
239
- const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
298
+ const deadline = {
299
+ startedAt: Date.now(),
300
+ timeoutMs: options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
301
+ retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
302
+ };
240
303
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
241
- const startedAt = Date.now();
242
304
  const lockPath = profileLockPath(profile);
305
+ const turnDirectory = profileTurnLockPath(profile);
243
306
  const directory = profilePath(profile);
244
307
  await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
308
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
245
309
  for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
246
310
  await (0, import_promises.chmod)(path, PRIVATE_DIR_MODE).catch((error) => {
247
311
  if (path !== directory || !isErrno(error, "ENOENT")) throw error;
248
312
  });
249
313
  }
250
- while (true) {
251
- const token = (0, import_node_crypto.randomUUID)();
252
- if (await publishProfileLock(profile, lockPath, token)) {
253
- return async () => {
254
- await releaseProfileLock(profile, lockPath, token);
255
- };
314
+ const { turn, firstWaitedTurn } = await acquireTurn(profile, turnDirectory, deadline);
315
+ try {
316
+ const deletion = await readJson((0, import_node_path.join)(turnDirectory, "deleted.json")).catch(() => null);
317
+ const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
318
+ while (true) {
319
+ const token = (0, import_node_crypto.randomUUID)();
320
+ if (await publishProfileLock(profile, lockPath, token)) {
321
+ return {
322
+ deletedWhileWaiting,
323
+ turnSlot: turn.slot,
324
+ release: async () => {
325
+ try {
326
+ await releaseProfileLock(profile, lockPath, token);
327
+ } finally {
328
+ await releaseTurn(profile, turn);
329
+ }
330
+ }
331
+ };
332
+ }
333
+ await signalTestLockContention(profile);
334
+ if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
335
+ await waitOrTimeOut(profile, deadline);
336
+ }
337
+ } catch (error) {
338
+ await releaseTurn(profile, turn);
339
+ throw error;
340
+ }
341
+ }
342
+ function checkDeadline(profile, deadline, detail) {
343
+ if (Date.now() - deadline.startedAt >= deadline.timeoutMs) {
344
+ throw new ProfileLockTimeoutError(profile, deadline.timeoutMs, detail);
345
+ }
346
+ }
347
+ async function waitOrTimeOut(profile, deadline, detail) {
348
+ checkDeadline(profile, deadline, detail);
349
+ await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
350
+ }
351
+ var TURN = /^(?:0|[1-9][0-9]*)$/;
352
+ var _a;
353
+ var relinquishedTurns = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Set());
354
+ var MARKER_RETRY_DELAYS_MS = [10, 50, 250];
355
+ var SETTLE_RETRY_MS = 1e3;
356
+ var SETTLE_RETRIES = 300;
357
+ async function acquireTurn(profile, directory, deadline) {
358
+ let firstWaitedTurn;
359
+ let problem;
360
+ 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.`;
361
+ for (let first = true; ; first = false) {
362
+ if (!first) checkDeadline(profile, deadline, problem);
363
+ problem = void 0;
364
+ const latest = await readLatestTurn(directory);
365
+ if (latest === "missing") {
366
+ await createTurnDirectory(directory);
367
+ continue;
368
+ }
369
+ if (latest === "empty") {
370
+ problem = damaged("has no turns");
371
+ await waitOrTimeOut(profile, deadline, problem);
372
+ continue;
373
+ }
374
+ if (latest === null) continue;
375
+ if (typeof latest === "number") {
376
+ problem = damaged(`has an unreadable turn ${latest}`);
377
+ await waitOrTimeOut(profile, deadline, problem);
378
+ continue;
379
+ }
380
+ firstWaitedTurn ?? (firstWaitedTurn = latest.done ? latest.slot + 1 : latest.slot);
381
+ if (latest.held && latest.done) {
382
+ const turn = await takeTurn(profile, directory, latest);
383
+ if (turn !== null) return { turn, firstWaitedTurn };
384
+ await waitOrTimeOut(profile, deadline);
385
+ } else if (latest.done) {
386
+ problem = damaged(`ends with turn ${latest.slot}, which was never granted`);
387
+ await waitOrTimeOut(profile, deadline, problem);
388
+ } else if (latest.pid === process.pid ? relinquishedTurns.has(latest.token) : !isProcessAlive(latest.pid)) {
389
+ await waitForTestBarrier(TEST_LOCK_TURN_SETTLE_BARRIER_DIR, profile);
390
+ await settleAbandonedTurn(directory, latest);
391
+ relinquishedTurns.delete(latest.token);
392
+ } else {
393
+ await signalTestLockContention(profile);
394
+ await waitOrTimeOut(profile, deadline);
395
+ }
396
+ }
397
+ }
398
+ async function readLatestTurn(directory) {
399
+ let names;
400
+ try {
401
+ names = await (0, import_promises.readdir)(directory);
402
+ } catch (error) {
403
+ if (isErrno(error, "ENOENT")) return "missing";
404
+ throw error;
405
+ }
406
+ const slots = names.filter((name) => TURN.test(name)).map(Number);
407
+ if (slots.length === 0) return "empty";
408
+ const slot = Math.max(...slots);
409
+ const turn = await readTurn(directory, slot);
410
+ return turn === "damaged" ? slot : turn;
411
+ }
412
+ async function readTurn(directory, slot) {
413
+ const path = (0, import_node_path.join)(directory, String(slot));
414
+ let text;
415
+ try {
416
+ text = await (0, import_promises.readFile)((0, import_node_path.join)(path, "owner.json"), "utf8");
417
+ } catch (error) {
418
+ if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTDIR")) throw error;
419
+ return await exists(path) ? "damaged" : null;
420
+ }
421
+ let owner;
422
+ try {
423
+ owner = JSON.parse(text);
424
+ } catch {
425
+ return "damaged";
426
+ }
427
+ if (owner === null || typeof owner !== "object" || typeof owner.token !== "string" || typeof owner.pid !== "number") {
428
+ return "damaged";
429
+ }
430
+ const done = await exists((0, import_node_path.join)(path, `${owner.token}.done`));
431
+ const held = await exists((0, import_node_path.join)(path, `${owner.token}.held`));
432
+ return {
433
+ slot,
434
+ token: owner.token,
435
+ pid: owner.pid,
436
+ after: typeof owner.after === "string" ? owner.after : null,
437
+ held,
438
+ done
439
+ };
440
+ }
441
+ async function isFinishedTurn(directory, slot, token) {
442
+ const turn = await readTurn(directory, slot);
443
+ if (turn === "damaged") throw new Error(`Turn ${slot} of the turn lock ${directory} is unreadable.`);
444
+ return turn !== null && turn.token === token && turn.held && turn.done;
445
+ }
446
+ async function takeTurn(profile, directory, latest) {
447
+ const token = (0, import_node_crypto.randomUUID)();
448
+ const slot = latest.slot + 1;
449
+ const path = (0, import_node_path.join)(directory, String(slot));
450
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token };
451
+ let published = false;
452
+ try {
453
+ published = await publishDirectory(
454
+ (0, import_node_path.join)(directory, `.stage-${process.pid}-${token}`),
455
+ path,
456
+ { "owner.json": `${JSON.stringify(owner, null, 2)}
457
+ ` },
458
+ () => waitForTestBarrier(TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, profile)
459
+ );
460
+ if (published && await isFinishedTurn(directory, latest.slot, latest.token) && await markTurn(path, token, "held")) {
461
+ return { directory, slot, token };
462
+ }
463
+ if (published) await markTurn(path, token, "done");
464
+ return null;
465
+ } catch (error) {
466
+ if (published) settleOwnTurn(directory, slot, token);
467
+ throw error;
468
+ }
469
+ }
470
+ async function settleAbandonedTurn(directory, abandoned) {
471
+ const path = (0, import_node_path.join)(directory, String(abandoned.slot));
472
+ if (!abandoned.held && abandoned.after !== null && await isFinishedTurn(directory, abandoned.slot - 1, abandoned.after)) {
473
+ await markTurn(path, abandoned.token, "held");
474
+ }
475
+ await markTurn(path, abandoned.token, "done");
476
+ }
477
+ function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
478
+ relinquishedTurns.add(token);
479
+ void (async () => {
480
+ try {
481
+ const turn = await readTurn(directory, slot);
482
+ if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
483
+ relinquishedTurns.delete(token);
484
+ } catch {
485
+ if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
256
486
  }
257
- await signalTestLockContention(profile);
258
- if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
259
- const elapsedMs = Date.now() - startedAt;
260
- if (elapsedMs >= timeoutMs) {
261
- throw new ProfileLockTimeoutError(profile, timeoutMs);
487
+ })();
488
+ }
489
+ async function markTurn(path, token, kind) {
490
+ for (let attempt = 0; ; attempt++) {
491
+ try {
492
+ await (0, import_promises.writeFile)((0, import_node_path.join)(path, `${token}.${kind}`), "", { mode: PRIVATE_FILE_MODE, flag: "wx" });
493
+ return true;
494
+ } catch (error) {
495
+ if (isErrno(error, "EEXIST")) return true;
496
+ if (isErrno(error, "ENOENT")) return false;
497
+ if (attempt >= MARKER_RETRY_DELAYS_MS.length) throw error;
498
+ await sleep(MARKER_RETRY_DELAYS_MS[attempt]);
499
+ }
500
+ }
501
+ }
502
+ async function createTurnDirectory(directory) {
503
+ const token = (0, import_node_crypto.randomUUID)();
504
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null };
505
+ await publishDirectory((0, import_node_path.join)((0, import_node_path.dirname)(directory), `.stage-${(0, import_node_path.basename)(directory)}-${process.pid}-${token}`), directory, {
506
+ "0/owner.json": `${JSON.stringify(owner, null, 2)}
507
+ `,
508
+ [`0/${token}.held`]: "",
509
+ [`0/${token}.done`]: ""
510
+ });
511
+ }
512
+ async function publishDirectory(stage, target, files, beforeRename) {
513
+ try {
514
+ for (const [name, contents] of Object.entries(files)) {
515
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)((0, import_node_path.join)(stage, name)), { recursive: true, mode: PRIVATE_DIR_MODE });
516
+ await writeFileDurably((0, import_node_path.join)(stage, name), contents);
517
+ }
518
+ await beforeRename?.();
519
+ await (0, import_promises.rename)(stage, target);
520
+ return true;
521
+ } catch (error) {
522
+ if (["ENOTEMPTY", "EEXIST", "ENOENT"].some((code) => isErrno(error, code)) || await exists(target)) return false;
523
+ throw error;
524
+ } finally {
525
+ await (0, import_promises.rm)(stage, { recursive: true, force: true }).catch(() => void 0);
526
+ }
527
+ }
528
+ async function releaseTurn(profile, turn) {
529
+ try {
530
+ await markTurn((0, import_node_path.join)(turn.directory, String(turn.slot)), turn.token, "done");
531
+ } catch {
532
+ settleOwnTurn(turn.directory, turn.slot, turn.token);
533
+ return;
534
+ }
535
+ await collectTurns(profile, turn).catch(() => void 0);
536
+ }
537
+ function isAbandonedTurnEntry(name) {
538
+ if (name.startsWith(".trash-")) return true;
539
+ const writer = /^\.(?:stage|deleted\.json)[-.]([0-9]+)[-.]/.exec(name)?.[1];
540
+ return writer !== void 0 && !isProcessAlive(Number(writer));
541
+ }
542
+ function isAbandonedTurnDirectoryStage(name) {
543
+ 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];
544
+ return writer !== void 0 && !isProcessAlive(Number(writer));
545
+ }
546
+ async function collectTurns(profile, turn) {
547
+ for (const [directory, abandoned] of [
548
+ [turn.directory, isAbandonedTurnEntry],
549
+ [(0, import_node_path.dirname)(turn.directory), isAbandonedTurnDirectoryStage]
550
+ ]) {
551
+ for (const name of (await (0, import_promises.readdir)(directory)).filter(abandoned)) {
552
+ await (0, import_promises.rm)((0, import_node_path.join)(directory, name), { recursive: true, force: true }).catch(() => void 0);
553
+ }
554
+ }
555
+ 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);
556
+ for (const slot of older) {
557
+ const candidate = await readTurn(turn.directory, slot);
558
+ if (candidate === null || candidate === "damaged" || !candidate.done) continue;
559
+ if (candidate.held) {
560
+ if (await exists((0, import_node_path.join)(turn.directory, String(slot - 1)))) continue;
561
+ const next = await readTurn(turn.directory, slot + 1);
562
+ if (next === null || next === "damaged" || !next.held || !next.done) continue;
563
+ }
564
+ await waitForTestBarrier(TEST_LOCK_TURN_COLLECT_BARRIER_DIR, profile);
565
+ const trash = (0, import_node_path.join)(turn.directory, `.trash-${(0, import_node_crypto.randomUUID)()}`);
566
+ if (await (0, import_promises.rename)((0, import_node_path.join)(turn.directory, String(slot)), trash).then(() => true, () => false)) {
567
+ await (0, import_promises.rm)(trash, { recursive: true, force: true }).catch(() => void 0);
262
568
  }
263
- await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
264
569
  }
265
570
  }
266
571
  async function publishProfileLock(profile, lockPath, token) {
@@ -273,12 +578,18 @@ async function publishProfileLock(profile, lockPath, token) {
273
578
  return false;
274
579
  }
275
580
  await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
581
+ const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
276
582
  const staged = (0, import_node_path.join)((0, import_node_path.dirname)(lockPath), `.lock-owner-${token}.tmp`);
277
- const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token };
583
+ const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }, null, 2)}
584
+ `;
278
585
  try {
279
- await (0, import_promises.writeFile)(staged, `${JSON.stringify(owner, null, 2)}
280
- `, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
281
- await (0, import_promises.link)(staged, (0, import_node_path.join)(lockPath, "owner.json"));
586
+ await (0, import_promises.writeFile)(staged, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
587
+ try {
588
+ await (0, import_promises.link)(staged, ownerPath);
589
+ } catch (error) {
590
+ if (!lacksHardLinks(error)) throw error;
591
+ await (0, import_promises.writeFile)(ownerPath, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
592
+ }
282
593
  return true;
283
594
  } catch (error) {
284
595
  if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
@@ -288,6 +599,10 @@ async function publishProfileLock(profile, lockPath, token) {
288
599
  await (0, import_promises.rm)(staged, { force: true }).catch(() => void 0);
289
600
  }
290
601
  }
602
+ var NO_HARD_LINK_ERRORS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EISDIR", "EMLINK"]);
603
+ function lacksHardLinks(error) {
604
+ return typeof error === "object" && error !== null && "code" in error && NO_HARD_LINK_ERRORS.has(String(error.code));
605
+ }
291
606
  async function releaseProfileLock(profile, lockPath, token) {
292
607
  const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
293
608
  const owner = await readJson(ownerPath).catch(() => null);
@@ -311,9 +626,10 @@ async function signalTestLockContention(profile) {
311
626
  `, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
312
627
  }
313
628
  async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
629
+ let aged;
314
630
  try {
315
631
  const { mtimeMs } = await (0, import_promises.stat)(lockPath);
316
- if (Date.now() - mtimeMs < staleAfterMs) return false;
632
+ aged = Date.now() - mtimeMs >= staleAfterMs;
317
633
  } catch {
318
634
  return false;
319
635
  }
@@ -322,7 +638,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
322
638
  try {
323
639
  const entries = await (0, import_promises.readdir)(lockPath);
324
640
  if (entries.includes("owner.json")) return false;
325
- if (recoveryFenced(checkedAt, staleAfterMs)) return false;
641
+ const onlyOrphanedClaims = entries.length > 0 && entries.every((entry) => RECOVERY_CLAIM.test(entry));
642
+ if (!onlyOrphanedClaims && (!aged || recoveryFenced(checkedAt, staleAfterMs))) return false;
643
+ for (const name of entries.filter((entry) => RECOVERY_CLAIM.test(entry))) {
644
+ const claimPath = (0, import_node_path.join)(lockPath, name);
645
+ if (await hasLiveRecoveryOwner(claimPath)) {
646
+ 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"));
647
+ return false;
648
+ }
649
+ await (0, import_promises.rm)(claimPath, { force: true });
650
+ }
326
651
  for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
327
652
  await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
328
653
  }
@@ -333,10 +658,28 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
333
658
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
334
659
  return true;
335
660
  }
661
+ async function hasLiveRecoveryOwner(claimPath) {
662
+ let contents;
663
+ try {
664
+ contents = await (0, import_promises.readFile)(claimPath, "utf8");
665
+ } catch (error) {
666
+ if (isErrno(error, "ENOENT")) return false;
667
+ throw error;
668
+ }
669
+ let owner;
670
+ try {
671
+ owner = JSON.parse(contents);
672
+ } catch {
673
+ return false;
674
+ }
675
+ const pid = typeof owner === "object" && owner !== null && "pid" in owner ? owner.pid : null;
676
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && isProcessAlive(pid);
677
+ }
336
678
  function recoveryFenced(since, staleAfterMs) {
337
679
  return performance.now() - since >= staleAfterMs / 2;
338
680
  }
339
681
  var ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
682
+ var RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
340
683
  var STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
341
684
  async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
342
685
  const directory = (0, import_node_path.dirname)(lockPath);
@@ -349,37 +692,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
349
692
  }
350
693
  async function recoverStaleLock(profile, lockPath, staleAfterMs) {
351
694
  const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
352
- const owner = await readJson(ownerPath).catch(() => null);
353
- if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
354
- if (!isStaleOwner(owner, staleAfterMs)) return false;
355
- const observedToken = typeof owner?.token === "string" && owner.token.length > 0 ? owner.token : "legacy";
695
+ const observed = await (0, import_promises.readFile)(ownerPath, "utf8").catch(() => null);
696
+ if (observed === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
697
+ if (!await isAbandonedOwner(observed, ownerPath, staleAfterMs)) return false;
356
698
  await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
357
- const claimPath = (0, import_node_path.join)(lockPath, `.stale-${(0, import_node_crypto.randomUUID)()}.json`);
699
+ const claimPath = (0, import_node_path.join)(lockPath, `.recover-${(0, import_node_crypto.randomUUID)()}.json`);
700
+ let moved = false;
358
701
  try {
359
702
  await (0, import_promises.link)(ownerPath, claimPath);
360
- } catch {
361
- return false;
703
+ } catch (error) {
704
+ if (!lacksHardLinks(error)) return false;
705
+ try {
706
+ await (0, import_promises.rename)(ownerPath, claimPath);
707
+ moved = true;
708
+ } catch {
709
+ return false;
710
+ }
362
711
  }
363
- const claimedAt = performance.now();
364
- await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
365
- const claimed = await readJson(claimPath).catch(() => null);
366
- const sameInstance = claimed !== null && (observedToken === "legacy" ? claimed.token === void 0 : claimed.token === observedToken);
712
+ if (moved) await waitForTestBarrier(TEST_LOCK_MOVED_BARRIER_DIR, profile);
713
+ const sameInstance = await (0, import_promises.readFile)(claimPath, "utf8").then((claimed) => claimed === observed, () => false);
367
714
  await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
368
- if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
369
- await (0, import_promises.rm)(claimPath, { force: true });
370
- await waitForTestBarrier(TEST_LOCK_FENCED_BARRIER_DIR, profile);
715
+ if (!sameInstance) {
716
+ if (moved) {
717
+ await (0, import_promises.rename)(claimPath, ownerPath).catch(() => void 0);
718
+ } else {
719
+ await (0, import_promises.unlink)(claimPath).catch(() => void 0);
720
+ }
371
721
  return false;
372
722
  }
373
- if (sameInstance) {
374
- await (0, import_promises.rm)(ownerPath, { force: true });
375
- await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
723
+ if (!moved) await (0, import_promises.rm)(ownerPath, { force: true });
724
+ await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
725
+ await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
726
+ for (const name of (await (0, import_promises.readdir)(lockPath).catch(() => [])).filter((entry) => RECOVERY_CLAIM.test(entry))) {
727
+ if ((0, import_node_path.join)(lockPath, name) !== claimPath) await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
376
728
  }
377
- await (0, import_promises.rm)(claimPath, { force: true });
378
- await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
379
- if (!sameInstance) return false;
729
+ if (await (0, import_promises.unlink)(claimPath).then(() => true, () => false)) await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
380
730
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
381
731
  return true;
382
732
  }
733
+ async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
734
+ let owner;
735
+ try {
736
+ owner = JSON.parse(observed);
737
+ } catch {
738
+ owner = null;
739
+ }
740
+ if (typeof owner === "object" && owner !== null && "pid" in owner) {
741
+ return isStaleOwner(owner, staleAfterMs);
742
+ }
743
+ const modifiedAt = await (0, import_promises.stat)(ownerPath).then(({ mtimeMs }) => mtimeMs, () => Number.NaN);
744
+ return Date.now() - modifiedAt >= staleAfterMs;
745
+ }
383
746
  async function waitForTestBarrier(environmentName, profile) {
384
747
  if (process.env.NODE_ENV !== "test") return;
385
748
  const barrierDirectory = process.env[environmentName];
@@ -406,6 +769,15 @@ function isStaleOwner(owner, staleAfterMs) {
406
769
  }
407
770
  return !isProcessAlive(owner.pid);
408
771
  }
772
+ async function exists(path) {
773
+ try {
774
+ await (0, import_promises.stat)(path);
775
+ return true;
776
+ } catch (error) {
777
+ if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) return false;
778
+ throw error;
779
+ }
780
+ }
409
781
  function isProcessAlive(pid) {
410
782
  try {
411
783
  process.kill(pid, 0);
@@ -429,6 +801,7 @@ function sleep(milliseconds) {
429
801
  // Annotate the CommonJS export names for ESM import in node:
430
802
  0 && (module.exports = {
431
803
  DEFAULT_PROFILE,
804
+ ProfileDeletedError,
432
805
  ProfileLockTimeoutError,
433
806
  additionalDelegationsPath,
434
807
  authRequestsPath,
@@ -438,6 +811,7 @@ function sleep(milliseconds) {
438
811
  profilePath,
439
812
  profileStoreMetadataPath,
440
813
  profileStorePath,
814
+ profileTurnLockPath,
441
815
  profilesPath,
442
816
  readAdditionalDelegations,
443
817
  readAuthRequests,
@@ -446,6 +820,8 @@ function sleep(milliseconds) {
446
820
  readProfileStore,
447
821
  readSession,
448
822
  readStoreMetadata,
823
+ recordProfileDeletion,
824
+ refuseWriteToDeletedProfile,
449
825
  removeSession,
450
826
  sessionPath,
451
827
  tinycloudConfigPath,