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