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