@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/artifacts.cjs
CHANGED
|
@@ -6957,22 +6957,33 @@ var TEST_LOCK_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_PUBLISH_BARRIER_DIR";
|
|
|
6957
6957
|
var TEST_LOCK_RELEASE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_RELEASE_BARRIER_DIR";
|
|
6958
6958
|
var TEST_LOCK_CLAIM_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIM_BARRIER_DIR";
|
|
6959
6959
|
var TEST_LOCK_CLAIMED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_CLAIMED_BARRIER_DIR";
|
|
6960
|
-
var TEST_LOCK_FENCED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_FENCED_BARRIER_DIR";
|
|
6961
6960
|
var TEST_LOCK_VERIFIED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_VERIFIED_BARRIER_DIR";
|
|
6961
|
+
var TEST_LOCK_MOVED_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_MOVED_BARRIER_DIR";
|
|
6962
|
+
var TEST_LOCK_TURN_PUBLISH_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_PUBLISH_BARRIER_DIR";
|
|
6963
|
+
var TEST_LOCK_TURN_SETTLE_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_SETTLE_BARRIER_DIR";
|
|
6964
|
+
var TEST_LOCK_TURN_COLLECT_BARRIER_DIR = "TC_TEST_PROFILE_LOCK_TURN_COLLECT_BARRIER_DIR";
|
|
6962
6965
|
function processWideContext(key) {
|
|
6963
|
-
var
|
|
6966
|
+
var _a2;
|
|
6964
6967
|
const registry = globalThis;
|
|
6965
|
-
return registry[
|
|
6968
|
+
return registry[_a2 = Symbol.for(key)] ?? (registry[_a2] = new import_node_async_hooks.AsyncLocalStorage());
|
|
6966
6969
|
}
|
|
6967
6970
|
var invocationStateRoot = processWideContext("tinycloud.operations.invocationStateRoot.v1");
|
|
6968
|
-
var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.
|
|
6971
|
+
var heldProfileLocks = processWideContext("tinycloud.operations.heldProfileLocks.v2");
|
|
6969
6972
|
var ProfileLockTimeoutError = class extends Error {
|
|
6970
|
-
|
|
6971
|
-
|
|
6973
|
+
/** `detail` names the cause when it is not simply another process holding the lock. */
|
|
6974
|
+
constructor(profile, timeoutMs, detail) {
|
|
6975
|
+
super(`Timed out waiting for the profile lock for "${profile}" after ${timeoutMs}ms.${detail ? ` ${detail}` : ""}`);
|
|
6972
6976
|
this.code = "PROFILE_LOCK_TIMEOUT";
|
|
6973
6977
|
this.name = "ProfileLockTimeoutError";
|
|
6974
6978
|
}
|
|
6975
6979
|
};
|
|
6980
|
+
var ProfileDeletedError = class extends Error {
|
|
6981
|
+
constructor(profile) {
|
|
6982
|
+
super(`Profile "${profile}" was deleted while this change waited for its lock, so the change was not written.`);
|
|
6983
|
+
this.code = "PROFILE_NOT_FOUND";
|
|
6984
|
+
this.name = "ProfileDeletedError";
|
|
6985
|
+
}
|
|
6986
|
+
};
|
|
6976
6987
|
function tinycloudHomePath() {
|
|
6977
6988
|
const home = invocationStateRoot.getStore() ?? process.env.TC_HOME ?? process.env.HOME ?? process.env.USERPROFILE ?? (0, import_node_os.homedir)();
|
|
6978
6989
|
return (0, import_node_path.join)(home, ".tinycloud");
|
|
@@ -6983,6 +6994,9 @@ function profilesPath() {
|
|
|
6983
6994
|
function profilePath(profile) {
|
|
6984
6995
|
return (0, import_node_path.join)(profilesPath(), validateProfileName(profile));
|
|
6985
6996
|
}
|
|
6997
|
+
function profileConfigPath(profile) {
|
|
6998
|
+
return (0, import_node_path.join)(profilePath(profile), "profile.json");
|
|
6999
|
+
}
|
|
6986
7000
|
function profileStorePath(profile, store) {
|
|
6987
7001
|
return (0, import_node_path.join)(profilePath(profile), `${store}.json`);
|
|
6988
7002
|
}
|
|
@@ -6992,6 +7006,9 @@ function profileStoreMetadataPath(profile, store) {
|
|
|
6992
7006
|
function profileLockPath(profile) {
|
|
6993
7007
|
return (0, import_node_path.join)(profilePath(profile), ".lock");
|
|
6994
7008
|
}
|
|
7009
|
+
function profileTurnLockPath(profile) {
|
|
7010
|
+
return (0, import_node_path.join)(tinycloudHomePath(), "profile-locks", validateProfileName(profile));
|
|
7011
|
+
}
|
|
6995
7012
|
async function readJson(filePath) {
|
|
6996
7013
|
try {
|
|
6997
7014
|
return JSON.parse(await (0, import_promises.readFile)(filePath, "utf8"));
|
|
@@ -7001,6 +7018,9 @@ async function readJson(filePath) {
|
|
|
7001
7018
|
}
|
|
7002
7019
|
}
|
|
7003
7020
|
async function writeJsonAtomic(filePath, value) {
|
|
7021
|
+
await replaceJson(filePath, value, false);
|
|
7022
|
+
}
|
|
7023
|
+
async function replaceJson(filePath, value, durable) {
|
|
7004
7024
|
const directory = (0, import_node_path.dirname)(filePath);
|
|
7005
7025
|
const temporaryPath = (0, import_node_path.join)(
|
|
7006
7026
|
directory,
|
|
@@ -7010,13 +7030,25 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
7010
7030
|
`;
|
|
7011
7031
|
await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7012
7032
|
try {
|
|
7013
|
-
|
|
7033
|
+
if (durable) await writeFileDurably(temporaryPath, contents);
|
|
7034
|
+
else await (0, import_promises.writeFile)(temporaryPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
|
|
7014
7035
|
await (0, import_promises.rename)(temporaryPath, filePath);
|
|
7015
7036
|
} catch (error) {
|
|
7016
7037
|
await (0, import_promises.rm)(temporaryPath, { force: true }).catch(() => void 0);
|
|
7017
7038
|
throw error;
|
|
7018
7039
|
}
|
|
7019
7040
|
}
|
|
7041
|
+
async function writeFileDurably(path, contents) {
|
|
7042
|
+
const handle = await (0, import_promises.open)(path, "wx", PRIVATE_FILE_MODE);
|
|
7043
|
+
try {
|
|
7044
|
+
if (contents) {
|
|
7045
|
+
await handle.writeFile(contents, "utf8");
|
|
7046
|
+
await handle.datasync();
|
|
7047
|
+
}
|
|
7048
|
+
} finally {
|
|
7049
|
+
await handle.close();
|
|
7050
|
+
}
|
|
7051
|
+
}
|
|
7020
7052
|
async function readAuthRequests(profile) {
|
|
7021
7053
|
return (await readProfileStore(profile, "auth-requests")).records;
|
|
7022
7054
|
}
|
|
@@ -7043,15 +7075,28 @@ async function withProfileLock(profile, action, options = {}) {
|
|
|
7043
7075
|
const lockPath = profileLockPath(normalizedProfile);
|
|
7044
7076
|
const held = (heldProfileLocks.getStore() ?? []).filter((ownership2) => ownership2.active);
|
|
7045
7077
|
if (held.some((ownership2) => ownership2.lockPath === lockPath)) return action();
|
|
7046
|
-
const
|
|
7047
|
-
const ownership = {
|
|
7078
|
+
const acquired = await acquireProfileLock(normalizedProfile, options);
|
|
7079
|
+
const ownership = {
|
|
7080
|
+
lockPath,
|
|
7081
|
+
turnSlot: acquired.turnSlot,
|
|
7082
|
+
deletedWhileWaiting: acquired.deletedWhileWaiting,
|
|
7083
|
+
active: true
|
|
7084
|
+
};
|
|
7048
7085
|
try {
|
|
7049
7086
|
return await heldProfileLocks.run([...held, ownership], action);
|
|
7050
7087
|
} finally {
|
|
7051
7088
|
ownership.active = false;
|
|
7052
|
-
await release();
|
|
7089
|
+
await acquired.release();
|
|
7090
|
+
if (acquired.deletedWhileWaiting) await (0, import_promises.rmdir)(profilePath(normalizedProfile)).catch(() => void 0);
|
|
7053
7091
|
}
|
|
7054
7092
|
}
|
|
7093
|
+
async function refuseWriteToDeletedProfile(profile) {
|
|
7094
|
+
const lockPath = profileLockPath(profile);
|
|
7095
|
+
const ownership = (heldProfileLocks.getStore() ?? []).find((candidate) => candidate.active && candidate.lockPath === lockPath);
|
|
7096
|
+
if (!ownership?.deletedWhileWaiting) return;
|
|
7097
|
+
if (await exists(profileConfigPath(profile))) return;
|
|
7098
|
+
throw new ProfileDeletedError(profile);
|
|
7099
|
+
}
|
|
7055
7100
|
async function updateProfileStore(profile, store, update, options = {}) {
|
|
7056
7101
|
return withProfileLock(
|
|
7057
7102
|
profile,
|
|
@@ -7060,6 +7105,7 @@ async function updateProfileStore(profile, store, update, options = {}) {
|
|
|
7060
7105
|
);
|
|
7061
7106
|
}
|
|
7062
7107
|
async function updateProfileStoreWhileLocked(profile, store, update) {
|
|
7108
|
+
await refuseWriteToDeletedProfile(profile);
|
|
7063
7109
|
const current = await readProfileStore(profile, store);
|
|
7064
7110
|
const next = await update(current.records);
|
|
7065
7111
|
await writeJsonAtomic(profileStorePath(profile, store), next.records);
|
|
@@ -7070,32 +7116,277 @@ async function writeFormatOneMetadata(profile, store) {
|
|
|
7070
7116
|
await writeJsonAtomic(profileStoreMetadataPath(profile, store), { formatVersion: 1 });
|
|
7071
7117
|
}
|
|
7072
7118
|
async function acquireProfileLock(profile, options) {
|
|
7073
|
-
const
|
|
7074
|
-
|
|
7119
|
+
const deadline = {
|
|
7120
|
+
startedAt: Date.now(),
|
|
7121
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
|
|
7122
|
+
retryMs: options.retryMs ?? DEFAULT_LOCK_RETRY_MS
|
|
7123
|
+
};
|
|
7075
7124
|
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_LOCK_MS;
|
|
7076
|
-
const startedAt = Date.now();
|
|
7077
7125
|
const lockPath = profileLockPath(profile);
|
|
7126
|
+
const turnDirectory = profileTurnLockPath(profile);
|
|
7078
7127
|
const directory = profilePath(profile);
|
|
7079
7128
|
await (0, import_promises.mkdir)(directory, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7129
|
+
await (0, import_promises.mkdir)((0, import_node_path.dirname)(turnDirectory), { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7080
7130
|
for (const path of [tinycloudHomePath(), profilesPath(), directory]) {
|
|
7081
7131
|
await (0, import_promises.chmod)(path, PRIVATE_DIR_MODE).catch((error) => {
|
|
7082
7132
|
if (path !== directory || !isErrno(error, "ENOENT")) throw error;
|
|
7083
7133
|
});
|
|
7084
7134
|
}
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
|
|
7088
|
-
|
|
7089
|
-
|
|
7090
|
-
|
|
7135
|
+
const { turn, firstWaitedTurn } = await acquireTurn(profile, turnDirectory, deadline);
|
|
7136
|
+
try {
|
|
7137
|
+
const deletion = await readJson((0, import_node_path.join)(turnDirectory, "deleted.json")).catch(() => null);
|
|
7138
|
+
const deletedWhileWaiting = typeof deletion?.slot === "number" && deletion.slot >= firstWaitedTurn;
|
|
7139
|
+
while (true) {
|
|
7140
|
+
const token = (0, import_node_crypto.randomUUID)();
|
|
7141
|
+
if (await publishProfileLock(profile, lockPath, token)) {
|
|
7142
|
+
return {
|
|
7143
|
+
deletedWhileWaiting,
|
|
7144
|
+
turnSlot: turn.slot,
|
|
7145
|
+
release: async () => {
|
|
7146
|
+
try {
|
|
7147
|
+
await releaseProfileLock(profile, lockPath, token);
|
|
7148
|
+
} finally {
|
|
7149
|
+
await releaseTurn(profile, turn);
|
|
7150
|
+
}
|
|
7151
|
+
}
|
|
7152
|
+
};
|
|
7153
|
+
}
|
|
7154
|
+
await signalTestLockContention(profile);
|
|
7155
|
+
if (await recoverStaleLock(profile, lockPath, staleAfterMs)) continue;
|
|
7156
|
+
await waitOrTimeOut(profile, deadline);
|
|
7091
7157
|
}
|
|
7092
|
-
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7158
|
+
} catch (error) {
|
|
7159
|
+
await releaseTurn(profile, turn);
|
|
7160
|
+
throw error;
|
|
7161
|
+
}
|
|
7162
|
+
}
|
|
7163
|
+
function checkDeadline(profile, deadline, detail) {
|
|
7164
|
+
if (Date.now() - deadline.startedAt >= deadline.timeoutMs) {
|
|
7165
|
+
throw new ProfileLockTimeoutError(profile, deadline.timeoutMs, detail);
|
|
7166
|
+
}
|
|
7167
|
+
}
|
|
7168
|
+
async function waitOrTimeOut(profile, deadline, detail) {
|
|
7169
|
+
checkDeadline(profile, deadline, detail);
|
|
7170
|
+
await sleep(Math.min(deadline.retryMs, deadline.timeoutMs - (Date.now() - deadline.startedAt)));
|
|
7171
|
+
}
|
|
7172
|
+
var TURN = /^(?:0|[1-9][0-9]*)$/;
|
|
7173
|
+
var _a;
|
|
7174
|
+
var relinquishedTurns = globalThis[_a = /* @__PURE__ */ Symbol.for("tinycloud.operations.relinquishedProfileTurns.v1")] ?? (globalThis[_a] = /* @__PURE__ */ new Set());
|
|
7175
|
+
var MARKER_RETRY_DELAYS_MS = [10, 50, 250];
|
|
7176
|
+
var SETTLE_RETRY_MS = 1e3;
|
|
7177
|
+
var SETTLE_RETRIES = 300;
|
|
7178
|
+
async function acquireTurn(profile, directory, deadline) {
|
|
7179
|
+
let firstWaitedTurn;
|
|
7180
|
+
let problem;
|
|
7181
|
+
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.`;
|
|
7182
|
+
for (let first = true; ; first = false) {
|
|
7183
|
+
if (!first) checkDeadline(profile, deadline, problem);
|
|
7184
|
+
problem = void 0;
|
|
7185
|
+
const latest = await readLatestTurn(directory);
|
|
7186
|
+
if (latest === "missing") {
|
|
7187
|
+
await createTurnDirectory(directory);
|
|
7188
|
+
continue;
|
|
7189
|
+
}
|
|
7190
|
+
if (latest === "empty") {
|
|
7191
|
+
problem = damaged("has no turns");
|
|
7192
|
+
await waitOrTimeOut(profile, deadline, problem);
|
|
7193
|
+
continue;
|
|
7194
|
+
}
|
|
7195
|
+
if (latest === null) continue;
|
|
7196
|
+
if (typeof latest === "number") {
|
|
7197
|
+
problem = damaged(`has an unreadable turn ${latest}`);
|
|
7198
|
+
await waitOrTimeOut(profile, deadline, problem);
|
|
7199
|
+
continue;
|
|
7200
|
+
}
|
|
7201
|
+
firstWaitedTurn ?? (firstWaitedTurn = latest.done ? latest.slot + 1 : latest.slot);
|
|
7202
|
+
if (latest.held && latest.done) {
|
|
7203
|
+
const turn = await takeTurn(profile, directory, latest);
|
|
7204
|
+
if (turn !== null) return { turn, firstWaitedTurn };
|
|
7205
|
+
await waitOrTimeOut(profile, deadline);
|
|
7206
|
+
} else if (latest.done) {
|
|
7207
|
+
problem = damaged(`ends with turn ${latest.slot}, which was never granted`);
|
|
7208
|
+
await waitOrTimeOut(profile, deadline, problem);
|
|
7209
|
+
} else if (latest.pid === process.pid ? relinquishedTurns.has(latest.token) : !isProcessAlive(latest.pid)) {
|
|
7210
|
+
await waitForTestBarrier(TEST_LOCK_TURN_SETTLE_BARRIER_DIR, profile);
|
|
7211
|
+
await settleAbandonedTurn(directory, latest);
|
|
7212
|
+
relinquishedTurns.delete(latest.token);
|
|
7213
|
+
} else {
|
|
7214
|
+
await signalTestLockContention(profile);
|
|
7215
|
+
await waitOrTimeOut(profile, deadline);
|
|
7216
|
+
}
|
|
7217
|
+
}
|
|
7218
|
+
}
|
|
7219
|
+
async function readLatestTurn(directory) {
|
|
7220
|
+
let names;
|
|
7221
|
+
try {
|
|
7222
|
+
names = await (0, import_promises.readdir)(directory);
|
|
7223
|
+
} catch (error) {
|
|
7224
|
+
if (isErrno(error, "ENOENT")) return "missing";
|
|
7225
|
+
throw error;
|
|
7226
|
+
}
|
|
7227
|
+
const slots = names.filter((name) => TURN.test(name)).map(Number);
|
|
7228
|
+
if (slots.length === 0) return "empty";
|
|
7229
|
+
const slot = Math.max(...slots);
|
|
7230
|
+
const turn = await readTurn(directory, slot);
|
|
7231
|
+
return turn === "damaged" ? slot : turn;
|
|
7232
|
+
}
|
|
7233
|
+
async function readTurn(directory, slot) {
|
|
7234
|
+
const path = (0, import_node_path.join)(directory, String(slot));
|
|
7235
|
+
let text;
|
|
7236
|
+
try {
|
|
7237
|
+
text = await (0, import_promises.readFile)((0, import_node_path.join)(path, "owner.json"), "utf8");
|
|
7238
|
+
} catch (error) {
|
|
7239
|
+
if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTDIR")) throw error;
|
|
7240
|
+
return await exists(path) ? "damaged" : null;
|
|
7241
|
+
}
|
|
7242
|
+
let owner;
|
|
7243
|
+
try {
|
|
7244
|
+
owner = JSON.parse(text);
|
|
7245
|
+
} catch {
|
|
7246
|
+
return "damaged";
|
|
7247
|
+
}
|
|
7248
|
+
if (owner === null || typeof owner !== "object" || typeof owner.token !== "string" || typeof owner.pid !== "number") {
|
|
7249
|
+
return "damaged";
|
|
7250
|
+
}
|
|
7251
|
+
const done = await exists((0, import_node_path.join)(path, `${owner.token}.done`));
|
|
7252
|
+
const held = await exists((0, import_node_path.join)(path, `${owner.token}.held`));
|
|
7253
|
+
return {
|
|
7254
|
+
slot,
|
|
7255
|
+
token: owner.token,
|
|
7256
|
+
pid: owner.pid,
|
|
7257
|
+
after: typeof owner.after === "string" ? owner.after : null,
|
|
7258
|
+
held,
|
|
7259
|
+
done
|
|
7260
|
+
};
|
|
7261
|
+
}
|
|
7262
|
+
async function isFinishedTurn(directory, slot, token) {
|
|
7263
|
+
const turn = await readTurn(directory, slot);
|
|
7264
|
+
if (turn === "damaged") throw new Error(`Turn ${slot} of the turn lock ${directory} is unreadable.`);
|
|
7265
|
+
return turn !== null && turn.token === token && turn.held && turn.done;
|
|
7266
|
+
}
|
|
7267
|
+
async function takeTurn(profile, directory, latest) {
|
|
7268
|
+
const token = (0, import_node_crypto.randomUUID)();
|
|
7269
|
+
const slot = latest.slot + 1;
|
|
7270
|
+
const path = (0, import_node_path.join)(directory, String(slot));
|
|
7271
|
+
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: latest.token };
|
|
7272
|
+
let published = false;
|
|
7273
|
+
try {
|
|
7274
|
+
published = await publishDirectory(
|
|
7275
|
+
(0, import_node_path.join)(directory, `.stage-${process.pid}-${token}`),
|
|
7276
|
+
path,
|
|
7277
|
+
{ "owner.json": `${JSON.stringify(owner, null, 2)}
|
|
7278
|
+
` },
|
|
7279
|
+
() => waitForTestBarrier(TEST_LOCK_TURN_PUBLISH_BARRIER_DIR, profile)
|
|
7280
|
+
);
|
|
7281
|
+
if (published && await isFinishedTurn(directory, latest.slot, latest.token) && await markTurn(path, token, "held")) {
|
|
7282
|
+
return { directory, slot, token };
|
|
7283
|
+
}
|
|
7284
|
+
if (published) await markTurn(path, token, "done");
|
|
7285
|
+
return null;
|
|
7286
|
+
} catch (error) {
|
|
7287
|
+
if (published) settleOwnTurn(directory, slot, token);
|
|
7288
|
+
throw error;
|
|
7289
|
+
}
|
|
7290
|
+
}
|
|
7291
|
+
async function settleAbandonedTurn(directory, abandoned) {
|
|
7292
|
+
const path = (0, import_node_path.join)(directory, String(abandoned.slot));
|
|
7293
|
+
if (!abandoned.held && abandoned.after !== null && await isFinishedTurn(directory, abandoned.slot - 1, abandoned.after)) {
|
|
7294
|
+
await markTurn(path, abandoned.token, "held");
|
|
7295
|
+
}
|
|
7296
|
+
await markTurn(path, abandoned.token, "done");
|
|
7297
|
+
}
|
|
7298
|
+
function settleOwnTurn(directory, slot, token, retries = SETTLE_RETRIES) {
|
|
7299
|
+
relinquishedTurns.add(token);
|
|
7300
|
+
void (async () => {
|
|
7301
|
+
try {
|
|
7302
|
+
const turn = await readTurn(directory, slot);
|
|
7303
|
+
if (turn !== null && turn !== "damaged" && turn.token === token && !turn.done) await settleAbandonedTurn(directory, turn);
|
|
7304
|
+
relinquishedTurns.delete(token);
|
|
7305
|
+
} catch {
|
|
7306
|
+
if (retries > 0) setTimeout(() => settleOwnTurn(directory, slot, token, retries - 1), SETTLE_RETRY_MS).unref();
|
|
7307
|
+
}
|
|
7308
|
+
})();
|
|
7309
|
+
}
|
|
7310
|
+
async function markTurn(path, token, kind) {
|
|
7311
|
+
for (let attempt = 0; ; attempt++) {
|
|
7312
|
+
try {
|
|
7313
|
+
await (0, import_promises.writeFile)((0, import_node_path.join)(path, `${token}.${kind}`), "", { mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
7314
|
+
return true;
|
|
7315
|
+
} catch (error) {
|
|
7316
|
+
if (isErrno(error, "EEXIST")) return true;
|
|
7317
|
+
if (isErrno(error, "ENOENT")) return false;
|
|
7318
|
+
if (attempt >= MARKER_RETRY_DELAYS_MS.length) throw error;
|
|
7319
|
+
await sleep(MARKER_RETRY_DELAYS_MS[attempt]);
|
|
7320
|
+
}
|
|
7321
|
+
}
|
|
7322
|
+
}
|
|
7323
|
+
async function createTurnDirectory(directory) {
|
|
7324
|
+
const token = (0, import_node_crypto.randomUUID)();
|
|
7325
|
+
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token, after: null };
|
|
7326
|
+
await publishDirectory((0, import_node_path.join)((0, import_node_path.dirname)(directory), `.stage-${(0, import_node_path.basename)(directory)}-${process.pid}-${token}`), directory, {
|
|
7327
|
+
"0/owner.json": `${JSON.stringify(owner, null, 2)}
|
|
7328
|
+
`,
|
|
7329
|
+
[`0/${token}.held`]: "",
|
|
7330
|
+
[`0/${token}.done`]: ""
|
|
7331
|
+
});
|
|
7332
|
+
}
|
|
7333
|
+
async function publishDirectory(stage, target, files, beforeRename) {
|
|
7334
|
+
try {
|
|
7335
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
7336
|
+
await (0, import_promises.mkdir)((0, import_node_path.dirname)((0, import_node_path.join)(stage, name)), { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
7337
|
+
await writeFileDurably((0, import_node_path.join)(stage, name), contents);
|
|
7338
|
+
}
|
|
7339
|
+
await beforeRename?.();
|
|
7340
|
+
await (0, import_promises.rename)(stage, target);
|
|
7341
|
+
return true;
|
|
7342
|
+
} catch (error) {
|
|
7343
|
+
if (["ENOTEMPTY", "EEXIST", "ENOENT"].some((code) => isErrno(error, code)) || await exists(target)) return false;
|
|
7344
|
+
throw error;
|
|
7345
|
+
} finally {
|
|
7346
|
+
await (0, import_promises.rm)(stage, { recursive: true, force: true }).catch(() => void 0);
|
|
7347
|
+
}
|
|
7348
|
+
}
|
|
7349
|
+
async function releaseTurn(profile, turn) {
|
|
7350
|
+
try {
|
|
7351
|
+
await markTurn((0, import_node_path.join)(turn.directory, String(turn.slot)), turn.token, "done");
|
|
7352
|
+
} catch {
|
|
7353
|
+
settleOwnTurn(turn.directory, turn.slot, turn.token);
|
|
7354
|
+
return;
|
|
7355
|
+
}
|
|
7356
|
+
await collectTurns(profile, turn).catch(() => void 0);
|
|
7357
|
+
}
|
|
7358
|
+
function isAbandonedTurnEntry(name) {
|
|
7359
|
+
if (name.startsWith(".trash-")) return true;
|
|
7360
|
+
const writer = /^\.(?:stage|deleted\.json)[-.]([0-9]+)[-.]/.exec(name)?.[1];
|
|
7361
|
+
return writer !== void 0 && !isProcessAlive(Number(writer));
|
|
7362
|
+
}
|
|
7363
|
+
function isAbandonedTurnDirectoryStage(name) {
|
|
7364
|
+
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];
|
|
7365
|
+
return writer !== void 0 && !isProcessAlive(Number(writer));
|
|
7366
|
+
}
|
|
7367
|
+
async function collectTurns(profile, turn) {
|
|
7368
|
+
for (const [directory, abandoned] of [
|
|
7369
|
+
[turn.directory, isAbandonedTurnEntry],
|
|
7370
|
+
[(0, import_node_path.dirname)(turn.directory), isAbandonedTurnDirectoryStage]
|
|
7371
|
+
]) {
|
|
7372
|
+
for (const name of (await (0, import_promises.readdir)(directory)).filter(abandoned)) {
|
|
7373
|
+
await (0, import_promises.rm)((0, import_node_path.join)(directory, name), { recursive: true, force: true }).catch(() => void 0);
|
|
7374
|
+
}
|
|
7375
|
+
}
|
|
7376
|
+
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);
|
|
7377
|
+
for (const slot of older) {
|
|
7378
|
+
const candidate = await readTurn(turn.directory, slot);
|
|
7379
|
+
if (candidate === null || candidate === "damaged" || !candidate.done) continue;
|
|
7380
|
+
if (candidate.held) {
|
|
7381
|
+
if (await exists((0, import_node_path.join)(turn.directory, String(slot - 1)))) continue;
|
|
7382
|
+
const next = await readTurn(turn.directory, slot + 1);
|
|
7383
|
+
if (next === null || next === "damaged" || !next.held || !next.done) continue;
|
|
7384
|
+
}
|
|
7385
|
+
await waitForTestBarrier(TEST_LOCK_TURN_COLLECT_BARRIER_DIR, profile);
|
|
7386
|
+
const trash = (0, import_node_path.join)(turn.directory, `.trash-${(0, import_node_crypto.randomUUID)()}`);
|
|
7387
|
+
if (await (0, import_promises.rename)((0, import_node_path.join)(turn.directory, String(slot)), trash).then(() => true, () => false)) {
|
|
7388
|
+
await (0, import_promises.rm)(trash, { recursive: true, force: true }).catch(() => void 0);
|
|
7097
7389
|
}
|
|
7098
|
-
await sleep(Math.min(retryMs, timeoutMs - elapsedMs));
|
|
7099
7390
|
}
|
|
7100
7391
|
}
|
|
7101
7392
|
async function publishProfileLock(profile, lockPath, token) {
|
|
@@ -7108,12 +7399,18 @@ async function publishProfileLock(profile, lockPath, token) {
|
|
|
7108
7399
|
return false;
|
|
7109
7400
|
}
|
|
7110
7401
|
await waitForTestBarrier(TEST_LOCK_PUBLISH_BARRIER_DIR, profile);
|
|
7402
|
+
const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
|
|
7111
7403
|
const staged = (0, import_node_path.join)((0, import_node_path.dirname)(lockPath), `.lock-owner-${token}.tmp`);
|
|
7112
|
-
const owner = { pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }
|
|
7404
|
+
const owner = `${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), token }, null, 2)}
|
|
7405
|
+
`;
|
|
7113
7406
|
try {
|
|
7114
|
-
await (0, import_promises.writeFile)(staged,
|
|
7115
|
-
|
|
7116
|
-
|
|
7407
|
+
await (0, import_promises.writeFile)(staged, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
7408
|
+
try {
|
|
7409
|
+
await (0, import_promises.link)(staged, ownerPath);
|
|
7410
|
+
} catch (error) {
|
|
7411
|
+
if (!lacksHardLinks(error)) throw error;
|
|
7412
|
+
await (0, import_promises.writeFile)(ownerPath, owner, { encoding: "utf8", mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
7413
|
+
}
|
|
7117
7414
|
return true;
|
|
7118
7415
|
} catch (error) {
|
|
7119
7416
|
if (isErrno(error, "EEXIST") || isErrno(error, "ENOENT")) return false;
|
|
@@ -7123,6 +7420,10 @@ async function publishProfileLock(profile, lockPath, token) {
|
|
|
7123
7420
|
await (0, import_promises.rm)(staged, { force: true }).catch(() => void 0);
|
|
7124
7421
|
}
|
|
7125
7422
|
}
|
|
7423
|
+
var NO_HARD_LINK_ERRORS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EISDIR", "EMLINK"]);
|
|
7424
|
+
function lacksHardLinks(error) {
|
|
7425
|
+
return typeof error === "object" && error !== null && "code" in error && NO_HARD_LINK_ERRORS.has(String(error.code));
|
|
7426
|
+
}
|
|
7126
7427
|
async function releaseProfileLock(profile, lockPath, token) {
|
|
7127
7428
|
const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
|
|
7128
7429
|
const owner = await readJson(ownerPath).catch(() => null);
|
|
@@ -7146,9 +7447,10 @@ async function signalTestLockContention(profile) {
|
|
|
7146
7447
|
`, { encoding: "utf8", flag: "wx" }).catch(() => void 0);
|
|
7147
7448
|
}
|
|
7148
7449
|
async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
7450
|
+
let aged;
|
|
7149
7451
|
try {
|
|
7150
7452
|
const { mtimeMs } = await (0, import_promises.stat)(lockPath);
|
|
7151
|
-
|
|
7453
|
+
aged = Date.now() - mtimeMs >= staleAfterMs;
|
|
7152
7454
|
} catch {
|
|
7153
7455
|
return false;
|
|
7154
7456
|
}
|
|
@@ -7157,7 +7459,16 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
|
7157
7459
|
try {
|
|
7158
7460
|
const entries = await (0, import_promises.readdir)(lockPath);
|
|
7159
7461
|
if (entries.includes("owner.json")) return false;
|
|
7160
|
-
|
|
7462
|
+
const onlyOrphanedClaims = entries.length > 0 && entries.every((entry) => RECOVERY_CLAIM.test(entry));
|
|
7463
|
+
if (!onlyOrphanedClaims && (!aged || recoveryFenced(checkedAt, staleAfterMs))) return false;
|
|
7464
|
+
for (const name of entries.filter((entry) => RECOVERY_CLAIM.test(entry))) {
|
|
7465
|
+
const claimPath = (0, import_node_path.join)(lockPath, name);
|
|
7466
|
+
if (await hasLiveRecoveryOwner(claimPath)) {
|
|
7467
|
+
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"));
|
|
7468
|
+
return false;
|
|
7469
|
+
}
|
|
7470
|
+
await (0, import_promises.rm)(claimPath, { force: true });
|
|
7471
|
+
}
|
|
7161
7472
|
for (const name of entries.filter((entry) => ABANDONED_CLAIM.test(entry))) {
|
|
7162
7473
|
await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
|
|
7163
7474
|
}
|
|
@@ -7168,10 +7479,28 @@ async function recoverOwnerlessLock(profile, lockPath, staleAfterMs) {
|
|
|
7168
7479
|
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
7169
7480
|
return true;
|
|
7170
7481
|
}
|
|
7482
|
+
async function hasLiveRecoveryOwner(claimPath) {
|
|
7483
|
+
let contents;
|
|
7484
|
+
try {
|
|
7485
|
+
contents = await (0, import_promises.readFile)(claimPath, "utf8");
|
|
7486
|
+
} catch (error) {
|
|
7487
|
+
if (isErrno(error, "ENOENT")) return false;
|
|
7488
|
+
throw error;
|
|
7489
|
+
}
|
|
7490
|
+
let owner;
|
|
7491
|
+
try {
|
|
7492
|
+
owner = JSON.parse(contents);
|
|
7493
|
+
} catch {
|
|
7494
|
+
return false;
|
|
7495
|
+
}
|
|
7496
|
+
const pid = typeof owner === "object" && owner !== null && "pid" in owner ? owner.pid : null;
|
|
7497
|
+
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && isProcessAlive(pid);
|
|
7498
|
+
}
|
|
7171
7499
|
function recoveryFenced(since, staleAfterMs) {
|
|
7172
7500
|
return performance.now() - since >= staleAfterMs / 2;
|
|
7173
7501
|
}
|
|
7174
7502
|
var ABANDONED_CLAIM = /^\.(?:release|stale)-[0-9a-f-]+\.json$/;
|
|
7503
|
+
var RECOVERY_CLAIM = /^\.recover-[0-9a-f-]+\.json$/;
|
|
7175
7504
|
var STAGED_OWNER = /^\.lock-owner-[0-9a-f-]+\.tmp$/;
|
|
7176
7505
|
async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
|
|
7177
7506
|
const directory = (0, import_node_path.dirname)(lockPath);
|
|
@@ -7184,37 +7513,57 @@ async function removeAgedOwnerFiles(lockPath, staleAfterMs) {
|
|
|
7184
7513
|
}
|
|
7185
7514
|
async function recoverStaleLock(profile, lockPath, staleAfterMs) {
|
|
7186
7515
|
const ownerPath = (0, import_node_path.join)(lockPath, "owner.json");
|
|
7187
|
-
const
|
|
7188
|
-
if (
|
|
7189
|
-
if (!
|
|
7190
|
-
const observedToken = typeof owner?.token === "string" && owner.token.length > 0 ? owner.token : "legacy";
|
|
7516
|
+
const observed = await (0, import_promises.readFile)(ownerPath, "utf8").catch(() => null);
|
|
7517
|
+
if (observed === null) return recoverOwnerlessLock(profile, lockPath, staleAfterMs);
|
|
7518
|
+
if (!await isAbandonedOwner(observed, ownerPath, staleAfterMs)) return false;
|
|
7191
7519
|
await waitForTestBarrier(TEST_LOCK_RECOVERY_BARRIER_DIR, profile);
|
|
7192
|
-
const claimPath = (0, import_node_path.join)(lockPath, `.
|
|
7520
|
+
const claimPath = (0, import_node_path.join)(lockPath, `.recover-${(0, import_node_crypto.randomUUID)()}.json`);
|
|
7521
|
+
let moved = false;
|
|
7193
7522
|
try {
|
|
7194
7523
|
await (0, import_promises.link)(ownerPath, claimPath);
|
|
7195
|
-
} catch {
|
|
7196
|
-
return false;
|
|
7524
|
+
} catch (error) {
|
|
7525
|
+
if (!lacksHardLinks(error)) return false;
|
|
7526
|
+
try {
|
|
7527
|
+
await (0, import_promises.rename)(ownerPath, claimPath);
|
|
7528
|
+
moved = true;
|
|
7529
|
+
} catch {
|
|
7530
|
+
return false;
|
|
7531
|
+
}
|
|
7197
7532
|
}
|
|
7198
|
-
|
|
7199
|
-
await
|
|
7200
|
-
const claimed = await readJson(claimPath).catch(() => null);
|
|
7201
|
-
const sameInstance = claimed !== null && (observedToken === "legacy" ? claimed.token === void 0 : claimed.token === observedToken);
|
|
7533
|
+
if (moved) await waitForTestBarrier(TEST_LOCK_MOVED_BARRIER_DIR, profile);
|
|
7534
|
+
const sameInstance = await (0, import_promises.readFile)(claimPath, "utf8").then((claimed) => claimed === observed, () => false);
|
|
7202
7535
|
await waitForTestBarrier(TEST_LOCK_VERIFIED_BARRIER_DIR, profile);
|
|
7203
|
-
if (sameInstance
|
|
7204
|
-
|
|
7205
|
-
|
|
7536
|
+
if (!sameInstance) {
|
|
7537
|
+
if (moved) {
|
|
7538
|
+
await (0, import_promises.rename)(claimPath, ownerPath).catch(() => void 0);
|
|
7539
|
+
} else {
|
|
7540
|
+
await (0, import_promises.unlink)(claimPath).catch(() => void 0);
|
|
7541
|
+
}
|
|
7206
7542
|
return false;
|
|
7207
7543
|
}
|
|
7208
|
-
if (
|
|
7209
|
-
|
|
7210
|
-
|
|
7544
|
+
if (!moved) await (0, import_promises.rm)(ownerPath, { force: true });
|
|
7545
|
+
await waitForTestBarrier(TEST_LOCK_CLAIM_BARRIER_DIR, profile);
|
|
7546
|
+
await waitForTestBarrier(TEST_LOCK_CLAIMED_BARRIER_DIR, profile);
|
|
7547
|
+
for (const name of (await (0, import_promises.readdir)(lockPath).catch(() => [])).filter((entry) => RECOVERY_CLAIM.test(entry))) {
|
|
7548
|
+
if ((0, import_node_path.join)(lockPath, name) !== claimPath) await (0, import_promises.rm)((0, import_node_path.join)(lockPath, name), { force: true });
|
|
7211
7549
|
}
|
|
7212
|
-
await (0, import_promises.
|
|
7213
|
-
await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
|
|
7214
|
-
if (!sameInstance) return false;
|
|
7550
|
+
if (await (0, import_promises.unlink)(claimPath).then(() => true, () => false)) await (0, import_promises.rmdir)(lockPath).catch(() => void 0);
|
|
7215
7551
|
await removeAgedOwnerFiles(lockPath, staleAfterMs);
|
|
7216
7552
|
return true;
|
|
7217
7553
|
}
|
|
7554
|
+
async function isAbandonedOwner(observed, ownerPath, staleAfterMs) {
|
|
7555
|
+
let owner;
|
|
7556
|
+
try {
|
|
7557
|
+
owner = JSON.parse(observed);
|
|
7558
|
+
} catch {
|
|
7559
|
+
owner = null;
|
|
7560
|
+
}
|
|
7561
|
+
if (typeof owner === "object" && owner !== null && "pid" in owner) {
|
|
7562
|
+
return isStaleOwner(owner, staleAfterMs);
|
|
7563
|
+
}
|
|
7564
|
+
const modifiedAt = await (0, import_promises.stat)(ownerPath).then(({ mtimeMs }) => mtimeMs, () => Number.NaN);
|
|
7565
|
+
return Date.now() - modifiedAt >= staleAfterMs;
|
|
7566
|
+
}
|
|
7218
7567
|
async function waitForTestBarrier(environmentName, profile) {
|
|
7219
7568
|
if (process.env.NODE_ENV !== "test") return;
|
|
7220
7569
|
const barrierDirectory = process.env[environmentName];
|
|
@@ -7241,6 +7590,15 @@ function isStaleOwner(owner, staleAfterMs) {
|
|
|
7241
7590
|
}
|
|
7242
7591
|
return !isProcessAlive(owner.pid);
|
|
7243
7592
|
}
|
|
7593
|
+
async function exists(path) {
|
|
7594
|
+
try {
|
|
7595
|
+
await (0, import_promises.stat)(path);
|
|
7596
|
+
return true;
|
|
7597
|
+
} catch (error) {
|
|
7598
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) return false;
|
|
7599
|
+
throw error;
|
|
7600
|
+
}
|
|
7601
|
+
}
|
|
7244
7602
|
function isProcessAlive(pid) {
|
|
7245
7603
|
try {
|
|
7246
7604
|
process.kill(pid, 0);
|