@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.
package/dist/state.js CHANGED
@@ -5,12 +5,14 @@ import {
5
5
  chmod,
6
6
  link,
7
7
  mkdir,
8
+ open,
8
9
  readFile,
9
10
  readdir,
10
11
  rename,
11
12
  rmdir,
12
13
  rm,
13
14
  stat,
15
+ unlink,
14
16
  writeFile
15
17
  } from "fs/promises";
16
18
  import { homedir } from "os";
@@ -28,22 +30,33 @@ var TEST_LOCK_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_PUBLISH_BARRIER_DIR";
28
30
  var TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
29
31
  var TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
30
32
  var TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
31
- var TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
32
33
  var TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
34
+ var TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
35
+ var TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
36
+ var TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
37
+ var TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
33
38
  function processWideContext(key) {
34
- var _a;
39
+ var _a2;
35
40
  const registry = globalThis;
36
- return registry[_a = Symbol.for(key)] ?? (registry[_a] = new AsyncLocalStorage());
41
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new AsyncLocalStorage());
37
42
  }
38
43
  var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
39
- var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v1");
44
+ var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
40
45
  var ProfileLockTimeoutError = class extends Error {
41
- constructor(profile, timeoutMs) {
42
- super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.`);
46
+ /** `detail` names the cause when it is not simply another process holding the lock. */
47
+ constructor(profile, timeoutMs, detail) {
48
+ super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.${detail ? ` ${detail}` : ""}`);
43
49
  this.code = "PROFILE_LOCK_TIMEOUT";
44
50
  this.name = "ProfileLockTimeoutError";
45
51
  }
46
52
  };
