@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.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,17 +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";
33
- var invocationStateRoot = new AsyncLocalStorage();
34
- var heldProfileLocks = new AsyncLocalStorage();
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";
38
+ function processWideContext(key) {
39
+ var _a2;
40
+ const registry = globalThis;
41
+ return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new AsyncLocalStorage());
42
+ }
43
+ var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
44
+ var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
35
45
  var ProfileLockTimeoutError = class extends Error {
36
- constructor(profile, timeoutMs) {
37
- 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}` : ""}`);
38
49
  this.code = "PROFILE_LOCK_TIMEOUT";
39
50
  this.name = "ProfileLockTimeoutError";
40
51
  }
41
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
+ };
42
60
  function tinycloudHomePath() {
43
61
  const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
44
62
  return join(home, ".tinycloud");
@@ -83,6 +101,9 @@ function profileLockPath(profile) {
83
101
  function profileLockMetadataPath(profile) {
84
102
  return join(profileLockPath(profile), "owner.json");
85
103
  }
104
+ function profileTurnLockPath(profile) {
105
+ return join(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
106
+ }
86
107
  async function readJson(filePath) {
87
108
  try {
88
109
  return JSON.parse(await readFile(filePath, "utf8"));
@@ -92,6 +113,9 @@ async function readJson(filePath) {
92
113
  }
93
114
  }
94
115
  async function writeJsonAtomic(filePath, value) {
116
+ await replaceJson(filePath, value, false);
117
+ }
118
+ async function replaceJson(filePath, value, durable) {
95
119
  const directory = dirname(filePath);
96
120
  const temporaryPath = join(
97
121
  directory,
@@ -101,13 +125,25 @@ async function writeJsonAtomic(filePath, value) {
101
125
  `;
102
126
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
103
127
  try {
104
- 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 });
105
130
  await rename(temporaryPath, filePath);
106
131
  } catch (error) {
107
132
  await rm(temporaryPath, { force: true }).catch(() => void 0);
108
133
  throw error;
109
134
  }
110
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
+ }
111
147
  async function readProfile(profile) {
112
148
  return readJson(profileConfigPath(profile));
113
149
  }
@@ -144,18 +180,38 @@ async function withProfileLock(profile, action, options = {}) {
144
180
  const lockPath = profileLockPath(normalizedProfile);
145
181
  const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
146
182
  if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
147
- const release = await acquireProfileLock(normalizedProfile, options);
148
- 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
+ };
149
190
  try {
150
191
  return await heldProfileLocks.run([...held, ownership], action);
151
192
  } finally {
152
193
  ownership.active = false;
153
- await release();
194
+ await acquired.release();
195
+ if (acquired.deletedWhileWaiting) await rmdir(profilePath(normalizedProfile)).catch(() => void 0);
154
196
  }
155
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
+ }
156
211
  async function upsertProfileRecord(profile, store, key, record, getKey, options = {}) {
157
212
  if (!key) throw new TypeError("A non-empty record key is required.");
158
213
  return withProfileLock(profile, async () => {
214
+ await refuseWriteToDeletedProfile(profile);
159
215
  const current = (await readProfileStore(profile, store)).records;
160
216
  const next = current.filter((candidate) => getKey(candidate) !== key);
161
217
  next.push(record);
@@ -172,6 +228,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
172
228
  );
173
229
  }
174
230
  async function updateProfileStoreWhileLocked(profile, store, update) {
231
+ await refuseWriteToDeletedProfile(profile);
175
232
  const current = await readProfileStore(profile, store);
176
233
  const next = await update(current.records);
177
234
  await writeJsonAtomic(profileStorePath(profile, store), next.records);
@@ -180,6 +237,7 @@ async function updateProfileStoreWhileLocked(profile, store, update) {
180
237
  }
181
238
  async function writeSession(profile, session, options = {}) {
182
239
  await withProfileLock(profile, async () => {
240
+ await refuseWriteToDeletedProfile(profile);
183
241
  await readStoreMetadata(profile, "session");
184
242
  await writeJsonAtomic(sessionPath(profile), session);
185
243
  await writeFormatOneMetadata(profile, "session");
@@ -195,32 +253,277 @@ async function writeFormatOneMetadata(profile, store) {
195
253
  await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
196
254
  }
197
255
  async function acquireProfileLock(profile, options) {
198
- const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
199
- 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
+ };
200
261
  const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
201
- const startedAt = Date.now();
202
262
  const lockPath = profileLockPath(profile);
263
+ const turnDirectory = profileTurnLockPath(profile);
203
264
  const directory = profilePath(profile);
204
265
  await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
266
+ await mkdir(dirname(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
205
267
  for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
206
268
  await chmod(path, PRIVATE_DIR_MODE).catch((error) => {
207
269
  if (path !== directory || !isErrno(error, "ENOENT")) throw error;
208
270
  });
209
271
  }
210
- while (true) {
211
- const token = randomUUID();
212
- if (await publishProfileLock(profile, lockPath, token)) {
213
- return async () => {
214
- await releaseProfileLock(profile, lockPath, token);
215
- };
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();
216
444
  }
217
- await signalTestLockContention(profile);
218
- if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
219
- const elapsedMs = Date.now() - startedAt;
220
- if (elapsedMs >= timeoutMs) {
221
- 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);
222
526
  }
223
- await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
224
527
  }
225
528
  }
226
529
  async function publishProfileLock(profile, lockPath, token) {
@@ -233,12 +536,18 @@ async function publishProfileLock(profile, lockPath, token) {
233
536
  return false;
234
537
  }
235
538
  await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
539
+ const ownerPath = join(lockPath, "owner.json");
236
540
  const staged = join(dirname(lockPath), `.lock-owner-${token}.tmp`);
237
- 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
+ `;
238
543
  try {
239
- await writeFile(staged, `${JSON.stringify(owner, null, 2)}
240
- `, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
241
- 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
+ }
242
551
  return true;
243
552
  } catch (error) {
244
553
  if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
@@ -248,6 +557,10 @@ async function publishProfileLock(profile, lockPath, token) {
248
557
  await rm(staged, { force: true }).catch(() => void 0);
249
558
  }
250
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
+ }
251
564
  async function releaseProfileLock(profile, lockPath, token) {
252
565
  const ownerPath = join(lockPath, "owner.json");
253
566
  const owner = await readJson(ownerPath).catch(() => null);
@@ -271,9 +584,10 @@ async function signalTestLockContention(profile) {
271
584
  `, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
272
585
  }
273
586
  async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
587
+ let aged;
274
588
  try {
275
589
  const { mtimeMs } = await stat(lockPath);
276
- if (Date.now() - mtimeMs < staleAfterMs) return false;
590
+ aged = Date.now() - mtimeMs >= staleAfterMs;
277
591
  } catch {
278
592
  return false;
279
593
  }
@@ -282,7 +596,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
282
596
  try {
283
597
  const entries = await readdir(lockPath);
284
598
  if (entries.includes("owner.json")) return false;
285
- 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
+ }
286
609
  for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
287
610
  await rm(join(lockPath, name), { force: true });
288
611
  }
@@ -293,10 +616,28 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
293
616
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
294
617
  return true;
295
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
+ }
296
636
  function recoveryFenced(since, staleAfterMs) {
297
637
  return performance.now() - since >= staleAfterMs / 2;
298
638
  }
299
639
  var ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
640
+ var RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
300
641
  var STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
301
642
  async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
302
643
  const directory = dirname(lockPath);
@@ -309,37 +650,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
309
650
  }
310
651
  async function recoverStaleLock(profile, lockPath, staleAfterMs) {
311
652
  const ownerPath = join(lockPath, "owner.json");
312
- const owner = await readJson(ownerPath).catch(() => null);
313
- if (owner === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
314
- if (!isStaleOwner(owner, staleAfterMs)) return false;
315
- 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;
316
656
  await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
317
- const claimPath = join(lockPath, `.stale-${randomUUID()}.json`);
657
+ const claimPath = join(lockPath, `.recover-${randomUUID()}.json`);
658
+ let moved = false;
318
659
  try {
319
660
  await link(ownerPath, claimPath);
320
- } catch {
321
- 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
+ }
322
669
  }
323
- const claimedAt = performance.now();
324
- await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
325
- const claimed = await readJson(claimPath).catch(() => null);
326
- 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);
327
672
  await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
328
- if (sameInstance && recoveryFenced(claimedAt, staleAfterMs)) {
329
- await rm(claimPath, { force: true });
330
- 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
+ }
331
679
  return false;
332
680
  }
333
- if (sameInstance) {
334
- await rm(ownerPath, { force: true });
335
- 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 });
336
686
  }
337
- await rm(claimPath, { force: true });
338
- await rmdir(lockPath).catch(() => void 0);
339
- if (!sameInstance) return false;
687
+ if (await unlink(claimPath).then(() => true, () => false)) await rmdir(lockPath).catch(() => void 0);
340
688
  await removeAgedOwnerFiles(lockPath, staleAfterMs);
341
689
  return true;
342
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
+ }
343
704
  async function waitForTestBarrier(environmentName, profile) {
344
705
  if (process.env.NODE_ENV !== "test") return;
345
706
  const barrierDirectory = process.env[environmentName];
@@ -366,6 +727,15 @@ function isStaleOwner(owner, staleAfterMs) {
366
727
  }
367
728
  return !isProcessAlive(owner.pid);
368
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
+ }
369
739
  function isProcessAlive(pid) {
370
740
  try {
371
741
  process.kill(pid, 0);
@@ -388,6 +758,7 @@ function sleep(milliseconds) {
388
758
  }
389
759
  export {
390
760
  DEFAULT_PROFILE,
761
+ ProfileDeletedError,
391
762
  ProfileLockTimeoutError,
392
763
  additionalDelegationsPath,
393
764
  authRequestsPath,
@@ -397,6 +768,7 @@ export {
397
768
  profilePath,
398
769
  profileStoreMetadataPath,
399
770
  profileStorePath,
771
+ profileTurnLockPath,
400
772
  profilesPath,
401
773
  readAdditionalDelegations,
402
774
  readAuthRequests,
@@ -405,6 +777,8 @@ export {
405
777
  readProfileStore,
406
778
  readSession,
407
779
  readStoreMetadata,
780
+ recordProfileDeletion,
781
+ refuseWriteToDeletedProfile,
408
782
  removeSession,
409
783
  sessionPath,
410
784
  tinycloudConfigPath,