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