dotmd-cli 0.70.3 → 0.70.4
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/package.json +1 -1
- package/src/atomic-mutation.mjs +11 -6
- package/src/config-edit.mjs +6 -3
- package/src/durable-rename.mjs +55 -0
- package/src/git.mjs +4 -1
package/package.json
CHANGED
package/src/atomic-mutation.mjs
CHANGED
|
@@ -23,10 +23,15 @@ import os from 'node:os';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { captureGitIndexGeneration, reclaimPreparedGitIndex, restoreGitIndexCas, sameGitIndexGeneration, stageMovePathsCas } from './git.mjs';
|
|
25
25
|
import { authorizeManagedDestination, authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
|
|
26
|
+
import { commitRename } from './durable-rename.mjs';
|
|
26
27
|
|
|
27
28
|
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
28
29
|
let tempSequence = 0;
|
|
29
30
|
|
|
31
|
+
// How long a peer waits to acquire a path lock before MutationLockError. Any
|
|
32
|
+
// retry budget spent while the lock is held has to fit well inside this.
|
|
33
|
+
export const MUTATION_LOCK_TIMEOUT_MS = 2000;
|
|
34
|
+
|
|
30
35
|
export function processStartIdentity(pid) {
|
|
31
36
|
try {
|
|
32
37
|
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
@@ -111,7 +116,7 @@ function transactionRoot(repoRoot, options = {}) {
|
|
|
111
116
|
function durableJson(filePath, value, options = {}) {
|
|
112
117
|
const content = JSON.stringify(value, null, 2) + '\n';
|
|
113
118
|
const temp = writeCompleteTemp(filePath, content, 0o600);
|
|
114
|
-
|
|
119
|
+
commitRename(temp.path, filePath, options.testHooks);
|
|
115
120
|
fsyncDirectory(path.dirname(filePath), options, 'transaction-manifest');
|
|
116
121
|
}
|
|
117
122
|
|
|
@@ -841,7 +846,7 @@ function reclaimLock(lockPath, lockRoot) {
|
|
|
841
846
|
}
|
|
842
847
|
|
|
843
848
|
export function withPathLocks(filePaths, options, callback) {
|
|
844
|
-
const { repoRoot, timeoutMs =
|
|
849
|
+
const { repoRoot, timeoutMs = MUTATION_LOCK_TIMEOUT_MS, retryMs = 20 } = options;
|
|
845
850
|
if (!repoRoot) throw new Error('withPathLocks requires repoRoot.');
|
|
846
851
|
const canonicals = [...new Set(filePaths.map(canonicalPath))].sort();
|
|
847
852
|
const lockRoot = safeGeneratedPath(path.join(path.resolve(repoRoot), '.dotmd', 'locks'), repoRoot, options, 'Lock root');
|
|
@@ -1036,7 +1041,7 @@ export function replaceSnapshot(snapshot, newContent, options = {}) {
|
|
|
1036
1041
|
try {
|
|
1037
1042
|
options.testHooks?.beforeReplacePublish?.({ snapshot, tempPath: prepared.path });
|
|
1038
1043
|
assertSnapshotCurrent(snapshot);
|
|
1039
|
-
|
|
1044
|
+
commitRename(prepared.path, snapshot.path, options.testHooks);
|
|
1040
1045
|
committed = committedGeneration(prepared, snapshot.path);
|
|
1041
1046
|
options.testHooks?.afterPublicationBeforePathOpen?.('replace', snapshot.path);
|
|
1042
1047
|
try { fsyncDirectory(path.dirname(snapshot.path), options, 'replace-publish'); }
|
|
@@ -1097,7 +1102,7 @@ export function createFileExclusive(filePath, content, options) {
|
|
|
1097
1102
|
options.testHooks?.afterReservationAcquired?.(filePath);
|
|
1098
1103
|
options.testHooks?.afterCreateReservation?.(filePath);
|
|
1099
1104
|
assertSnapshotCurrent(reservation);
|
|
1100
|
-
|
|
1105
|
+
commitRename(prepared.path, filePath, options.testHooks);
|
|
1101
1106
|
tempPublished = true;
|
|
1102
1107
|
committed = committedGeneration(prepared, filePath);
|
|
1103
1108
|
options.testHooks?.afterPublicationBeforePathOpen?.('create-rename', filePath);
|
|
@@ -1210,7 +1215,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
|
1210
1215
|
// step that can permit an injected or external source edit.
|
|
1211
1216
|
assertSnapshotCurrent(source);
|
|
1212
1217
|
for (const item of preparedUpdates) assertSnapshotCurrent(item.snapshot);
|
|
1213
|
-
|
|
1218
|
+
commitRename(sourcePath, backup, testHooks);
|
|
1214
1219
|
moved = true;
|
|
1215
1220
|
backupSnapshot = { ...source, path: backup, identityKind: 'inode-content' };
|
|
1216
1221
|
fsyncDirectory(path.dirname(sourcePath));
|
|
@@ -1218,7 +1223,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
|
1218
1223
|
|
|
1219
1224
|
testHooks?.afterSourceMove?.({ backup, sourcePath, targetPath });
|
|
1220
1225
|
assertSnapshotCurrent(reservation);
|
|
1221
|
-
|
|
1226
|
+
commitRename(stagedPath, targetPath, testHooks);
|
|
1222
1227
|
published = true;
|
|
1223
1228
|
reserved = false;
|
|
1224
1229
|
publishedSnapshot = committedGeneration(prepared, targetPath);
|
package/src/config-edit.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// 2. fs.writeFileSync(<path>.tmp, new)
|
|
8
8
|
// 3. import the tmp via file:// + cache-bust query — must parse
|
|
9
9
|
// 4. resolveConfig(tmp) — must not surface new warnings
|
|
10
|
-
// 5.
|
|
10
|
+
// 5. commitRename(tmp, real) only on full success
|
|
11
11
|
// 6. on any failure: unlink tmp, surface error, real file untouched
|
|
12
12
|
//
|
|
13
13
|
// Pulling in @babel/parser would force a heavy dep on a project with two
|
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
// `templates` section is 264 lines of arrow functions with multi-line
|
|
16
16
|
// template literals). Line surgery is the conservative choice.
|
|
17
17
|
|
|
18
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
19
19
|
import { pathToFileURL } from 'node:url';
|
|
20
20
|
import { resolveConfig } from './config.mjs';
|
|
21
|
+
import { commitRename } from './durable-rename.mjs';
|
|
21
22
|
|
|
22
23
|
const STATUS_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
23
24
|
const RESERVED_NAMES = new Set([
|
|
@@ -595,7 +596,9 @@ export async function writeConfigAtomic(configPath, newContent, cwd) {
|
|
|
595
596
|
throw new ConfigEditError(`Generated config surfaces new warnings:\n - ${novel.join('\n - ')}`);
|
|
596
597
|
}
|
|
597
598
|
|
|
598
|
-
|
|
599
|
+
// The live config is very likely open in the user's editor; on Windows that
|
|
600
|
+
// makes the rename fail transiently, so publish through the retrying helper.
|
|
601
|
+
commitRename(tmpPath, configPath);
|
|
599
602
|
} catch (err) {
|
|
600
603
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
601
604
|
throw err;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Retrying rename for publishing a prepared temp over a live path.
|
|
2
|
+
//
|
|
3
|
+
// Lives in its own leaf module rather than in atomic-mutation.mjs because
|
|
4
|
+
// atomic-mutation.mjs already imports git.mjs, and git.mjs needs this helper for
|
|
5
|
+
// its .git/index publish — exporting it from atomic-mutation.mjs would close an
|
|
6
|
+
// import cycle.
|
|
7
|
+
import { renameSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
10
|
+
|
|
11
|
+
const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES'];
|
|
12
|
+
const RENAME_RETRY_ATTEMPTS = 10;
|
|
13
|
+
const RENAME_RETRY_BACKOFF_MS = 10;
|
|
14
|
+
|
|
15
|
+
// Total the backoff can sleep across a fully exhausted retry — the sum of
|
|
16
|
+
// RENAME_RETRY_BACKOFF_MS × (1 … attempts-1). Exported so the budget can be
|
|
17
|
+
// checked against MUTATION_LOCK_TIMEOUT_MS deterministically, rather than by
|
|
18
|
+
// timing a run on a shared CI runner.
|
|
19
|
+
export const RENAME_RETRY_SLEEP_BUDGET_MS =
|
|
20
|
+
(RENAME_RETRY_BACKOFF_MS * (RENAME_RETRY_ATTEMPTS - 1) * RENAME_RETRY_ATTEMPTS) / 2;
|
|
21
|
+
|
|
22
|
+
// Windows refuses to rename onto — or away from — a path another process holds
|
|
23
|
+
// open, surfacing EPERM/EBUSY/EACCES. That holder is by construction a
|
|
24
|
+
// non-cooperating one (editor, AV, Search Indexer, `git`): dotmd's path lock is
|
|
25
|
+
// advisory and only excludes other dotmd processes, so a retry here is never
|
|
26
|
+
// waiting on a peer we would have serialized against anyway. POSIX never fails a
|
|
27
|
+
// rename this way, so retrying there would mask a genuinely different fault.
|
|
28
|
+
//
|
|
29
|
+
// The budget is bounded by withPathLocks' timeoutMs (2000ms default): these
|
|
30
|
+
// renames run while the lock is held, so a longer budget would trade a rare
|
|
31
|
+
// transient EPERM for common peer MutationLockErrors. 10 attempts with a 10ms
|
|
32
|
+
// linear backoff is ~450ms of sleep — comfortable headroom under 2s. Do not
|
|
33
|
+
// raise it past ~1s without also raising timeoutMs.
|
|
34
|
+
export function commitRename(from, to, testHooks) {
|
|
35
|
+
// Single seam for the platform check so the retry is reachable in tests on the
|
|
36
|
+
// POSIX machines that actually run them.
|
|
37
|
+
const windowsRenameSemantics = testHooks?.forceWindowsRenameSemantics ?? process.platform === 'win32';
|
|
38
|
+
for (let attempt = 0; ; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
const injected = testHooks?.forceRenameError?.(attempt);
|
|
41
|
+
if (injected) {
|
|
42
|
+
const error = new Error(`injected rename ${injected}`);
|
|
43
|
+
error.code = injected;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
renameSync(from, to);
|
|
47
|
+
return;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (!windowsRenameSemantics) throw err;
|
|
50
|
+
if (!RENAME_RETRY_CODES.includes(err?.code)) throw err;
|
|
51
|
+
if (attempt >= RENAME_RETRY_ATTEMPTS - 1) throw err;
|
|
52
|
+
Atomics.wait(sleepBuffer, 0, 0, RENAME_RETRY_BACKOFF_MS * (attempt + 1));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/git.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { commitRename } from './durable-rename.mjs';
|
|
5
6
|
|
|
6
7
|
// Best-effort `git check-ignore` for a path. Returns true only when git
|
|
7
8
|
// definitively reports the path is ignored; any failure (not a repo, git
|
|
@@ -675,7 +676,9 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
|
|
|
675
676
|
if (!sameGitIndexGeneration(current, expected)) throw new Error('Git index changed before transaction publication; current staging was preserved.');
|
|
676
677
|
testHooks.afterGitIndexCompare?.({ lockPath, current, desired });
|
|
677
678
|
if (desired.exists) {
|
|
678
|
-
|
|
679
|
+
// .git/index is routinely held open by concurrent git processes and IDE git
|
|
680
|
+
// extensions, so on Windows this publish needs the retry.
|
|
681
|
+
commitRename(lockPath, indexPath, testHooks);
|
|
679
682
|
lockOwned = false;
|
|
680
683
|
} else {
|
|
681
684
|
if (existsSync(indexPath)) unlinkSync(indexPath);
|