53
+ var ProfileDeletedError = class extends Error {
54
+ constructor(profile) {
55
+ super(`Profile "${profile}" was deleted while this change waited for its lock, so the change was not written.`);
56
+ this.code = "PROFILE_NOT_FOUND";
57
+ this.name = "ProfileDeletedError";
58
+ }
59
+ };
47
60
  function tinycloudHomePath() {
48
61
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
49
62
  return join(home, ".tinycloud");
@@ -88,6 +101,9 @@ function profileLockPath(profile) {
88
101
  function profileLockMetadataPath(profile) {
89
102
  return join(profileLockPath(profile), "owner.json");
90
103
  }
104
+ function profileTurnLockPath(profile) {
105
+ return join(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
106
+ }
91
107
  async function readJson(filePath) {
92
108
  try {
93
109
  return JSON.parse(await readFile(filePath, "utf8"));
@@ -97,6 +113,9 @@ async function readJson(filePath) {
97
113
  }
98
114
  }
99
115
  async function writeJsonAtomic(filePath, value) {
116
+ await replaceJson(filePath, value, false);
117
+ }
118
+ async function replaceJson(filePath, value, durable) {
100
119
  const directory = dirname(filePath);
101
120
  const temporaryPath = join(
102
121
  directory,
@@ -106,13 +125,25 @@ async function writeJsonAtomic(filePath, value) {
106
125
  `;
107
126
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
108
127
  try {
109
- await writeFile(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
128
+ if (durable) await writeFileDurably(temporaryPath, contents);
129
+ else await writeFile(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
110
130
  await rename(temporaryPath, filePath);
111
131
  } catch (error) {
112
132
  await rm(temporaryPath, { force: true }).catch(() => void 0);
113
133
  throw error;
114
134
  }
115
135
  }
136
+ async function writeFileDurably(path, contents) {
137
+ const handle = await open(path, "wx", PRIVATE_FILE_MODE);
138
+ try {
139
+ if (contents) {
140
+ await handle.writeFile(contents, "utf8");
141
+ await handle.datasync();
142
+ }
143
+ } finally {
144
+ await handle.close();
145
+ }
146
+ }
116
147
  async function readProfile(profile) {
117
148
  return readJson(profileConfigPath(profile));
118
149
  }
@@ -149,18 +180,38 @@ async function withProfileLock(profile, action, options = {}) {
149
180
  const lockPath = profileLockPath(normalizedProfile);
150
181
  const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
151
182
  if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
152
- const release = await acquireProfileLock(normalizedProfile, options);
153
- const ownership = { lockPath, active: true };
183
+ const acquired = await acquireProfileLock(normalizedProfile, options);
184
+ const ownership = {
185
+ lockPath,
186
+ turnSlot: acquired.turnSlot,
187
+ deletedWhileWaiting: acquired.deletedWhileWaiting,
188
+ active: true
189
+ };
154
190
  try {
155
191
  return await heldProfileLocks.run([...held, ownership], action);
156
192
  } finally {
157
193
  ownership.active = false;
158
- await release();
194
+ await acquired.release();
195
+ if (acquired.deletedWhileWaiting) await rmdir(profilePath(normalizedProfile)).catch(() => void 0);
159
196
  }
160
197
  }
198
+ async function recordProfileDeletion(profile) {
199
+ const lockPath = profileLockPath(profile);
200
+ const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
201
+ if (!ownership) throw new Error(`Recording the deletion of profile "${profile}" requires holding its lock.`);
202
+ await replaceJson(join(profileTurnLockPath(profile), "deleted.json"), { slot: ownership.turnSlot }, true);
203
+ }
204
+ async function refuseWriteToDeletedProfile(profile) {
205
+ const lockPath = profileLockPath(profile);
206
+ const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
207
+ if (!ownership?.deletedWhileWaiting) return;
208
+ if (await exists(profileConfigPath(profile))) return;
209
+ throw new ProfileDeletedError(profile);
210
+ }
161
211
  async function upsertProfileRecord(profile, store, key, record, getKey, options = {}) {
162
212
  if (!key) throw new TypeError("A non-empty record key is required.");
163
213
  return withProfileLock(profile, async () => {
214
+ await refuseWriteToDeletedProfile(profile);
164
215
  const current = (await readProfileStore(profile, store)).records;
165
216
  const next = current.filter((candidate) => getKey(candidate) !== key);
166
217
  next.push(record);
@@ -177,6 +228,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
177
228
  );
178
229
  }
179
230
  async function updateProfileStoreWhileLocked(profile, store, update) {
231
+ await refuseWriteToDeletedProfile(profile);
180
232
  const current = await readProfileStore(profile, store);
181
233
  const next = await update(current.records);
182
234
  await writeJsonAtomic(profileStorePath(profile, store), next.records);
@@ -185,6 +237,7 @@ async function updateProfileStoreWhileLocked(profile, store, update) {
185
237
  }
186
238
  async function writeSession(profile, session, options = {}) {
187
239
  await withProfileLock(profile, async () => {
240
+ await refuseWriteToDeletedProfile(profile);
188
241
  await readStoreMetadata(profile, "session");
189
242
  await writeJsonAtomic(sessionPath(profile), session);
190
243
  await writeFormatOneMetadata(profile, "session");
@@ -200,32 +253,277 @@ async function writeFormatOneMetadata(profile, store) {
200
253
  await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
201
254
  }
202
255
  async function acquireProfileLock(profile, options) {
203
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
204
- const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
256
+ const deadline = {
257
+ startedAt: Date.now(),
258
+ timeoutMs: options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
259
+ retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
260
+ };
205
261
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
206
- const startedAt = Date.now();
207
262
  const lockPath = profileLockPath(profile);
263
+ const turnDirectory = profileTurnLockPath(profile);
208
264
  const directory = profilePath(profile);
209
265
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
266
+ await mkdir(dirname(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
210
267
  for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
211
268
  await chmod(path, PRIVATE_DIR_MODE).catch((error) => {
212
269
  if (path !== directory || !isErrno(error, "ENOENT")) throw error;
213
270
  });
214
271
  }
215
- while (true) {
216
- const token = randomUUID();
217
- if (await publishProfileLock(profile, lockPath, token)) {
218
- return async () => {
219
- await releaseProfileLock(profile, lockPath, token);
220
- };
272
+ const { turn, firstWaitedTurn } = await acquireTurn(profile, turnDirectory, deadline);
273
+ try {
274
+ const deletion = await readJson(join(turnDirectory, "deleted.json")).catch(() => null);
275
+ const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
276
+ while (true) {
277
+ const token = randomUUID();
278
+ if (await publishProfileLock(profile, lockPath, token)) {
279
+ return {
280
+ deletedWhileWaiting,
281
+ turnSlot: turn.slot,
282
+ release: async () => {
283
+ try {
284
+ await releaseProfileLock(profile, lockPath, token);
285
+ } finally {
286
+ await releaseTurn(profile, turn);
287
+ }
288
+ }
289
+ };
290
+ }
291
+ await signalTestLockContention(profile);
292
+ if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
293
+ await waitOrTimeOut(profile, deadline);
294
+ }
295
+ } catch (error) {
296
+ await releaseTurn(profile, turn);
297
+ throw error;
298
+ }
299
+ }
300
+ function checkDeadline(profile, deadline, detail) {
301
+ if (Date.now() - deadline.startedAt >= deadline.timeoutMs) {
302
+ throw new ProfileLockTimeoutError(profile, deadline.timeoutMs, detail);
303
+ }
304
+ }
305
+ async function waitOrTimeOut(profile, deadline, detail) {
306
+ checkDeadline(profile, deadline, detail);
307
+ await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
308
+ }
309
+ var TURN = /^(?:0|[1-9][0-9]*)$/;
310
+ var _a;
311
+ var relinquishedTurns = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Set());
312
+ var MARKER_RETRY_DELAYS_MS = [10, 50, 250];
313
+ var SETTLE_RETRY_MS = 1e3;
314
+ var SETTLE_RETRIES = 300;
315
+ async function acquireTurn(profile, directory, deadline) {
316
+ let firstWaitedTurn;
317
+ let problem;
318
+ 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.`;
319
+ for (let first = true; ; first = false) {
320
+ if (!first) checkDeadline(profile, deadline, problem);
321
+ problem = void 0;
322
+ const latest = await readLatestTurn(directory);
323
+ if (latest === "missing") {
324
+ await createTurnDirectory(directory);
325
+ continue;
326
+ }
327
+ if (latest === "empty") {
328
+ problem = damaged("has no turns");
329
+ await waitOrTimeOut(profile, deadline, problem);
330
+ continue;
331
+ }
332
+ if (latest === null) continue;
333
+ if (typeof latest === "number") {
334
+ problem = damaged(`has an unreadable turn ${latest}`);
335
+ await waitOrTimeOut(profile, deadline, problem);
336
+ continue;
337
+ }
338
+ firstWaitedTurn ?? (firstWaitedTurn = latest.done ? latest.slot + 1 : latest.slot);
339
+ if (latest.held && latest.done) {
340
+ const turn = await takeTurn(profile, directory, latest);
341
+ if (turn !== null) return { turn, firstWaitedTurn };
342
+ await waitOrTimeOut(profile, deadline);
343
+ } else if (latest.done) {
344
+ problem = damaged(`ends with turn ${latest.slot}, which was never granted`);
345
+ await waitOrTimeOut(profile, deadline, problem);
346
+ } else if (latest.pid === process.pid ? relinquishedTurns.has(latest.token) : !isProcessAlive(latest.pid)) {
347
+ await waitForTestBarrier(TEST_LOCK_TURN_SETTLE_BARRIER_DIR, profile);
348
+ await settleAbandonedTurn(directory, latest);
349
+ relinquishedTurns.delete(latest.token);
350
+ } else {
351
+ await signalTestLockContention(profile);
352
+ await waitOrTimeOut(profile, deadline);
353
+ }
354
+ }
355
+ }
356
+ async function readLatestTurn(directory) {
357
+ let names;
358
+ try {
359
+ names = await readdir(directory);
360
+ } catch (error) {
361
+ if (isErrno(error, "ENOENT")) return "missing";
362
+ throw error;
363
+ }
364
+ const slots = names.filter((name) => TURN.test(name)).map(Number);
365
+ if (slots.length === 0) return "empty";
366
+ const slot = Math.max(...slots);
367
+ const turn = await readTurn(directory, slot);
368
+ return turn === "damaged" ? slot : turn;
369
+ }
370
+ async function readTurn(directory, slot) {
371
+ const path = join(directory, String(slot));
372
+ let text;
373
+ try {
374
+ text = await readFile(join(path, "owner.json"), "utf8");
375
+ } catch (error) {
376
+ if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTDIR")) throw error;
377
+ return await exists(path) ? "damaged" : null;
378
+ }
379
+ let owner;
380
+ try {
381
+ owner = JSON.parse(text);
382
+ } catch {
383
+ return "damaged";
384
+ }
385
+ if (owner === null || typeof owner !== "object" || typeof owner.token !== "string" || typeof owner.pid !== "number") {
386
+ return "damaged";
387
+ }
388
+ const done = await exists(join(path, `${owner.token}.done`));
389
+ const held = await exists(join(path, `${owner.token}.held`));
390
+ return {
391
+ slot,
392
+ token: owner.token,
393
+ pid: owner.pid,
394
+ after: typeof owner.after === "string" ? owner.after : null,
395
+ held,
396
+ done
397
+ };
398
+ }
399
+ async function isFinishedTurn(directory, slot, token) {
400
+ const turn = await readTurn(directory, slot);
401
+ if (turn === "damaged") throw new Error(`Turn ${slot} of the turn lock ${directory} is unreadable.`);
402
+ return turn !== null && turn.token === token && turn.held && turn.done;
403
+ }
404
+ async function takeTurn(profile, directory, latest) {
405
+ const token = randomUUID();
406
+ const slot = latest.slot + 1;
407
+ const path = join(directory, String(slot));
408
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token };
409
+ let published = false;
410
+ try {
411
+ published = await publishDirectory(
412
+ join(directory, `.stage-${process.pid}-${token}`),
413
+ path,
414
+ { "owner.json": `${JSON.stringify(owner, null, 2)}
415
+ ` },
416
+ () => waitForTestBarrier(TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, profile)
417
+ );
418
+ if (published && await isFinishedTurn(directory, latest.slot, latest.token) && await markTurn(path, token, "held")) {
419
+ return { directory, slot, token };
420
+ }
421
+ if (published) await markTurn(path, token, "done");
422
+ return null;
423
+ } catch (error) {
424
+ if (published) settleOwnTurn(directory, slot, token);
425
+ throw error;
426
+ }
427
+ }
428
+ async function settleAbandonedTurn(directory, abandoned) {
429
+ const path = join(directory, String(abandoned.slot));
430
+ if (!abandoned.held && abandoned.after !== null && await isFinishedTurn(directory, abandoned.slot - 1, abandoned.after)) {
431
+ await markTurn(path, abandoned.token, "held");
432
+ }
433
+ await markTurn(path, abandoned.token, "done");
434
+ }
435
+ function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
436
+ relinquishedTurns.add(token);
437
+ void (async () => {
438
+ try {
439
+ const turn = await readTurn(directory, slot);
440
+ if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
441
+ relinquishedTurns.delete(token);
442
+ } catch {
443
+ if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
221
444
  }
222
- await signalTestLockContention(profile);
223
- if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
224
- const elapsedMs = Date.now() - startedAt;
225
- if (elapsedMs >= timeoutMs) {
226
- throw new ProfileLockTimeoutError(profile, timeoutMs);
445
+ })();
446
+ }
447
+ async function markTurn(path, token, kind) {
448
+ for (let attempt = 0; ; attempt++) {
449
+ try {
450
+ await writeFile(join(path, `${token}.${kind}`), "", { mode: PRIVATE_FILE_MODE, flag: "wx" });
451
+ return true;
452
+ } catch (error) {
453
+ if (isErrno(error, "EEXIST")) return true;
454
+ if (isErrno(error, "ENOENT")) return false;
455
+ if (attempt >= MARKER_RETRY_DELAYS_MS.length) throw error;
456
+ await sleep(MARKER_RETRY_DELAYS_MS[attempt]);
457
+ }
458
+ }
459
+ }
460
+ async function createTurnDirectory(directory) {
461
+ const token = randomUUID();
462
+ const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null };
463
+ await publishDirectory(join(dirname(directory), `.stage-${basename(directory)}-${process.pid}-${token}`), directory, {
464
+ "0/owner.json": `${JSON.stringify(owner, null, 2)}
465
+ `,
466
+ [`0/${token}.held`]: "",
467
+ [`0/${token}.done`]: ""
468
+ });
469
+ }
470
+ async function publishDirectory(stage, target, files, beforeRename) {
471
+ try {
472
+ for (const [name, contents] of Object.entries(files)) {
473
+ await mkdir(dirname(join(stage, name)), { recursive: true, mode: PRIVATE_DIR_MODE });
474
+ await writeFileDurably(join(stage, name), contents);
475
+ }
476
+ await beforeRename?.();
477
+ await rename(stage, target);
478
+ return true;
479
+ } catch (error) {
480
+ if (["ENOTEMPTY", "EEXIST", "ENOENT"].some((code) => isErrno(error, code)) || await exists(target)) return false;
481
+ throw error;
482
+ } finally {
483
+ await rm(stage, { recursive: true, force: true }).catch(() => void 0);
484
+ }
485
+ }
486
+ async function releaseTurn(profile, turn) {
487
+ try {
488
+ await markTurn(join(turn.directory, String(turn.slot)), turn.token, "done");
489
+ } catch {
490
+ settleOwnTurn(turn.directory, turn.slot, turn.token);
491
+ return;
492
+ }
493
+ await collectTurns(profile, turn).catch(() => void 0);
494
+ }
495
+ function isAbandonedTurnEntry(name) {
496
+ if (name.startsWith(".trash-")) return true;
497
+ const writer = /^\.(?:stage|deleted\.json)[-.]([0-9]+)[-.]/.exec(name)?.[1];
498
+ return writer !== void 0 && !isProcessAlive(Number(writer));
499
+ }
500
+ function isAbandonedTurnDirectoryStage(name) {
501
+ 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];
502
+ return writer !== void 0 && !isProcessAlive(Number(writer));
503
+ }
504
+ async function collectTurns(profile, turn) {
505
+ for (const [directory, abandoned] of [
506
+ [turn.directory, isAbandonedTurnEntry],
507
+ [dirname(turn.directory), isAbandonedTurnDirectoryStage]
508
+ ]) {
509
+ for (const name of (await readdir(directory)).filter(abandoned)) {
510
+ await rm(join(directory, name), { recursive: true, force: true }).catch(() => void 0);
511
+ }
512
+ }
513
+ const older = (await readdir(turn.directory)).filter((name) => TURN.test(name)).map(Number).filter((slot) => slot < turn.slot).sort((left, right) => left - right);
514
+ for (const slot of older) {
515
+ const candidate = await readTurn(turn.directory, slot);
516
+ if (candidate === null || candidate === "damaged" || !candidate.done) continue;
517
+ if (candidate.held) {
518
+ if (await exists(join(turn.directory, String(slot - 1)))) continue;
519
+ const next = await readTurn(turn.directory, slot + 1);
520
+ if (next === null || next === "damaged" || !next.held || !next.done) continue;
521
+ }
522
+ await waitForTestBarrier(TEST_LOCK_TURN_COLLECT_BARRIER_DIR, profile);
523
+ const trash = join(turn.directory, `.trash-${randomUUID()}`);
524
+ if (await rename(join(turn.directory, String(slot)), trash).then(() => true, () => false)) {
525
+ await rm(trash, { recursive: true, force: true }).catch(() => void 0);
227
526
  }
228
- await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
229
527
  }
230
528
  }
231
529
  async function publishProfileLock(profile, lockPath, token) {
@@ -238,12 +536,18 @@ async function publishProfileLock(profile, lockPath, token) {
238
536
  return false;
239
537
  }
240
538
  await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
539
+ const ownerPath = join(lockPath, "owner.json");
241
540
  const staged = join(dirname(lockPath), `.lock-owner-${token}.tmp`);
242
- const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token };
541
+ const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }, null, 2)}
542
+ `;
243
543
  try {
244
- await writeFile(staged, `${JSON.stringify(owner, null, 2)}
245
- `, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
246
- await link(staged, join(lockPath, "owner.json"));
544
+ await writeFile(staged, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
545
+ try {
546
+ await link(staged, ownerPath);
547
+ } catch (error) {
548
+ if (!lacksHardLinks(error)) throw error;
549
+ await writeFile(ownerPath, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
550
+ }
247
551
  return true;
248
552
  } catch (error) {
249
553
  if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
@@ -253,6 +557,10 @@ async function publishProfileLock(profile, lockPath, token) {
253
557
  await rm(staged, { force: true }).catch(() => void 0);
254
558
  }
255
559
  }
560
+ var NO_HARD_LINK_ERRORS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EISDIR", "EMLINK"]);
561
+ function lacksHardLinks(error) {
562
+ return typeof error === "object" && error !== null && "code" in error && NO_HARD_LINK_ERRORS.has(String(error.code));
563
+ }
256
564
  async function releaseProfileLock(profile, lockPath, token) {
257
565
  const ownerPath = join(lockPath, "owner.json");
258
566
  const owner = await readJson(ownerPath).catch(() => null);
@@ -276,9 +584,10 @@ async function signalTestLockContention(profile) {
276
584
  `, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
