@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.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
|
|
34
|
-
var
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
148
|
-
const ownership = {
|
|
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
|
|
199
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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,
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
313
|
-
if (
|
|
314
|
-
if (!
|
|
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, `.
|
|
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
|
-
|
|
324
|
-
await
|
|
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
|
|
329
|
-
|
|
330
|
-
|
|
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 (
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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,
|