@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/cli-runtime.js
CHANGED
|
@@ -7086,20 +7086,22 @@ import {
|
|
|
7086
7086
|
chmod,
|
|
7087
7087
|
link,
|
|
7088
7088
|
mkdir,
|
|
7089
|
+
open,
|
|
7089
7090
|
readFile,
|
|
7090
7091
|
readdir,
|
|
7091
7092
|
rename,
|
|
7092
7093
|
rmdir,
|
|
7093
7094
|
rm,
|
|
7094
7095
|
stat,
|
|
7096
|
+
unlink,
|
|
7095
7097
|
writeFile
|
|
7096
7098
|
} from "fs/promises";
|
|
7097
7099
|
import { homedir } from "os";
|
|
7098
7100
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
|
|
7099
7101
|
function processWideContext(key) {
|
|
7100
|
-
var
|
|
7102
|
+
var _a2;
|
|
7101
7103
|
const registry = globalThis;
|
|
7102
|
-
return registry[
|
|
7104
|
+
return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new AsyncLocalStorage());
|
|
7103
7105
|
}
|
|
7104
7106
|
function tinycloudHomePath() {
|
|
7105
7107
|
const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
|
|
@@ -7139,6 +7141,9 @@ function profileStoreMetadataPath(profile, store) {
|
|
|
7139
7141
|
function profileLockPath(profile) {
|
|
7140
7142
|
return join(profilePath(profile), ".lock");
|
|
7141
7143
|
}
|
|
7144
|
+
function profileTurnLockPath(profile) {
|
|
7145
|
+
return join(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
|
|
7146
|
+
}
|
|
7142
7147
|
async function readJson(filePath) {
|
|
7143
7148
|
try {
|
|
7144
7149
|
return JSON.parse(await readFile(filePath, "utf8"));
|
|
@@ -7148,6 +7153,9 @@ async function readJson(filePath) {
|
|
|
7148
7153
|
}
|
|
7149
7154
|
}
|
|
7150
7155
|
async function writeJsonAtomic(filePath, value) {
|
|
7156
|
+
await replaceJson(filePath, value, false);
|
|
7157
|
+
}
|
|
7158
|
+
async function replaceJson(filePath, value, durable) {
|
|
7151
7159
|
const directory = dirname(filePath);
|
|
7152
7160
|
const temporaryPath = join(
|
|
7153
7161
|
directory,
|
|
@@ -7157,13 +7165,25 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
7157
7165
|
`;
|
|
7158
7166
|
await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7159
7167
|
try {
|
|
7160
|
-
await
|
|
7168
|
+
if (durable) await writeFileDurably(temporaryPath, contents);
|
|
7169
|
+
else await writeFile(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
|
|
7161
7170
|
await rename(temporaryPath, filePath);
|
|
7162
7171
|
} catch (error) {
|
|
7163
7172
|
await rm(temporaryPath, { force: true }).catch(() => void 0);
|
|
7164
7173
|
throw error;
|
|
7165
7174
|
}
|
|
7166
7175
|
}
|
|
7176
|
+
async function writeFileDurably(path, contents) {
|
|
7177
|
+
const handle = await open(path, "wx", PRIVATE_FILE_MODE);
|
|
7178
|
+
try {
|
|
7179
|
+
if (contents) {
|
|
7180
|
+
await handle.writeFile(contents, "utf8");
|
|
7181
|
+
await handle.datasync();
|
|
7182
|
+
}
|
|
7183
|
+
} finally {
|
|
7184
|
+
await handle.close();
|
|
7185
|
+
}
|
|
7186
|
+
}
|
|
7167
7187
|
async function readProfile(profile) {
|
|
7168
7188
|
return readJson(profileConfigPath(profile));
|
|
7169
7189
|
}
|
|
@@ -7200,15 +7220,28 @@ async function withProfileLock(profile, action, options = {}) {
|
|
|
7200
7220
|
const lockPath = profileLockPath(normalizedProfile);
|
|
7201
7221
|
const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
|
|
7202
7222
|
if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
|
|
7203
|
-
const
|
|
7204
|
-
const ownership = {
|
|
7223
|
+
const acquired = await acquireProfileLock(normalizedProfile, options);
|
|
7224
|
+
const ownership = {
|
|
7225
|
+
lockPath,
|
|
7226
|
+
turnSlot: acquired.turnSlot,
|
|
7227
|
+
deletedWhileWaiting: acquired.deletedWhileWaiting,
|
|
7228
|
+
active: true
|
|
7229
|
+
};
|
|
7205
7230
|
try {
|
|
7206
7231
|
return await heldProfileLocks.run([...held, ownership], action);
|
|
7207
7232
|
} finally {
|
|
7208
7233
|
ownership.active = false;
|
|
7209
|
-
await release();
|
|
7234
|
+
await acquired.release();
|
|
7235
|
+
if (acquired.deletedWhileWaiting) await rmdir(profilePath(normalizedProfile)).catch(() => void 0);
|
|
7210
7236
|
}
|
|
7211
7237
|
}
|
|
7238
|
+
async function refuseWriteToDeletedProfile(profile) {
|
|
7239
|
+
const lockPath = profileLockPath(profile);
|
|
7240
|
+
const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
|
|
7241
|
+
if (!ownership?.deletedWhileWaiting) return;
|
|
7242
|
+
if (await exists(profileConfigPath(profile))) return;
|
|
7243
|
+
throw new ProfileDeletedError(profile);
|
|
7244
|
+
}
|
|
7212
7245
|
async function updateProfileStore(profile, store, update, options = {}) {
|
|
7213
7246
|
return withProfileLock(
|
|
7214
7247
|
profile,
|
|
@@ -7217,6 +7250,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
|
|
|
7217
7250
|
);
|
|
7218
7251
|
}
|
|
7219
7252
|
async function updateProfileStoreWhileLocked(profile, store, update) {
|
|
7253
|
+
await refuseWriteToDeletedProfile(profile);
|
|
7220
7254
|
const current = await readProfileStore(profile, store);
|
|
7221
7255
|
const next = await update(current.records);
|
|
7222
7256
|
await writeJsonAtomic(profileStorePath(profile, store), next.records);
|
|
@@ -7227,32 +7261,271 @@ async function writeFormatOneMetadata(profile, store) {
|
|
|
7227
7261
|
await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
|
|
7228
7262
|
}
|
|
7229
7263
|
async function acquireProfileLock(profile, options) {
|
|
7230
|
-
const
|
|
7231
|
-
|
|
7264
|
+
const deadline = {
|
|
7265
|
+
startedAt: Date.now(),
|
|
7266
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
|
|
7267
|
+
retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
|
|
7268
|
+
};
|
|
7232
7269
|
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
|
|
7233
|
-
const startedAt = Date.now();
|
|
7234
7270
|
const lockPath = profileLockPath(profile);
|
|
7271
|
+
const turnDirectory = profileTurnLockPath(profile);
|
|
7235
7272
|
const directory = profilePath(profile);
|
|
7236
7273
|
await mkdir(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7274
|
+
await mkdir(dirname(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7237
7275
|
for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
|
|
7238
7276
|
await chmod(path, PRIVATE_DIR_MODE).catch((error) => {
|
|
7239
7277
|
if (path !== directory || !isErrno(error, "ENOENT")) throw error;
|
|
7240
7278
|
});
|
|
7241
7279
|
}
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7247
|
-
|
|
7280
|
+
const { turn, firstWaitedTurn } = await acquireTurn(profile, turnDirectory, deadline);
|
|
7281
|
+
try {
|
|
7282
|
+
const deletion = await readJson(join(turnDirectory, "deleted.json")).catch(() => null);
|
|
7283
|
+
const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
|
|
7284
|
+
while (true) {
|
|
7285
|
+
const token = randomUUID();
|
|
7286
|
+
if (await publishProfileLock(profile, lockPath, token)) {
|
|
7287
|
+
return {
|
|
7288
|
+
deletedWhileWaiting,
|
|
7289
|
+
turnSlot: turn.slot,
|
|
7290
|
+
release: async () => {
|
|
7291
|
+
try {
|
|
7292
|
+
await releaseProfileLock(profile, lockPath, token);
|
|
7293
|
+
} finally {
|
|
7294
|
+
await releaseTurn(profile, turn);
|
|
7295
|
+
}
|
|
7296
|
+
}
|
|
7297
|
+
};
|
|
7298
|
+
}
|
|
7299
|
+
await signalTestLockContention(profile);
|
|
7300
|
+
if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
|
|
7301
|
+
await waitOrTimeOut(profile, deadline);
|
|
7302
|
+
}
|
|
7303
|
+
} catch (error) {
|
|
7304
|
+
await releaseTurn(profile, turn);
|
|
7305
|
+
throw error;
|
|
7306
|
+
}
|
|
7307
|
+
}
|
|
7308
|
+
function checkDeadline(profile, deadline, detail) {
|
|
7309
|
+
if (Date.now() - deadline.startedAt >= deadline.timeoutMs) {
|
|
7310
|
+
throw new ProfileLockTimeoutError(profile, deadline.timeoutMs, detail);
|
|
7311
|
+
}
|
|
7312
|
+
}
|
|
7313
|
+
async function waitOrTimeOut(profile, deadline, detail) {
|
|
7314
|
+
checkDeadline(profile, deadline, detail);
|
|
7315
|
+
await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
|
|
7316
|
+
}
|
|
7317
|
+
async function acquireTurn(profile, directory, deadline) {
|
|
7318
|
+
let firstWaitedTurn;
|
|
7319
|
+
let problem;
|
|
7320
|
+
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.`;
|
|
7321
|
+
for (let first = true; ; first = false) {
|
|
7322
|
+
if (!first) checkDeadline(profile, deadline, problem);
|
|
7323
|
+
problem = void 0;
|
|
7324
|
+
const latest = await readLatestTurn(directory);
|
|
7325
|
+
if (latest === "missing") {
|
|
7326
|
+
await createTurnDirectory(directory);
|
|
7327
|
+
continue;
|
|
7328
|
+
}
|
|
7329
|
+
if (latest === "empty") {
|
|
7330
|
+
problem = damaged("has no turns");
|
|
7331
|
+
await waitOrTimeOut(profile, deadline, problem);
|
|
7332
|
+
continue;
|
|
7333
|
+
}
|
|
7334
|
+
if (latest === null) continue;
|
|
7335
|
+
if (typeof latest === "number") {
|
|
7336
|
+
problem = damaged(`has an unreadable turn ${latest}`);
|
|
7337
|
+
await waitOrTimeOut(profile, deadline, problem);
|
|
7338
|
+
continue;
|
|
7339
|
+
}
|
|
7340
|
+
firstWaitedTurn ?? (firstWaitedTurn = latest.done ? latest.slot + 1 : latest.slot);
|
|
7341
|
+
if (latest.held && latest.done) {
|
|
7342
|
+
const turn = await takeTurn(profile, directory, latest);
|
|
7343
|
+
if (turn !== null) return { turn, firstWaitedTurn };
|
|
7344
|
+
await waitOrTimeOut(profile, deadline);
|
|
7345
|
+
} else if (latest.done) {
|
|
7346
|
+
problem = damaged(`ends with turn ${latest.slot}, which was never granted`);
|
|
7347
|
+
await waitOrTimeOut(profile, deadline, problem);
|
|
7348
|
+
} else if (latest.pid === process.pid ? relinquishedTurns.has(latest.token) : !isProcessAlive(latest.pid)) {
|
|
7349
|
+
await waitForTestBarrier(TEST_LOCK_TURN_SETTLE_BARRIER_DIR, profile);
|
|
7350
|
+
await settleAbandonedTurn(directory, latest);
|
|
7351
|
+
relinquishedTurns.delete(latest.token);
|
|
7352
|
+
} else {
|
|
7353
|
+
await signalTestLockContention(profile);
|
|
7354
|
+
await waitOrTimeOut(profile, deadline);
|
|
7355
|
+
}
|
|
7356
|
+
}
|
|
7357
|
+
}
|
|
7358
|
+
async function readLatestTurn(directory) {
|
|
7359
|
+
let names;
|
|
7360
|
+
try {
|
|
7361
|
+
names = await readdir(directory);
|
|
7362
|
+
} catch (error) {
|
|
7363
|
+
if (isErrno(error, "ENOENT")) return "missing";
|
|
7364
|
+
throw error;
|
|
7365
|
+
}
|
|
7366
|
+
const slots = names.filter((name) => TURN.test(name)).map(Number);
|
|
7367
|
+
if (slots.length === 0) return "empty";
|
|
7368
|
+
const slot = Math.max(...slots);
|
|
7369
|
+
const turn = await readTurn(directory, slot);
|
|
7370
|
+
return turn === "damaged" ? slot : turn;
|
|
7371
|
+
}
|
|
7372
|
+
async function readTurn(directory, slot) {
|
|
7373
|
+
const path = join(directory, String(slot));
|
|
7374
|
+
let text;
|
|
7375
|
+
try {
|
|
7376
|
+
text = await readFile(join(path, "owner.json"), "utf8");
|
|
7377
|
+
} catch (error) {
|
|
7378
|
+
if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTDIR")) throw error;
|
|
7379
|
+
return await exists(path) ? "damaged" : null;
|
|
7380
|
+
}
|
|
7381
|
+
let owner;
|
|
7382
|
+
try {
|
|
7383
|
+
owner = JSON.parse(text);
|
|
7384
|
+
} catch {
|
|
7385
|
+
return "damaged";
|
|
7386
|
+
}
|
|
7387
|
+
if (owner === null || typeof owner !== "object" || typeof owner.token !== "string" || typeof owner.pid !== "number") {
|
|
7388
|
+
return "damaged";
|
|
7389
|
+
}
|
|
7390
|
+
const done = await exists(join(path, `${owner.token}.done`));
|
|
7391
|
+
const held = await exists(join(path, `${owner.token}.held`));
|
|
7392
|
+
return {
|
|
7393
|
+
slot,
|
|
7394
|
+
token: owner.token,
|
|
7395
|
+
pid: owner.pid,
|
|
7396
|
+
after: typeof owner.after === "string" ? owner.after : null,
|
|
7397
|
+
held,
|
|
7398
|
+
done
|
|
7399
|
+
};
|
|
7400
|
+
}
|
|
7401
|
+
async function isFinishedTurn(directory, slot, token) {
|
|
7402
|
+
const turn = await readTurn(directory, slot);
|
|
7403
|
+
if (turn === "damaged") throw new Error(`Turn ${slot} of the turn lock ${directory} is unreadable.`);
|
|
7404
|
+
return turn !== null && turn.token === token && turn.held && turn.done;
|
|
7405
|
+
}
|
|
7406
|
+
async function takeTurn(profile, directory, latest) {
|
|
7407
|
+
const token = randomUUID();
|
|
7408
|
+
const slot = latest.slot + 1;
|
|
7409
|
+
const path = join(directory, String(slot));
|
|
7410
|
+
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token };
|
|
7411
|
+
let published = false;
|
|
7412
|
+
try {
|
|
7413
|
+
published = await publishDirectory(
|
|
7414
|
+
join(directory, `.stage-${process.pid}-${token}`),
|
|
7415
|
+
path,
|
|
7416
|
+
{ "owner.json": `${JSON.stringify(owner, null, 2)}
|
|
7417
|
+
` },
|
|
7418
|
+
() => waitForTestBarrier(TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, profile)
|
|
7419
|
+
);
|
|
7420
|
+
if (published && await isFinishedTurn(directory, latest.slot, latest.token) && await markTurn(path, token, "held")) {
|
|
7421
|
+
return { directory, slot, token };
|
|
7422
|
+
}
|
|
7423
|
+
if (published) await markTurn(path, token, "done");
|
|
7424
|
+
return null;
|
|
7425
|
+
} catch (error) {
|
|
7426
|
+
if (published) settleOwnTurn(directory, slot, token);
|
|
7427
|
+
throw error;
|
|
7428
|
+
}
|
|
7429
|
+
}
|
|
7430
|
+
async function settleAbandonedTurn(directory, abandoned) {
|
|
7431
|
+
const path = join(directory, String(abandoned.slot));
|
|
7432
|
+
if (!abandoned.held && abandoned.after !== null && await isFinishedTurn(directory, abandoned.slot - 1, abandoned.after)) {
|
|
7433
|
+
await markTurn(path, abandoned.token, "held");
|
|
7434
|
+
}
|
|
7435
|
+
await markTurn(path, abandoned.token, "done");
|
|
7436
|
+
}
|
|
7437
|
+
function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
|
|
7438
|
+
relinquishedTurns.add(token);
|
|
7439
|
+
void (async () => {
|
|
7440
|
+
try {
|
|
7441
|
+
const turn = await readTurn(directory, slot);
|
|
7442
|
+
if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
|
|
7443
|
+
relinquishedTurns.delete(token);
|
|
7444
|
+
} catch {
|
|
7445
|
+
if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
|
|
7446
|
+
}
|
|
7447
|
+
})();
|
|
7448
|
+
}
|
|
7449
|
+
async function markTurn(path, token, kind) {
|
|
7450
|
+
for (let attempt = 0; ; attempt++) {
|
|
7451
|
+
try {
|
|
7452
|
+
await writeFile(join(path, `${token}.${kind}`), "", { mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
7453
|
+
return true;
|
|
7454
|
+
} catch (error) {
|
|
7455
|
+
if (isErrno(error, "EEXIST")) return true;
|
|
7456
|
+
if (isErrno(error, "ENOENT")) return false;
|
|
7457
|
+
if (attempt >= MARKER_RETRY_DELAYS_MS.length) throw error;
|
|
7458
|
+
await sleep(MARKER_RETRY_DELAYS_MS[attempt]);
|
|
7459
|
+
}
|
|
7460
|
+
}
|
|
7461
|
+
}
|
|
7462
|
+
async function createTurnDirectory(directory) {
|
|
7463
|
+
const token = randomUUID();
|
|
7464
|
+
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null };
|
|
7465
|
+
await publishDirectory(join(dirname(directory), `.stage-${basename(directory)}-${process.pid}-${token}`), directory, {
|
|
7466
|
+
"0/owner.json": `${JSON.stringify(owner, null, 2)}
|
|
7467
|
+
`,
|
|
7468
|
+
[`0/${token}.held`]: "",
|
|
7469
|
+
[`0/${token}.done`]: ""
|
|
7470
|
+
});
|
|
7471
|
+
}
|
|
7472
|
+
async function publishDirectory(stage, target, files, beforeRename) {
|
|
7473
|
+
try {
|
|
7474
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
7475
|
+
await mkdir(dirname(join(stage, name)), { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7476
|
+
await writeFileDurably(join(stage, name), contents);
|
|
7477
|
+
}
|
|
7478
|
+
await beforeRename?.();
|
|
7479
|
+
await rename(stage, target);
|
|
7480
|
+
return true;
|
|
7481
|
+
} catch (error) {
|
|
7482
|
+
if (["ENOTEMPTY", "EEXIST", "ENOENT"].some((code) => isErrno(error, code)) || await exists(target)) return false;
|
|
7483
|
+
throw error;
|
|
7484
|
+
} finally {
|
|
7485
|
+
await rm(stage, { recursive: true, force: true }).catch(() => void 0);
|
|
7486
|
+
}
|
|
7487
|
+
}
|
|
7488
|
+
async function releaseTurn(profile, turn) {
|
|
7489
|
+
try {
|
|
7490
|
+
await markTurn(join(turn.directory, String(turn.slot)), turn.token, "done");
|
|
7491
|
+
} catch {
|
|
7492
|
+
settleOwnTurn(turn.directory, turn.slot, turn.token);
|
|
7493
|
+
return;
|
|
7494
|
+
}
|
|
7495
|
+
await collectTurns(profile, turn).catch(() => void 0);
|
|
7496
|
+
}
|
|
7497
|
+
function isAbandonedTurnEntry(name) {
|
|
7498
|
+
if (name.startsWith(".trash-")) return true;
|
|
7499
|
+
const writer = /^\.(?:stage|deleted\.json)[-.]([0-9]+)[-.]/.exec(name)?.[1];
|
|
7500
|
+
return writer !== void 0 && !isProcessAlive(Number(writer));
|
|
7501
|
+
}
|
|
7502
|
+
function isAbandonedTurnDirectoryStage(name) {
|
|
7503
|
+
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];
|
|
7504
|
+
return writer !== void 0 && !isProcessAlive(Number(writer));
|
|
7505
|
+
}
|
|
7506
|
+
async function collectTurns(profile, turn) {
|
|
7507
|
+
for (const [directory, abandoned] of [
|
|
7508
|
+
[turn.directory, isAbandonedTurnEntry],
|
|
7509
|
+
[dirname(turn.directory), isAbandonedTurnDirectoryStage]
|
|
7510
|
+
]) {
|
|
7511
|
+
for (const name of (await readdir(directory)).filter(abandoned)) {
|
|
7512
|
+
await rm(join(directory, name), { recursive: true, force: true }).catch(() => void 0);
|
|
7513
|
+
}
|
|
7514
|
+
}
|
|
7515
|
+
const older = (await readdir(turn.directory)).filter((name) => TURN.test(name)).map(Number).filter((slot) => slot < turn.slot).sort((left, right) => left - right);
|
|
7516
|
+
for (const slot of older) {
|
|
7517
|
+
const candidate = await readTurn(turn.directory, slot);
|
|
7518
|
+
if (candidate === null || candidate === "damaged" || !candidate.done) continue;
|
|
7519
|
+
if (candidate.held) {
|
|
7520
|
+
if (await exists(join(turn.directory, String(slot - 1)))) continue;
|
|
7521
|
+
const next = await readTurn(turn.directory, slot + 1);
|
|
7522
|
+
if (next === null || next === "damaged" || !next.held || !next.done) continue;
|
|
7248
7523
|
}
|
|
7249
|
-
await
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
throw new ProfileLockTimeoutError(profile, timeoutMs);
|
|
7524
|
+
await waitForTestBarrier(TEST_LOCK_TURN_COLLECT_BARRIER_DIR, profile);
|
|
7525
|
+
const trash = join(turn.directory, `.trash-${randomUUID()}`);
|
|
7526
|
+
if (await rename(join(turn.directory, String(slot)), trash).then(() => true, () => false)) {
|
|
7527
|
+
await rm(trash, { recursive: true, force: true }).catch(() => void 0);
|
|
7254
7528
|
}
|
|
7255
|
-
await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
|
|
7256
7529
|
}
|
|
7257
7530
|
}
|
|
7258
7531
|
async function publishProfileLock(profile, lockPath, token) {
|
|
@@ -7265,12 +7538,18 @@ async function publishProfileLock(profile, lockPath, token) {
|
|
|
7265
7538
|
return false;
|
|
7266
7539
|
}
|
|
7267
7540
|
await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
|
|
7541
|
+
const ownerPath = join(lockPath, "owner.json");
|
|
7268
7542
|
const staged = join(dirname(lockPath), `.lock-owner-${token}.tmp`);
|
|
7269
|
-
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }
|
|
7543
|
+
const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }, null, 2)}
|
|
7544
|
+
`;
|
|
7270
7545
|
try {
|
|
7271
|
-
await writeFile(staged,
|
|
7272
|
-
|
|
7273
|
-
|
|
7546
|
+
await writeFile(staged, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
7547
|
+
try {
|
|
7548
|
+
await link(staged, ownerPath);
|
|
7549
|
+
} catch (error) {
|
|
7550
|
+
if (!lacksHardLinks(error)) throw error;
|
|
7551
|
+
await writeFile(ownerPath, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
7552
|
+
}
|
|
7274
7553
|
return true;
|
|
7275
7554
|
} catch (error) {
|
|
7276
7555
|
if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
|
|
@@ -7280,6 +7559,9 @@ async function publishProfileLock(profile, lockPath, token) {
|
|
|
7280
7559
|
await rm(staged, { force: true }).catch(() => void 0);
|
|
7281
7560
|
}
|
|
7282
7561
|
}
|
|
7562
|
+
function lacksHardLinks(error) {
|
|
7563
|
+
return typeof error === "object" && error !== null && "code" in error && NO_HARD_LINK_ERRORS.has(String(error.code));
|
|
7564
|
+
}
|
|
7283
7565
|
async function releaseProfileLock(profile, lockPath, token) {
|
|
7284
7566
|
const ownerPath = join(lockPath, "owner.json");
|
|
7285
7567
|
const owner = await readJson(ownerPath).catch(() => null);
|
|
@@ -7303,9 +7585,10 @@ async function signalTestLockContention(profile) {
|
|
|
7303
7585
|
`, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
|
|
7304
7586
|
}
|
|
7305
7587
|
async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
7588
|
+
let aged;
|
|
7306
7589
|
try {
|
|
7307
7590
|
const { mtimeMs } = await stat(lockPath);
|
|
7308
|
-
|
|
7591
|
+
aged = Date.now() - mtimeMs >= staleAfterMs;
|
|
7309
7592
|
} catch {
|
|
7310
7593
|
return false;
|
|
7311
7594
|
}
|
|
@@ -7314,7 +7597,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
|
7314
7597
|
try {
|
|
7315
7598
|
const entries = await readdir(lockPath);
|
|
7316
7599
|
if (entries.includes("owner.json")) return false;
|
|
7317
|
-
|
|
7600
|
+
const onlyOrphanedClaims = entries.length > 0 && entries.every((entry) => RECOVERY_CLAIM.test(entry));
|
|
7601
|
+
if (!onlyOrphanedClaims && (!aged || recoveryFenced(checkedAt, staleAfterMs))) return false;
|
|
7602
|
+
for (const name of entries.filter((entry) => RECOVERY_CLAIM.test(entry))) {
|
|
7603
|
+
const claimPath = join(lockPath, name);
|
|
7604
|
+
if (await hasLiveRecoveryOwner(claimPath)) {
|
|
7605
|
+
if (!await exists(join(lockPath, "owner.json"))) await rename(claimPath, join(lockPath, "owner.json"));
|
|
7606
|
+
return false;
|
|
7607
|
+
}
|
|
7608
|
+
await rm(claimPath, { force: true });
|
|
7609
|
+
}
|
|
7318
7610
|
for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
|
|
7319
7611
|
await rm(join(lockPath, name), { force: true });
|
|
7320
7612
|
}
|
|
@@ -7325,6 +7617,23 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
|
7325
7617
|
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
7326
7618
|
return true;
|
|
7327
7619
|
}
|
|
7620
|
+
async function hasLiveRecoveryOwner(claimPath) {
|
|
7621
|
+
let contents;
|
|
7622
|
+
try {
|
|
7623
|
+
contents = await readFile(claimPath, "utf8");
|
|
7624
|
+
} catch (error) {
|
|
7625
|
+
if (isErrno(error, "ENOENT")) return false;
|
|
7626
|
+
throw error;
|
|
7627
|
+
}
|
|
7628
|
+
let owner;
|
|
7629
|
+
try {
|
|
7630
|
+
owner = JSON.parse(contents);
|
|
7631
|
+
} catch {
|
|
7632
|
+
return false;
|
|
7633
|
+
}
|
|
7634
|
+
const pid = typeof owner === "object" && owner !== null && "pid" in owner ? owner.pid : null;
|
|
7635
|
+
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && isProcessAlive(pid);
|
|
7636
|
+
}
|
|
7328
7637
|
function recoveryFenced(since, staleAfterMs) {
|
|
7329
7638
|
return performance.now() - since >= staleAfterMs / 2;
|
|
7330
7639
|
}
|
|
@@ -7339,37 +7648,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
|
|
|
7339
7648
|
}
|
|
7340
7649
|
async function recoverStaleLock(profile, lockPath, staleAfterMs) {
|
|
7341
7650
|
const ownerPath = join(lockPath, "owner.json");
|
|
7342
|
-
const
|
|
7343
|
-
if (
|
|
7344
|
-
if (!
|
|
7345
|
-
const observedToken = typeof owner?.token === "string" && owner.token.length > 0 ? owner.token : "legacy";
|
|
7651
|
+
const observed = await readFile(ownerPath, "utf8").catch(() => null);
|
|
7652
|
+
if (observed === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
|
|
7653
|
+
if (!await isAbandonedOwner(observed, ownerPath, staleAfterMs)) return false;
|
|
7346
7654
|
await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
|
|
7347
|
-
const claimPath = join(lockPath, `.
|
|
7655
|
+
const claimPath = join(lockPath, `.recover-${randomUUID()}.json`);
|
|
7656
|
+
let moved = false;
|
|
7348
7657
|
try {
|
|
7349
7658
|
await link(ownerPath, claimPath);
|
|
7350
|
-
} catch {
|
|
7351
|
-
return false;
|
|
7659
|
+
} catch (error) {
|
|
7660
|
+
if (!lacksHardLinks(error)) return false;
|
|
7661
|
+
try {
|
|
7662
|
+
await rename(ownerPath, claimPath);
|
|
7663
|
+
moved = true;
|
|
7664
|
+
} catch {
|
|
7665
|
+
return false;
|
|
7666
|
+
}
|
|
7352
7667
|
}
|
|
7353
|
-
|
|
7354
|
-
await
|
|
7355
|
-
const claimed = await readJson(claimPath).catch(() => null);
|
|
7356
|
-
const sameInstance = claimed !== null && (observedToken === "legacy" ? claimed.token === void 0 : claimed.token === observedToken);
|
|
7668
|
+
if (moved) await waitForTestBarrier(TEST_LOCK_MOVED_BARRIER_DIR, profile);
|
|
7669
|
+
const sameInstance = await readFile(claimPath, "utf8").then((claimed) => claimed === observed, () => false);
|
|
7357
7670
|
await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
|
|
7358
|
-
if (sameInstance
|
|
7359
|
-
|
|
7360
|
-
|
|
7671
|
+
if (!sameInstance) {
|
|
7672
|
+
if (moved) {
|
|
7673
|
+
await rename(claimPath, ownerPath).catch(() => void 0);
|
|
7674
|
+
} else {
|
|
7675
|
+
await unlink(claimPath).catch(() => void 0);
|
|
7676
|
+
}
|
|
7361
7677
|
return false;
|
|
7362
7678
|
}
|
|
7363
|
-
if (
|
|
7364
|
-
|
|
7365
|
-
|
|
7679
|
+
if (!moved) await rm(ownerPath, { force: true });
|
|
7680
|
+
await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
|
|
7681
|
+
await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
|
|
7682
|
+
for (const name of (await readdir(lockPath).catch(() => [])).filter((entry) => RECOVERY_CLAIM.test(entry))) {
|
|
7683
|
+
if (join(lockPath, name) !== claimPath) await rm(join(lockPath, name), { force: true });
|
|
7366
7684
|
}
|
|
7367
|
-
await
|
|
7368
|
-
await rmdir(lockPath).catch(() => void 0);
|
|
7369
|
-
if (!sameInstance) return false;
|
|
7685
|
+
if (await unlink(claimPath).then(() => true, () => false)) await rmdir(lockPath).catch(() => void 0);
|
|
7370
7686
|
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
7371
7687
|
return true;
|
|
7372
7688
|
}
|
|
7689
|
+
async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
|
|
7690
|
+
let owner;
|
|
7691
|
+
try {
|
|
7692
|
+
owner = JSON.parse(observed);
|
|
7693
|
+
} catch {
|
|
7694
|
+
owner = null;
|
|
7695
|
+
}
|
|
7696
|
+
if (typeof owner === "object" && owner !== null && "pid" in owner) {
|
|
7697
|
+
return isStaleOwner(owner, staleAfterMs);
|
|
7698
|
+
}
|
|
7699
|
+
const modifiedAt = await stat(ownerPath).then(({ mtimeMs }) => mtimeMs, () => Number.NaN);
|
|
7700
|
+
return Date.now() - modifiedAt >= staleAfterMs;
|
|
7701
|
+
}
|
|
7373
7702
|
async function waitForTestBarrier(environmentName, profile) {
|
|
7374
7703
|
if (process.env.NODE_ENV !== "test") return;
|
|
7375
7704
|
const barrierDirectory = process.env[environmentName];
|
|
@@ -7396,6 +7725,15 @@ function isStaleOwner(owner, staleAfterMs) {
|
|
|
7396
7725
|
}
|
|
7397
7726
|
return !isProcessAlive(owner.pid);
|
|
7398
7727
|
}
|
|
7728
|
+
async function exists(path) {
|
|
7729
|
+
try {
|
|
7730
|
+
await stat(path);
|
|
7731
|
+
return true;
|
|
7732
|
+
} catch (error) {
|
|
7733
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) return false;
|
|
7734
|
+
throw error;
|
|
7735
|
+
}
|
|
7736
|
+
}
|
|
7399
7737
|
function isProcessAlive(pid) {
|
|
7400
7738
|
try {
|
|
7401
7739
|
process.kill(pid, 0);
|
|
@@ -7416,7 +7754,7 @@ function isErrno(error, code) {
|
|
|
7416
7754
|
function sleep(milliseconds) {
|
|
7417
7755
|
return new Promise((resolve2) => setTimeout(resolve2, milliseconds));
|
|
7418
7756
|
}
|
|
7419
|
-
var DEFAULT_PROFILE, DEFAULT_LOCK_TIMEOUT_MS, DEFAULT_LOCK_RETRY_MS, DEFAULT_STALE_LOCK_MS, TEST_LOCK_CONTENTION_SIGNAL_PATH, PRIVATE_FILE_MODE, PRIVATE_DIR_MODE, TEST_LOCK_RECOVERY_BARRIER_DIR, TEST_LOCK_OWNERLESS_BARRIER_DIR, TEST_LOCK_PUBLISH_BARRIER_DIR, TEST_LOCK_RELEASE_BARRIER_DIR, TEST_LOCK_CLAIM_BARRIER_DIR, TEST_LOCK_CLAIMED_BARRIER_DIR,
|
|
7757
|
+
var DEFAULT_PROFILE, DEFAULT_LOCK_TIMEOUT_MS, DEFAULT_LOCK_RETRY_MS, DEFAULT_STALE_LOCK_MS, TEST_LOCK_CONTENTION_SIGNAL_PATH, PRIVATE_FILE_MODE, PRIVATE_DIR_MODE, TEST_LOCK_RECOVERY_BARRIER_DIR, TEST_LOCK_OWNERLESS_BARRIER_DIR, TEST_LOCK_PUBLISH_BARRIER_DIR, TEST_LOCK_RELEASE_BARRIER_DIR, TEST_LOCK_CLAIM_BARRIER_DIR, TEST_LOCK_CLAIMED_BARRIER_DIR, TEST_LOCK_VERIFIED_BARRIER_DIR, TEST_LOCK_MOVED_BARRIER_DIR, TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, TEST_LOCK_TURN_SETTLE_BARRIER_DIR, TEST_LOCK_TURN_COLLECT_BARRIER_DIR, invocationStateRoot, heldProfileLocks, ProfileLockTimeoutError, ProfileDeletedError, TURN, _a, relinquishedTurns, MARKER_RETRY_DELAYS_MS, SETTLE_RETRY_MS, SETTLE_RETRIES, NO_HARD_LINK_ERRORS, ABANDONED_CLAIM, RECOVERY_CLAIM, STAGED_OWNER;
|
|
7420
7758
|
var init_state = __esm({
|
|
7421
7759
|
"src/state.ts"() {
|
|
7422
7760
|
"use strict";
|
|
@@ -7433,18 +7771,36 @@ var init_state = __esm({
|
|
|
7433
7771
|
TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
|
|
7434
7772
|
TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
|
|
7435
7773
|
TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
|
|
7436
|
-
TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
|
|
7437
7774
|
TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
|
|
7775
|
+
TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
|
|
7776
|
+
TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
|
|
7777
|
+
TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
|
|
7778
|
+
TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
|
|
7438
7779
|
invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
|
|
7439
|
-
heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.
|
|
7780
|
+
heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
|
|
7440
7781
|
ProfileLockTimeoutError = class extends Error {
|
|
7441
|
-
|
|
7442
|
-
|
|
7782
|
+
/** `detail` names the cause when it is not simply another process holding the lock. */
|
|
7783
|
+
constructor(profile, timeoutMs, detail) {
|
|
7784
|
+
super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.${detail ? ` ${detail}` : ""}`);
|
|
7443
7785
|
this.code = "PROFILE_LOCK_TIMEOUT";
|
|
7444
7786
|
this.name = "ProfileLockTimeoutError";
|
|
7445
7787
|
}
|
|
7446
7788
|
};
|
|
7789
|
+
ProfileDeletedError = class extends Error {
|
|
7790
|
+
constructor(profile) {
|
|
7791
|
+
super(`Profile "${profile}" was deleted while this change waited for its lock, so the change was not written.`);
|
|
7792
|
+
this.code = "PROFILE_NOT_FOUND";
|
|
7793
|
+
this.name = "ProfileDeletedError";
|
|
7794
|
+
}
|
|
7795
|
+
};
|
|
7796
|
+
TURN = /^(?:0|[1-9][0-9]*)$/;
|
|
7797
|
+
relinquishedTurns = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Set());
|
|
7798
|
+
MARKER_RETRY_DELAYS_MS = [10, 50, 250];
|
|
7799
|
+
SETTLE_RETRY_MS = 1e3;
|
|
7800
|
+
SETTLE_RETRIES = 300;
|
|
7801
|
+
NO_HARD_LINK_ERRORS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EISDIR", "EMLINK"]);
|
|
7447
7802
|
ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
|
|
7803
|
+
RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
|
|
7448
7804
|
STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
|
|
7449
7805
|
}
|
|
7450
7806
|
});
|