@tinycloud/operations 0.4.0-beta.7 → 0.4.0-beta.9

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,22 +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";
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";
73
80
  function processWideContext(key) {
74
- var _a;
81
+ var _a2;
75
82
  const registry = globalThis;
76
- return registry[_a = Symbol.for(key)] ?? (registry[_a] = new import_node_async_hooks.AsyncLocalStorage());
83
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new import_node_async_hooks.AsyncLocalStorage());
77
84
  }
78
85
  var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
79
- var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v1");
86
+ var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
80
87
  var ProfileLockTimeoutError = class extends Error {
81
- constructor(profile, timeoutMs) {
82
- 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}` : ""}`);
83
91
  this.code = "PROFILE_LOCK_TIMEOUT";
84
92
  this.name = "ProfileLockTimeoutError";
85
93
  }
86
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
+ };
87
102
  function tinycloudHomePath() {
88
103
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
89
104
  return (0, import_node_path.join)(home, ".tinycloud");
@@ -128,6 +143,9 @@ function profileLockPath(profile) {
128
143
  function profileLockMetadataPath(profile) {
129
144
  return (0, import_node_path.join)(profileLockPath(profile), "owner.json");
130
145
  }
146
+ function profileTurnLockPath(profile) {
147
+ return (0, import_node_path.join)(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
148
+ }
131
149
  async function readJson(filePath) {
132
150
  try {
133
151
  return JSON.parse(await (0, import_promises.readFile)(filePath, "utf8"));
@@ -137,6 +155,9 @@ async function readJson(filePath) {
137
155
  }
138
156
  }
139
157
  async function writeJsonAtomic(filePath, value) {
158
+ await replaceJson(filePath, value, false);
159
+ }
160
+ async function replaceJson(filePath, value, durable) {
140
161
  const directory = (0, import_node_path.dirname)(filePath);
141
162
  const temporaryPath = (0, import_node_path.join)(
142
163
  directory,
@@ -146,13 +167,25 @@ async function writeJsonAtomic(filePath, value) {
146
167
  `;
147
168
  await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
148
169
  try {
149
- 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 });
150
172
  await (0, import_promises.rename)(temporaryPath, filePath);
151
173
  } catch (error) {
152
174
  await (0, import_promises.rm)(temporaryPath, { force: true }).catch(() => void 0);
153
175
  throw error;
154
176
  }
155
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
+ }
156
189
  async function readProfile(profile) {
157
190
  return readJson(profileConfigPath(profile));
158
191
  }
@@ -189,18 +222,38 @@ async function withProfileLock(profile, action, options = {}) {
189
222
  const lockPath = profileLockPath(normalizedProfile);
190
223
  const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
191
224
  if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
192
- const release = await acquireProfileLock(normalizedProfile, options);
193
- 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
+ };
194
232
  try {
195
233
  return await heldProfileLocks.run([...held, ownership], action);
196
234
  } finally {
197
235
  ownership.active = false;
198
- await release();
236
+ await acquired.release();
237
+ if (acquired.deletedWhileWaiting) await (0, import_promises.rmdir)(profilePath(normalizedProfile)).catch(() => void 0);
199
238
  }
200
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
+ }
201
253
  async function upsertProfileRecord(profile, store, key, record, getKey, options = {}) {
202
254
  if (!key) throw new TypeError("A non-empty record key is required.");
203
255
  return withProfileLock(profile, async () => {
256
+ await refuseWriteToDeletedProfile(profile);
204
257
  const current = (await readProfileStore(profile, store)).records;
205
258
  const next = current.filter((candidate) => getKey(candidate) !== key);
206
259
  next.push(record);
@@ -217,6 +270,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
217
270
  );
218
271
  }
219
272
  async function updateProfileStoreWhileLocked(profile, store, update) {
273
+ await refuseWriteToDeletedProfile(profile);
220
274
  const current = await readProfileStore(profile, store);
221
275
  const next = await update(current.records);
222
276
  await writeJsonAtomic(profileStorePath(profile, store), next.records);
@@ -225,6 +279,7 @@ async function updateProfileStoreWhileLocked(profile, store, update) {
225
279
  }
226
280
  async function writeSession(profile, session, options = {}) {
227
281
  await withProfileLock(profile, async () => {
282
+ await refuseWriteToDeletedProfile(profile);
228
283
  await readStoreMetadata(profile, "session");
229
284
  await writeJsonAtomic(sessionPath(profile), session);
230
285
  await writeFormatOneMetadata(profile, "session");
@@ -240,32 +295,277 @@ async function writeFormatOneMetadata(profile, store) {
240
295
  await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
241
296
  }
242
297
  async function acquireProfileLock(profile, options) {
243
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
244
- 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
+ };
245
303
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
246
- const startedAt = Date.now();
247
304
  const lockPath = profileLockPath(profile);
305
+ const turnDirectory = profileTurnLockPath(profile);
248
306
  const directory = profilePath(profile);
249
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 });
250
309
  for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
251
310
  await (0, import_promises.chmod)(path, PRIVATE_DIR_MODE).catch((error) => {
252
311
  if (path !== directory || !isErrno(error, "ENOENT")) throw error;
253
312
  });
254
313
  }
255
- while (true) {
256
- const token = (0, import_node_crypto.randomUUID)();
257
- if (await publishProfileLock(profile, lockPath, token)) {
258
- return async () => {
259
- await releaseProfileLock(profile, lockPath, token);
260
- };
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();
261
486
  }
262
- await signalTestLockContention(profile);
263
- if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
264
- const elapsedMs = Date.now() - startedAt;
265
- if (elapsedMs >= timeoutMs) {
266
- 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);
267
568
  }
268
- await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
269
569
  }
270
570
  }
271
571
  async function publishProfileLock(profile, lockPath, token) {
@@ -278,12 +578,18 @@ async function publishProfileLock(profile, lockPath, token) {
278
578
  return false;
279
579
  }
280
580
  await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
581
+ const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
281
582
  const staged = (0, import_node_path.join)((0, import_node_path.dirname)(lockPath), `.lock-owner-${token}.tmp`);
282
- 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
+ `;
283
585
  try {
284
- await (0, import_promises.writeFile)(staged, `${JSON.stringify(owner, null, 2)}
285
- `, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
286
- 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
+ }
287
593
  return true;
288
594
  } catch (error) {
289
595
  if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
@@ -293,6 +599,10 @@ async function publishProfileLock(profile, lockPath, token) {
293
599
  await (0, import_promises.rm)(staged, { force: true }).catch(() => void 0);
294
600
  }
295
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
+ }
296
606
  async function releaseProfileLock(profile, lockPath, token) {
297
607
  const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
298
608
  const owner = await readJson(ownerPath).catch(() => null);
@@ -316,9 +626,10 @@ async function signalTestLockContention(profile) {
316
626
  `, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
317
627
  }
318
628
  async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
629
+ let aged;
319
630
  try {
320
631
  const { mtimeMs } = await (0, import_promises.stat)(lockPath);
321
- if (Date.now() - mtimeMs < staleAfterMs) return false;
632
+ aged = Date.now() - mtimeMs >= staleAfterMs;
322
633
  } catch {
323
634
  return false;
324
635
  }
@@ -327,7 +638,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
327
638
  try {
328
639
  const entries = await (0, import_promises.readdir)(lockPath);
329
640
  if (entries.includes("owner.json")) return false;
330
- 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
+ }
331
651
  for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
332
652
  await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
333
653
  }
@@ -338,10 +658,28 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
338
658
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
339
659
  return true;
340
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
+ }
341
678
  function recoveryFenced(since, staleAfterMs) {
342
679
  return performance.now() - since >= staleAfterMs / 2;
343
680
  }
344
681
  var ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
682
+ var RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
345
683
  var STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
346
684
  async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
347
685
  const directory = (0, import_node_path.dirname)(lockPath);
@@ -354,37 +692,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
354
692
  }
355
693
  async function recoverStaleLock(profile, lockPath, staleAfterMs) {
356
694
  const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
357
- const owner = await readJson(ownerPath).catch(() => null);
358
- if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
359
- if (!isStaleOwner(owner, staleAfterMs)) return false;
360
- 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;
361
698
  await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
362
- 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;
363
701
  try {
364
702
  await (0, import_promises.link)(ownerPath, claimPath);
365
- } catch {
366
- 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
+ }
367
711
  }
368
- const claimedAt = performance.now();
369
- await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
370
- const claimed = await readJson(claimPath).catch(() => null);
371
- 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);
372
714
  await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
373
- if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
374
- await (0, import_promises.rm)(claimPath, { force: true });
375
- 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
+ }
376
721
  return false;
377
722
  }
378
- if (sameInstance) {
379
- await (0, import_promises.rm)(ownerPath, { force: true });
380
- 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 });
381
728
  }
382
- await (0, import_promises.rm)(claimPath, { force: true });
383
- await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
384
- if (!sameInstance) return false;
729
+ if (await (0, import_promises.unlink)(claimPath).then(() => true, () => false)) await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
385
730
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
386
731
  return true;
387
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
+ }
388
746
  async function waitForTestBarrier(environmentName, profile) {
389
747
  if (process.env.NODE_ENV !== "test") return;
390
748
  const barrierDirectory = process.env[environmentName];
@@ -411,6 +769,15 @@ function isStaleOwner(owner, staleAfterMs) {
411
769
  }
412
770
  return !isProcessAlive(owner.pid);
413
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
+ }
414
781
  function isProcessAlive(pid) {
415
782
  try {
416
783
  process.kill(pid, 0);
@@ -434,6 +801,7 @@ function sleep(milliseconds) {
434
801
  // Annotate the CommonJS export names for ESM import in node:
435
802
  0 && (module.exports = {
436
803
  DEFAULT_PROFILE,
804
+ ProfileDeletedError,
437
805
  ProfileLockTimeoutError,
438
806
  additionalDelegationsPath,
439
807
  authRequestsPath,
@@ -443,6 +811,7 @@ function sleep(milliseconds) {
443
811
  profilePath,
444
812
  profileStoreMetadataPath,
445
813
  profileStorePath,
814
+ profileTurnLockPath,
446
815
  profilesPath,
447
816
  readAdditionalDelegations,
448
817
  readAuthRequests,
@@ -451,6 +820,8 @@ function sleep(milliseconds) {
451
820
  readProfileStore,
452
821
  readSession,
453
822
  readStoreMetadata,
823
+ recordProfileDeletion,
824
+ refuseWriteToDeletedProfile,
454
825
  removeSession,
455
826
  sessionPath,
456
827
  tinycloudConfigPath,