277
585
  }
278
586
  async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
587
+ let aged;
279
588
  try {
280
589
  const { mtimeMs } = await stat(lockPath);
281
- if (Date.now() - mtimeMs < staleAfterMs) return false;
590
+ aged = Date.now() - mtimeMs >= staleAfterMs;
282
591
  } catch {
283
592
  return false;
284
593
  }
@@ -287,7 +596,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
287
596
  try {
288
597
  const entries = await readdir(lockPath);
289
598
  if (entries.includes("owner.json")) return false;
290
- if (recoveryFenced(checkedAt, staleAfterMs)) return false;
599
+ const onlyOrphanedClaims = entries.length > 0 && entries.every((entry) => RECOVERY_CLAIM.test(entry));
600
+ if (!onlyOrphanedClaims && (!aged || recoveryFenced(checkedAt, staleAfterMs))) return false;
601
+ for (const name of entries.filter((entry) => RECOVERY_CLAIM.test(entry))) {
602
+ const claimPath = join(lockPath, name);
603
+ if (await hasLiveRecoveryOwner(claimPath)) {
604
+ if (!await exists(join(lockPath, "owner.json"))) await rename(claimPath, join(lockPath, "owner.json"));
605
+ return false;
606
+ }
607
+ await rm(claimPath, { force: true });
608
+ }
291
609
  for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
292
610
  await rm(join(lockPath, name), { force: true });
293
611
  }
