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