@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.cjs
CHANGED
|
@@ -7083,9 +7083,9 @@ var init_authority = __esm({
|
|
|
7083
7083
|
|
|
7084
7084
|
// src/state.ts
|
|
7085
7085
|
function processWideContext(key) {
|
|
7086
|
-
var
|
|
7086
|
+
var _a2;
|
|
7087
7087
|
const registry = globalThis;
|
|
7088
|
-
return registry[
|
|
7088
|
+
return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new import_node_async_hooks.AsyncLocalStorage());
|
|
7089
7089
|
}
|
|
7090
7090
|
function tinycloudHomePath() {
|
|
7091
7091
|
const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
|
|
@@ -7125,6 +7125,9 @@ function profileStoreMetadataPath(profile, store) {
|
|
|
7125
7125
|
function profileLockPath(profile) {
|
|
7126
7126
|
return (0, import_node_path.join)(profilePath(profile), ".lock");
|
|
7127
7127
|
}
|
|
7128
|
+
function profileTurnLockPath(profile) {
|
|
7129
|
+
return (0, import_node_path.join)(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
|
|
7130
|
+
}
|
|
7128
7131
|
async function readJson(filePath) {
|
|
7129
7132
|
try {
|
|
7130
7133
|
return JSON.parse(await (0, import_promises.readFile)(filePath, "utf8"));
|
|
@@ -7134,6 +7137,9 @@ async function readJson(filePath) {
|
|
|
7134
7137
|
}
|
|
7135
7138
|
}
|
|
7136
7139
|
async function writeJsonAtomic(filePath, value) {
|
|
7140
|
+
await replaceJson(filePath, value, false);
|
|
7141
|
+
}
|
|
7142
|
+
async function replaceJson(filePath, value, durable) {
|
|
7137
7143
|
const directory = (0, import_node_path.dirname)(filePath);
|
|
7138
7144
|
const temporaryPath = (0, import_node_path.join)(
|
|
7139
7145
|
directory,
|
|
@@ -7143,13 +7149,25 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
7143
7149
|
`;
|
|
7144
7150
|
await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7145
7151
|
try {
|
|
7146
|
-
|
|
7152
|
+
if (durable) await writeFileDurably(temporaryPath, contents);
|
|
7153
|
+
else await (0, import_promises.writeFile)(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
|
|
7147
7154
|
await (0, import_promises.rename)(temporaryPath, filePath);
|
|
7148
7155
|
} catch (error) {
|
|
7149
7156
|
await (0, import_promises.rm)(temporaryPath, { force: true }).catch(() => void 0);
|
|
7150
7157
|
throw error;
|
|
7151
7158
|
}
|
|
7152
7159
|
}
|
|
7160
|
+
async function writeFileDurably(path, contents) {
|
|
7161
|
+
const handle = await (0, import_promises.open)(path, "wx", PRIVATE_FILE_MODE);
|
|
7162
|
+
try {
|
|
7163
|
+
if (contents) {
|
|
7164
|
+
await handle.writeFile(contents, "utf8");
|
|
7165
|
+
await handle.datasync();
|
|
7166
|
+
}
|
|
7167
|
+
} finally {
|
|
7168
|
+
await handle.close();
|
|
7169
|
+
}
|
|
7170
|
+
}
|
|
7153
7171
|
async function readProfile(profile) {
|
|
7154
7172
|
return readJson(profileConfigPath(profile));
|
|
7155
7173
|
}
|
|
@@ -7186,15 +7204,28 @@ async function withProfileLock(profile, action, options = {}) {
|
|
|
7186
7204
|
const lockPath = profileLockPath(normalizedProfile);
|
|
7187
7205
|
const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
|
|
7188
7206
|
if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
|
|
7189
|
-
const
|
|
7190
|
-
const ownership = {
|
|
7207
|
+
const acquired = await acquireProfileLock(normalizedProfile, options);
|
|
7208
|
+
const ownership = {
|
|
7209
|
+
lockPath,
|
|
7210
|
+
turnSlot: acquired.turnSlot,
|
|
7211
|
+
deletedWhileWaiting: acquired.deletedWhileWaiting,
|
|
7212
|
+
active: true
|
|
7213
|
+
};
|
|
7191
7214
|
try {
|
|
7192
7215
|
return await heldProfileLocks.run([...held, ownership], action);
|
|
7193
7216
|
} finally {
|
|
7194
7217
|
ownership.active = false;
|
|
7195
|
-
await release();
|
|
7218
|
+
await acquired.release();
|
|
7219
|
+
if (acquired.deletedWhileWaiting) await (0, import_promises.rmdir)(profilePath(normalizedProfile)).catch(() => void 0);
|
|
7196
7220
|
}
|
|
7197
7221
|
}
|
|
7222
|
+
async function refuseWriteToDeletedProfile(profile) {
|
|
7223
|
+
const lockPath = profileLockPath(profile);
|
|
7224
|
+
const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
|
|
7225
|
+
if (!ownership?.deletedWhileWaiting) return;
|
|
7226
|
+
if (await exists(profileConfigPath(profile))) return;
|
|
7227
|
+
throw new ProfileDeletedError(profile);
|
|
7228
|
+
}
|
|
7198
7229
|
async function updateProfileStore(profile, store, update, options = {}) {
|
|
7199
7230
|
return withProfileLock(
|
|
7200
7231
|
profile,
|
|
@@ -7203,6 +7234,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
|
|
|
7203
7234
|
);
|
|
7204
7235
|
}
|
|
7205
7236
|
async function updateProfileStoreWhileLocked(profile, store, update) {
|
|
7237
|
+
await refuseWriteToDeletedProfile(profile);
|
|
7206
7238
|
const current = await readProfileStore(profile, store);
|
|
7207
7239
|
const next = await update(current.records);
|
|
7208
7240
|
await writeJsonAtomic(profileStorePath(profile, store), next.records);
|
|
@@ -7213,32 +7245,271 @@ async function writeFormatOneMetadata(profile, store) {
|
|
|
7213
7245
|
await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
|
|
7214
7246
|
}
|
|
7215
7247
|
async function acquireProfileLock(profile, options) {
|
|
7216
|
-
const
|
|
7217
|
-
|
|
7248
|
+
const deadline = {
|
|
7249
|
+
startedAt: Date.now(),
|
|
7250
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
|
|
7251
|
+
retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
|
|
7252
|
+
};
|
|
7218
7253
|
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
|
|
7219
|
-
const startedAt = Date.now();
|
|
7220
7254
|
const lockPath = profileLockPath(profile);
|
|
7255
|
+
const turnDirectory = profileTurnLockPath(profile);
|
|
7221
7256
|
const directory = profilePath(profile);
|
|
7222
7257
|
await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7258
|
+
await (0, import_promises.mkdir)((0, import_node_path.dirname)(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7223
7259
|
for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
|
|
7224
7260
|
await (0, import_promises.chmod)(path, PRIVATE_DIR_MODE).catch((error) => {
|
|
7225
7261
|
if (path !== directory || !isErrno(error, "ENOENT")) throw error;
|
|
7226
7262
|
});
|
|
7227
7263
|
}
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7264
|
+
const { turn, firstWaitedTurn } = await acquireTurn(profile, turnDirectory, deadline);
|
|
7265
|
+
try {
|
|
7266
|
+
const deletion = await readJson((0, import_node_path.join)(turnDirectory, "deleted.json")).catch(() => null);
|
|
7267
|
+
const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
|
|
7268
|
+
while (true) {
|
|
7269
|
+
const token = (0, import_node_crypto.randomUUID)();
|
|
7270
|
+
if (await publishProfileLock(profile, lockPath, token)) {
|
|
7271
|
+
return {
|
|
7272
|
+
deletedWhileWaiting,
|
|
7273
|
+
turnSlot: turn.slot,
|
|
7274
|
+
release: async () => {
|
|
7275
|
+
try {
|
|
7276
|
+
await releaseProfileLock(profile, lockPath, token);
|
|
7277
|
+
} finally {
|
|
7278
|
+
await releaseTurn(profile, turn);
|
|
7279
|
+
}
|
|
7280
|
+
}
|
|
7281
|
+
};
|
|
7282
|
+
}
|
|
7283
|
+
await signalTestLockContention(profile);
|
|
7284
|
+
if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
|
|
7285
|
+
await waitOrTimeOut(profile, deadline);
|
|
7286
|
+
}
|
|
7287
|
+
} catch (error) {
|
|
7288
|
+
await releaseTurn(profile, turn);
|
|
7289
|
+
throw error;
|
|
7290
|
+
}
|
|
7291
|
+
}
|
|
7292
|
+
function checkDeadline(profile, deadline, detail) {
|
|
7293
|
+
if (Date.now() - deadline.startedAt >= deadline.timeoutMs) {
|
|
7294
|
+
throw new ProfileLockTimeoutError(profile, deadline.timeoutMs, detail);
|
|
7295
|
+
}
|
|
7296
|
+
}
|
|
7297
|
+
async function waitOrTimeOut(profile, deadline, detail) {
|
|
7298
|
+
checkDeadline(profile, deadline, detail);
|
|
7299
|
+
await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
|
|
7300
|
+
}
|
|
7301
|
+
async function acquireTurn(profile, directory, deadline) {
|
|
7302
|
+
let firstWaitedTurn;
|
|
7303
|
+
let problem;
|
|
7304
|
+
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.`;
|
|
7305
|
+
for (let first = true; ; first = false) {
|
|
7306
|
+
if (!first) checkDeadline(profile, deadline, problem);
|
|
7307
|
+
problem = void 0;
|
|
7308
|
+
const latest = await readLatestTurn(directory);
|
|
7309
|
+
if (latest === "missing") {
|
|
7310
|
+
await createTurnDirectory(directory);
|
|
7311
|
+
continue;
|
|
7312
|
+
}
|
|
7313
|
+
if (latest === "empty") {
|
|
7314
|
+
problem = damaged("has no turns");
|
|
7315
|
+
await waitOrTimeOut(profile, deadline, problem);
|
|
7316
|
+
continue;
|
|
7317
|
+
}
|
|
7318
|
+
if (latest === null) continue;
|
|
7319
|
+
if (typeof latest === "number") {
|
|
7320
|
+
problem = damaged(`has an unreadable turn ${latest}`);
|
|
7321
|
+
await waitOrTimeOut(profile, deadline, problem);
|
|
7322
|
+
continue;
|
|
7323
|
+
}
|
|
7324
|
+
firstWaitedTurn ?? (firstWaitedTurn = latest.done ? latest.slot + 1 : latest.slot);
|
|
7325
|
+
if (latest.held && latest.done) {
|
|
7326
|
+
const turn = await takeTurn(profile, directory, latest);
|
|
7327
|
+
if (turn !== null) return { turn, firstWaitedTurn };
|
|
7328
|
+
await waitOrTimeOut(profile, deadline);
|
|
7329
|
+
} else if (latest.done) {
|
|
7330
|
+
problem = damaged(`ends with turn ${latest.slot}, which was never granted`);
|
|
7331
|
+
await waitOrTimeOut(profile, deadline, problem);
|
|
7332
|
+
} else if (latest.pid === process.pid ? relinquishedTurns.has(latest.token) : !isProcessAlive(latest.pid)) {
|
|
7333
|
+
await waitForTestBarrier(TEST_LOCK_TURN_SETTLE_BARRIER_DIR, profile);
|
|
7334
|
+
await settleAbandonedTurn(directory, latest);
|
|
7335
|
+
relinquishedTurns.delete(latest.token);
|
|
7336
|
+
} else {
|
|
7337
|
+
await signalTestLockContention(profile);
|
|
7338
|
+
await waitOrTimeOut(profile, deadline);
|
|
7339
|
+
}
|
|
7340
|
+
}
|
|
7341
|
+
}
|
|
7342
|
+
async function readLatestTurn(directory) {
|
|
7343
|
+
let names;
|
|
7344
|
+
try {
|
|
7345
|
+
names = await (0, import_promises.readdir)(directory);
|
|
7346
|
+
} catch (error) {
|
|
7347
|
+
if (isErrno(error, "ENOENT")) return "missing";
|
|
7348
|
+
throw error;
|
|
7349
|
+
}
|
|
7350
|
+
const slots = names.filter((name) => TURN.test(name)).map(Number);
|
|
7351
|
+
if (slots.length === 0) return "empty";
|
|
7352
|
+
const slot = Math.max(...slots);
|
|
7353
|
+
const turn = await readTurn(directory, slot);
|
|
7354
|
+
return turn === "damaged" ? slot : turn;
|
|
7355
|
+
}
|
|
7356
|
+
async function readTurn(directory, slot) {
|
|
7357
|
+
const path = (0, import_node_path.join)(directory, String(slot));
|
|
7358
|
+
let text;
|
|
7359
|
+
try {
|
|
7360
|
+
text = await (0, import_promises.readFile)((0, import_node_path.join)(path, "owner.json"), "utf8");
|
|
7361
|
+
} catch (error) {
|
|
7362
|
+
if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTDIR")) throw error;
|
|
7363
|
+
return await exists(path) ? "damaged" : null;
|
|
7364
|
+
}
|
|
7365
|
+
let owner;
|
|
7366
|
+
try {
|
|
7367
|
+
owner = JSON.parse(text);
|
|
7368
|
+
} catch {
|
|
7369
|
+
return "damaged";
|
|
7370
|
+
}
|
|
7371
|
+
if (owner === null || typeof owner !== "object" || typeof owner.token !== "string" || typeof owner.pid !== "number") {
|
|
7372
|
+
return "damaged";
|
|
7373
|
+
}
|
|
7374
|
+
const done = await exists((0, import_node_path.join)(path, `${owner.token}.done`));
|
|
7375
|
+
const held = await exists((0, import_node_path.join)(path, `${owner.token}.held`));
|
|
7376
|
+
return {
|
|
7377
|
+
slot,
|
|
7378
|
+
token: owner.token,
|
|
7379
|
+
pid: owner.pid,
|
|
7380
|
+
after: typeof owner.after === "string" ? owner.after : null,
|
|
7381
|
+
held,
|
|
7382
|
+
done
|
|
7383
|
+
};
|
|
7384
|
+
}
|
|
7385
|
+
async function isFinishedTurn(directory, slot, token) {
|
|
7386
|
+
const turn = await readTurn(directory, slot);
|
|
7387
|
+
if (turn === "damaged") throw new Error(`Turn ${slot} of the turn lock ${directory} is unreadable.`);
|
|
7388
|
+
return turn !== null && turn.token === token && turn.held && turn.done;
|
|
7389
|
+
}
|
|
7390
|
+
async function takeTurn(profile, directory, latest) {
|
|
7391
|
+
const token = (0, import_node_crypto.randomUUID)();
|
|
7392
|
+
const slot = latest.slot + 1;
|
|
7393
|
+
const path = (0, import_node_path.join)(directory, String(slot));
|
|
7394
|
+
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token };
|
|
7395
|
+
let published = false;
|
|
7396
|
+
try {
|
|
7397
|
+
published = await publishDirectory(
|
|
7398
|
+
(0, import_node_path.join)(directory, `.stage-${process.pid}-${token}`),
|
|
7399
|
+
path,
|
|
7400
|
+
{ "owner.json": `${JSON.stringify(owner, null, 2)}
|
|
7401
|
+
` },
|
|
7402
|
+
() => waitForTestBarrier(TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, profile)
|
|
7403
|
+
);
|
|
7404
|
+
if (published && await isFinishedTurn(directory, latest.slot, latest.token) && await markTurn(path, token, "held")) {
|
|
7405
|
+
return { directory, slot, token };
|
|
7406
|
+
}
|
|
7407
|
+
if (published) await markTurn(path, token, "done");
|
|
7408
|
+
return null;
|
|
7409
|
+
} catch (error) {
|
|
7410
|
+
if (published) settleOwnTurn(directory, slot, token);
|
|
7411
|
+
throw error;
|
|
7412
|
+
}
|
|
7413
|
+
}
|
|
7414
|
+
async function settleAbandonedTurn(directory, abandoned) {
|
|
7415
|
+
const path = (0, import_node_path.join)(directory, String(abandoned.slot));
|
|
7416
|
+
if (!abandoned.held && abandoned.after !== null && await isFinishedTurn(directory, abandoned.slot - 1, abandoned.after)) {
|
|
7417
|
+
await markTurn(path, abandoned.token, "held");
|
|
7418
|
+
}
|
|
7419
|
+
await markTurn(path, abandoned.token, "done");
|
|
7420
|
+
}
|
|
7421
|
+
function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
|
|
7422
|
+
relinquishedTurns.add(token);
|
|
7423
|
+
void (async () => {
|
|
7424
|
+
try {
|
|
7425
|
+
const turn = await readTurn(directory, slot);
|
|
7426
|
+
if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
|
|
7427
|
+
relinquishedTurns.delete(token);
|
|
7428
|
+
} catch {
|
|
7429
|
+
if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
|
|
7430
|
+
}
|
|
7431
|
+
})();
|
|
7432
|
+
}
|
|
7433
|
+
async function markTurn(path, token, kind) {
|
|
7434
|
+
for (let attempt = 0; ; attempt++) {
|
|
7435
|
+
try {
|
|
7436
|
+
await (0, import_promises.writeFile)((0, import_node_path.join)(path, `${token}.${kind}`), "", { mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
7437
|
+
return true;
|
|
7438
|
+
} catch (error) {
|
|
7439
|
+
if (isErrno(error, "EEXIST")) return true;
|
|
7440
|
+
if (isErrno(error, "ENOENT")) return false;
|
|
7441
|
+
if (attempt >= MARKER_RETRY_DELAYS_MS.length) throw error;
|
|
7442
|
+
await sleep(MARKER_RETRY_DELAYS_MS[attempt]);
|
|
7443
|
+
}
|
|
7444
|
+
}
|
|
7445
|
+
}
|
|
7446
|
+
async function createTurnDirectory(directory) {
|
|
7447
|
+
const token = (0, import_node_crypto.randomUUID)();
|
|
7448
|
+
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null };
|
|
7449
|
+
await publishDirectory((0, import_node_path.join)((0, import_node_path.dirname)(directory), `.stage-${(0, import_node_path.basename)(directory)}-${process.pid}-${token}`), directory, {
|
|
7450
|
+
"0/owner.json": `${JSON.stringify(owner, null, 2)}
|
|
7451
|
+
`,
|
|
7452
|
+
[`0/${token}.held`]: "",
|
|
7453
|
+
[`0/${token}.done`]: ""
|
|
7454
|
+
});
|
|
7455
|
+
}
|
|
7456
|
+
async function publishDirectory(stage, target, files, beforeRename) {
|
|
7457
|
+
try {
|
|
7458
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
7459
|
+
await (0, import_promises.mkdir)((0, import_node_path.dirname)((0, import_node_path.join)(stage, name)), { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7460
|
+
await writeFileDurably((0, import_node_path.join)(stage, name), contents);
|
|
7461
|
+
}
|
|
7462
|
+
await beforeRename?.();
|
|
7463
|
+
await (0, import_promises.rename)(stage, target);
|
|
7464
|
+
return true;
|
|
7465
|
+
} catch (error) {
|
|
7466
|
+
if (["ENOTEMPTY", "EEXIST", "ENOENT"].some((code) => isErrno(error, code)) || await exists(target)) return false;
|
|
7467
|
+
throw error;
|
|
7468
|
+
} finally {
|
|
7469
|
+
await (0, import_promises.rm)(stage, { recursive: true, force: true }).catch(() => void 0);
|
|
7470
|
+
}
|
|
7471
|
+
}
|
|
7472
|
+
async function releaseTurn(profile, turn) {
|
|
7473
|
+
try {
|
|
7474
|
+
await markTurn((0, import_node_path.join)(turn.directory, String(turn.slot)), turn.token, "done");
|
|
7475
|
+
} catch {
|
|
7476
|
+
settleOwnTurn(turn.directory, turn.slot, turn.token);
|
|
7477
|
+
return;
|
|
7478
|
+
}
|
|
7479
|
+
await collectTurns(profile, turn).catch(() => void 0);
|
|
7480
|
+
}
|
|
7481
|
+
function isAbandonedTurnEntry(name) {
|
|
7482
|
+
if (name.startsWith(".trash-")) return true;
|
|
7483
|
+
const writer = /^\.(?:stage|deleted\.json)[-.]([0-9]+)[-.]/.exec(name)?.[1];
|
|
7484
|
+
return writer !== void 0 && !isProcessAlive(Number(writer));
|
|
7485
|
+
}
|
|
7486
|
+
function isAbandonedTurnDirectoryStage(name) {
|
|
7487
|
+
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];
|
|
7488
|
+
return writer !== void 0 && !isProcessAlive(Number(writer));
|
|
7489
|
+
}
|
|
7490
|
+
async function collectTurns(profile, turn) {
|
|
7491
|
+
for (const [directory, abandoned] of [
|
|
7492
|
+
[turn.directory, isAbandonedTurnEntry],
|
|
7493
|
+
[(0, import_node_path.dirname)(turn.directory), isAbandonedTurnDirectoryStage]
|
|
7494
|
+
]) {
|
|
7495
|
+
for (const name of (await (0, import_promises.readdir)(directory)).filter(abandoned)) {
|
|
7496
|
+
await (0, import_promises.rm)((0, import_node_path.join)(directory, name), { recursive: true, force: true }).catch(() => void 0);
|
|
7497
|
+
}
|
|
7498
|
+
}
|
|
7499
|
+
const older = (await (0, import_promises.readdir)(turn.directory)).filter((name) => TURN.test(name)).map(Number).filter((slot) => slot < turn.slot).sort((left, right) => left - right);
|
|
7500
|
+
for (const slot of older) {
|
|
7501
|
+
const candidate = await readTurn(turn.directory, slot);
|
|
7502
|
+
if (candidate === null || candidate === "damaged" || !candidate.done) continue;
|
|
7503
|
+
if (candidate.held) {
|
|
7504
|
+
if (await exists((0, import_node_path.join)(turn.directory, String(slot - 1)))) continue;
|
|
7505
|
+
const next = await readTurn(turn.directory, slot + 1);
|
|
7506
|
+
if (next === null || next === "damaged" || !next.held || !next.done) continue;
|
|
7234
7507
|
}
|
|
7235
|
-
await
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
throw new ProfileLockTimeoutError(profile, timeoutMs);
|
|
7508
|
+
await waitForTestBarrier(TEST_LOCK_TURN_COLLECT_BARRIER_DIR, profile);
|
|
7509
|
+
const trash = (0, import_node_path.join)(turn.directory, `.trash-${(0, import_node_crypto.randomUUID)()}`);
|
|
7510
|
+
if (await (0, import_promises.rename)((0, import_node_path.join)(turn.directory, String(slot)), trash).then(() => true, () => false)) {
|
|
7511
|
+
await (0, import_promises.rm)(trash, { recursive: true, force: true }).catch(() => void 0);
|
|
7240
7512
|
}
|
|
7241
|
-
await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
|
|
7242
7513
|
}
|
|
7243
7514
|
}
|
|
7244
7515
|
async function publishProfileLock(profile, lockPath, token) {
|
|
@@ -7251,12 +7522,18 @@ async function publishProfileLock(profile, lockPath, token) {
|
|
|
7251
7522
|
return false;
|
|
7252
7523
|
}
|
|
7253
7524
|
await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
|
|
7525
|
+
const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
|
|
7254
7526
|
const staged = (0, import_node_path.join)((0, import_node_path.dirname)(lockPath), `.lock-owner-${token}.tmp`);
|
|
7255
|
-
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }
|
|
7527
|
+
const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }, null, 2)}
|
|
7528
|
+
`;
|
|
7256
7529
|
try {
|
|
7257
|
-
await (0, import_promises.writeFile)(staged,
|
|
7258
|
-
|
|
7259
|
-
|
|
7530
|
+
await (0, import_promises.writeFile)(staged, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
7531
|
+
try {
|
|
7532
|
+
await (0, import_promises.link)(staged, ownerPath);
|
|
7533
|
+
} catch (error) {
|
|
7534
|
+
if (!lacksHardLinks(error)) throw error;
|
|
7535
|
+
await (0, import_promises.writeFile)(ownerPath, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
7536
|
+
}
|
|
7260
7537
|
return true;
|
|
7261
7538
|
} catch (error) {
|
|
7262
7539
|
if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
|
|
@@ -7266,6 +7543,9 @@ async function publishProfileLock(profile, lockPath, token) {
|
|
|
7266
7543
|
await (0, import_promises.rm)(staged, { force: true }).catch(() => void 0);
|
|
7267
7544
|
}
|
|
7268
7545
|
}
|
|
7546
|
+
function lacksHardLinks(error) {
|
|
7547
|
+
return typeof error === "object" && error !== null && "code" in error && NO_HARD_LINK_ERRORS.has(String(error.code));
|
|
7548
|
+
}
|
|
7269
7549
|
async function releaseProfileLock(profile, lockPath, token) {
|
|
7270
7550
|
const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
|
|
7271
7551
|
const owner = await readJson(ownerPath).catch(() => null);
|
|
@@ -7289,9 +7569,10 @@ async function signalTestLockContention(profile) {
|
|
|
7289
7569
|
`, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
|
|
7290
7570
|
}
|
|
7291
7571
|
async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
7572
|
+
let aged;
|
|
7292
7573
|
try {
|
|
7293
7574
|
const { mtimeMs } = await (0, import_promises.stat)(lockPath);
|
|
7294
|
-
|
|
7575
|
+
aged = Date.now() - mtimeMs >= staleAfterMs;
|
|
7295
7576
|
} catch {
|
|
7296
7577
|
return false;
|
|
7297
7578
|
}
|
|
@@ -7300,7 +7581,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
|
7300
7581
|
try {
|
|
7301
7582
|
const entries = await (0, import_promises.readdir)(lockPath);
|
|
7302
7583
|
if (entries.includes("owner.json")) return false;
|
|
7303
|
-
|
|
7584
|
+
const onlyOrphanedClaims = entries.length > 0 && entries.every((entry) => RECOVERY_CLAIM.test(entry));
|
|
7585
|
+
if (!onlyOrphanedClaims && (!aged || recoveryFenced(checkedAt, staleAfterMs))) return false;
|
|
7586
|
+
for (const name of entries.filter((entry) => RECOVERY_CLAIM.test(entry))) {
|
|
7587
|
+
const claimPath = (0, import_node_path.join)(lockPath, name);
|
|
7588
|
+
if (await hasLiveRecoveryOwner(claimPath)) {
|
|
7589
|
+
if (!await exists((0, import_node_path.join)(lockPath, "owner.json"))) await (0, import_promises.rename)(claimPath, (0, import_node_path.join)(lockPath, "owner.json"));
|
|
7590
|
+
return false;
|
|
7591
|
+
}
|
|
7592
|
+
await (0, import_promises.rm)(claimPath, { force: true });
|
|
7593
|
+
}
|
|
7304
7594
|
for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
|
|
7305
7595
|
await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
|
|
7306
7596
|
}
|
|
@@ -7311,6 +7601,23 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
|
7311
7601
|
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
7312
7602
|
return true;
|
|
7313
7603
|
}
|
|
7604
|
+
async function hasLiveRecoveryOwner(claimPath) {
|
|
7605
|
+
let contents;
|
|
7606
|
+
try {
|
|
7607
|
+
contents = await (0, import_promises.readFile)(claimPath, "utf8");
|
|
7608
|
+
} catch (error) {
|
|
7609
|
+
if (isErrno(error, "ENOENT")) return false;
|
|
7610
|
+
throw error;
|
|
7611
|
+
}
|
|
7612
|
+
let owner;
|
|
7613
|
+
try {
|
|
7614
|
+
owner = JSON.parse(contents);
|
|
7615
|
+
} catch {
|
|
7616
|
+
return false;
|
|
7617
|
+
}
|
|
7618
|
+
const pid = typeof owner === "object" && owner !== null && "pid" in owner ? owner.pid : null;
|
|
7619
|
+
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && isProcessAlive(pid);
|
|
7620
|
+
}
|
|
7314
7621
|
function recoveryFenced(since, staleAfterMs) {
|
|
7315
7622
|
return performance.now() - since >= staleAfterMs / 2;
|
|
7316
7623
|
}
|
|
@@ -7325,37 +7632,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
|
|
|
7325
7632
|
}
|
|
7326
7633
|
async function recoverStaleLock(profile, lockPath, staleAfterMs) {
|
|
7327
7634
|
const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
|
|
7328
|
-
const
|
|
7329
|
-
if (
|
|
7330
|
-
if (!
|
|
7331
|
-
const observedToken = typeof owner?.token === "string" && owner.token.length > 0 ? owner.token : "legacy";
|
|
7635
|
+
const observed = await (0, import_promises.readFile)(ownerPath, "utf8").catch(() => null);
|
|
7636
|
+
if (observed === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
|
|
7637
|
+
if (!await isAbandonedOwner(observed, ownerPath, staleAfterMs)) return false;
|
|
7332
7638
|
await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
|
|
7333
|
-
const claimPath = (0, import_node_path.join)(lockPath, `.
|
|
7639
|
+
const claimPath = (0, import_node_path.join)(lockPath, `.recover-${(0, import_node_crypto.randomUUID)()}.json`);
|
|
7640
|
+
let moved = false;
|
|
7334
7641
|
try {
|
|
7335
7642
|
await (0, import_promises.link)(ownerPath, claimPath);
|
|
7336
|
-
} catch {
|
|
7337
|
-
return false;
|
|
7643
|
+
} catch (error) {
|
|
7644
|
+
if (!lacksHardLinks(error)) return false;
|
|
7645
|
+
try {
|
|
7646
|
+
await (0, import_promises.rename)(ownerPath, claimPath);
|
|
7647
|
+
moved = true;
|
|
7648
|
+
} catch {
|
|
7649
|
+
return false;
|
|
7650
|
+
}
|
|
7338
7651
|
}
|
|
7339
|
-
|
|
7340
|
-
await
|
|
7341
|
-
const claimed = await readJson(claimPath).catch(() => null);
|
|
7342
|
-
const sameInstance = claimed !== null && (observedToken === "legacy" ? claimed.token === void 0 : claimed.token === observedToken);
|
|
7652
|
+
if (moved) await waitForTestBarrier(TEST_LOCK_MOVED_BARRIER_DIR, profile);
|
|
7653
|
+
const sameInstance = await (0, import_promises.readFile)(claimPath, "utf8").then((claimed) => claimed === observed, () => false);
|
|
7343
7654
|
await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
|
|
7344
|
-
if (sameInstance
|
|
7345
|
-
|
|
7346
|
-
|
|
7655
|
+
if (!sameInstance) {
|
|
7656
|
+
if (moved) {
|
|
7657
|
+
await (0, import_promises.rename)(claimPath, ownerPath).catch(() => void 0);
|
|
7658
|
+
} else {
|
|
7659
|
+
await (0, import_promises.unlink)(claimPath).catch(() => void 0);
|
|
7660
|
+
}
|
|
7347
7661
|
return false;
|
|
7348
7662
|
}
|
|
7349
|
-
if (
|
|
7350
|
-
|
|
7351
|
-
|
|
7663
|
+
if (!moved) await (0, import_promises.rm)(ownerPath, { force: true });
|
|
7664
|
+
await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
|
|
7665
|
+
await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
|
|
7666
|
+
for (const name of (await (0, import_promises.readdir)(lockPath).catch(() => [])).filter((entry) => RECOVERY_CLAIM.test(entry))) {
|
|
7667
|
+
if ((0, import_node_path.join)(lockPath, name) !== claimPath) await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
|
|
7352
7668
|
}
|
|
7353
|
-
await (0, import_promises.
|
|
7354
|
-
await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
|
|
7355
|
-
if (!sameInstance) return false;
|
|
7669
|
+
if (await (0, import_promises.unlink)(claimPath).then(() => true, () => false)) await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
|
|
7356
7670
|
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
7357
7671
|
return true;
|
|
7358
7672
|
}
|
|
7673
|
+
async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
|
|
7674
|
+
let owner;
|
|
7675
|
+
try {
|
|
7676
|
+
owner = JSON.parse(observed);
|
|
7677
|
+
} catch {
|
|
7678
|
+
owner = null;
|
|
7679
|
+
}
|
|
7680
|
+
if (typeof owner === "object" && owner !== null && "pid" in owner) {
|
|
7681
|
+
return isStaleOwner(owner, staleAfterMs);
|
|
7682
|
+
}
|
|
7683
|
+
const modifiedAt = await (0, import_promises.stat)(ownerPath).then(({ mtimeMs }) => mtimeMs, () => Number.NaN);
|
|
7684
|
+
return Date.now() - modifiedAt >= staleAfterMs;
|
|
7685
|
+
}
|
|
7359
7686
|
async function waitForTestBarrier(environmentName, profile) {
|
|
7360
7687
|
if (process.env.NODE_ENV !== "test") return;
|
|
7361
7688
|
const barrierDirectory = process.env[environmentName];
|
|
@@ -7382,6 +7709,15 @@ function isStaleOwner(owner, staleAfterMs) {
|
|
|
7382
7709
|
}
|
|
7383
7710
|
return !isProcessAlive(owner.pid);
|
|
7384
7711
|
}
|
|
7712
|
+
async function exists(path) {
|
|
7713
|
+
try {
|
|
7714
|
+
await (0, import_promises.stat)(path);
|
|
7715
|
+
return true;
|
|
7716
|
+
} catch (error) {
|
|
7717
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) return false;
|
|
7718
|
+
throw error;
|
|
7719
|
+
}
|
|
7720
|
+
}
|
|
7385
7721
|
function isProcessAlive(pid) {
|
|
7386
7722
|
try {
|
|
7387
7723
|
process.kill(pid, 0);
|
|
@@ -7402,7 +7738,7 @@ function isErrno(error, code) {
|
|
|
7402
7738
|
function sleep(milliseconds) {
|
|
7403
7739
|
return new Promise((resolve2) => setTimeout(resolve2, milliseconds));
|
|
7404
7740
|
}
|
|
7405
|
-
var import_node_crypto, import_node_async_hooks, import_promises, import_node_os, import_node_path, 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,
|
|
7741
|
+
var import_node_crypto, import_node_async_hooks, import_promises, import_node_os, import_node_path, 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;
|
|
7406
7742
|
var init_state = __esm({
|
|
7407
7743
|
"src/state.ts"() {
|
|
7408
7744
|
"use strict";
|
|
@@ -7424,18 +7760,36 @@ var init_state = __esm({
|
|
|
7424
7760
|
TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
|
|
7425
7761
|
TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
|
|
7426
7762
|
TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
|
|
7427
|
-
TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
|
|
7428
7763
|
TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
|
|
7764
|
+
TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
|
|
7765
|
+
TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
|
|
7766
|
+
TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
|
|
7767
|
+
TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
|
|
7429
7768
|
invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
|
|
7430
|
-
heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.
|
|
7769
|
+
heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
|
|
7431
7770
|
ProfileLockTimeoutError = class extends Error {
|
|
7432
|
-
|
|
7433
|
-
|
|
7771
|
+
/** `detail` names the cause when it is not simply another process holding the lock. */
|
|
7772
|
+
constructor(profile, timeoutMs, detail) {
|
|
7773
|
+
super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.${detail ? ` ${detail}` : ""}`);
|
|
7434
7774
|
this.code = "PROFILE_LOCK_TIMEOUT";
|
|
7435
7775
|
this.name = "ProfileLockTimeoutError";
|
|
7436
7776
|
}
|
|
7437
7777
|
};
|
|
7778
|
+
ProfileDeletedError = class extends Error {
|
|
7779
|
+
constructor(profile) {
|
|
7780
|
+
super(`Profile "${profile}" was deleted while this change waited for its lock, so the change was not written.`);
|
|
7781
|
+
this.code = "PROFILE_NOT_FOUND";
|
|
7782
|
+
this.name = "ProfileDeletedError";
|
|
7783
|
+
}
|
|
7784
|
+
};
|
|
7785
|
+
TURN = /^(?:0|[1-9][0-9]*)$/;
|
|
7786
|
+
relinquishedTurns = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Set());
|
|
7787
|
+
MARKER_RETRY_DELAYS_MS = [10, 50, 250];
|
|
7788
|
+
SETTLE_RETRY_MS = 1e3;
|
|
7789
|
+
SETTLE_RETRIES = 300;
|
|
7790
|
+
NO_HARD_LINK_ERRORS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EISDIR", "EMLINK"]);
|
|
7438
7791
|
ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
|
|
7792
|
+
RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
|
|
7439
7793
|
STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
|
|
7440
7794
|
}
|
|
7441
7795
|
});
|