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