@@ -298,10 +616,28 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
298
616
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
299
617
  return true;
300
618
  }
619
+ async function hasLiveRecoveryOwner(claimPath) {
620
+ let contents;
621
+ try {
622
+ contents = await readFile(claimPath, "utf8");
623
+ } catch (error) {
624
+ if (isErrno(error, "ENOENT")) return false;
625
+ throw error;
626
+ }
627
+ let owner;
628
+ try {
629
+ owner = JSON.parse(contents);
630
+ } catch {
631
+ return false;
632
+ }
633
+ const pid = typeof owner === "object" && owner !== null && "pid" in owner ? owner.pid : null;
634
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && isProcessAlive(pid);
635
+ }
301
636
  function recoveryFenced(since, staleAfterMs) {
302
637
  return performance.now() - since >= staleAfterMs / 2;
303
638
  }
304
639
  var ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
640
+ var RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
305
641
  var STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
306
642
  async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
307
643
  const directory = dirname(lockPath);
@@ -314,37 +650,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
314
650
  }
315
651
  async function recoverStaleLock(profile, lockPath, staleAfterMs) {
316
652
  const ownerPath = join(lockPath, "owner.json");
317
- const owner = await readJson(ownerPath).catch(() => null);
318
- if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
319
- if (!isStaleOwner(owner, staleAfterMs)) return false;
320
- const observedToken = typeof owner?.token === "string" && owner.token.length > 0 ? owner.token : "legacy";
653
+ const observed = await readFile(ownerPath, "utf8").catch(() => null);
654
+ if (observed === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
655
+ if (!await isAbandonedOwner(observed, ownerPath, staleAfterMs)) return false;
321
656
  await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
322
- const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);
657
+ const claimPath = join(lockPath, `.recover-${randomUUID()}.json`);
658
+ let moved = false;
323
659
  try {
324
660
  await link(ownerPath, claimPath);
325
- } catch {
326
- return false;
661
+ } catch (error) {
662
+ if (!lacksHardLinks(error)) return false;
663
+ try {
664
+ await rename(ownerPath, claimPath);
665
+ moved = true;
666
+ } catch {
667
+ return false;
668
+ }
327
669
  }
328
- const claimedAt = performance.now();
329
- await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
330
- const claimed = await readJson(claimPath).catch(() => null);
331
- const sameInstance = claimed !== null && (observedToken === "legacy" ? claimed.token === void 0 : claimed.token === observedToken);
670
+ if (moved) await waitForTestBarrier(TEST_LOCK_MOVED_BARRIER_DIR, profile);
671
+ const sameInstance = await readFile(claimPath, "utf8").then((claimed) => claimed === observed, () => false);
332
672
  await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
333
- if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
334
- await rm(claimPath, { force: true });
335
- await waitForTestBarrier(TEST_LOCK_FENCED_BARRIER_DIR, profile);
673
+ if (!sameInstance) {
674
+ if (moved) {
675
+ await rename(claimPath, ownerPath).catch(() => void 0);
676
+ } else {
677
+ await unlink(claimPath).catch(() => void 0);
678
+ }
336
679
  return false;
337
680
  }
338
- if (sameInstance) {
339
- await rm(ownerPath, { force: true });
340
- await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
681
+ if (!moved) await rm(ownerPath, { force: true });
682
+ await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
683
+ await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
684
+ for (const name of (await readdir(lockPath).catch(() => [])).filter((entry) => RECOVERY_CLAIM.test(entry))) {
685
+ if (join(lockPath, name) !== claimPath) await rm(join(lockPath, name), { force: true });
341
686
  }
342
- await rm(claimPath, { force: true });
343
- await rmdir(lockPath).catch(() => void 0);
344
- if (!sameInstance) return false;
687
+ if (await unlink(claimPath).then(() => true, () => false)) await rmdir(lockPath).catch(() => void 0);
345
688
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
346
689
  return true;
347
690
  }
691
+ async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
692
+ let owner;
693
+ try {
694
+ owner = JSON.parse(observed);
695
+ } catch {
696
+ owner = null;
697
+ }
698
+ if (typeof owner === "object" && owner !== null && "pid" in owner) {
699
+ return isStaleOwner(owner, staleAfterMs);
700
+ }
701
+ const modifiedAt = await stat(ownerPath).then(({ mtimeMs }) => mtimeMs, () => Number.NaN);
702
+ return Date.now() - modifiedAt >= staleAfterMs;
703
+ }
348
704
  async function waitForTestBarrier(environmentName, profile) {
349
705
  if (process.env.NODE_ENV !== "test") return;
350
706
  const barrierDirectory = process.env[environmentName];
@@ -371,6 +727,15 @@ function isStaleOwner(owner, staleAfterMs) {
371
727
  }
372
728
  return !isProcessAlive(owner.pid);
373
729
  }
730
+ async function exists(path) {
731
+ try {
732
+ await stat(path);
733
+ return true;
734
+ } catch (error) {
735
+ if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) return false;
736
+ throw error;
737
+ }
738
+ }
374
739
  function isProcessAlive(pid) {
375
740
  try {
376
741
  process.kill(pid, 0);
@@ -393,6 +758,7 @@ function sleep(milliseconds) {
393
758
  }
394
759
  export {
395
760
  DEFAULT_PROFILE,
761
+ ProfileDeletedError,
396
762
  ProfileLockTimeoutError,
397
763
  additionalDelegationsPath,
398
764
  authRequestsPath,
@@ -402,6 +768,7 @@ export {
402
768
  profilePath,
403
769
  profileStoreMetadataPath,
404
770
  profileStorePath,
771
+ profileTurnLockPath,
405
772
  profilesPath,
406
773
  readAdditionalDelegations,
407
774
  readAuthRequests,
@@ -410,6 +777,8 @@ export {
410
777
  readProfileStore,
411
778
  readSession,
412
779
  readStoreMetadata,
780
+ recordProfileDeletion,
781
+ refuseWriteToDeletedProfile,
413
782
  removeSession,
414
783
  sessionPath,
415
784
  tinycloudConfigPath,