This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync, readFileSync, writeFileSync, mkdirSync, openSync, closeSync, renameSync, statSync,
|
||||
constants, copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, statSync, unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { join, dirname, extname } from 'node:path';
|
||||
|
||||
@@ -16,8 +16,8 @@ import { join, dirname, extname } from 'node:path';
|
||||
* read-modify-write 自体は同期実行(better-sqlite3 / tools は単一スレッド同期)なので、
|
||||
* 一回の呼び出し内では中断されない。temp+rename により、別エージェントの並行書込で
|
||||
* 半端な JSON を観測したり last-writer-wins でキーを取りこぼす窓を除去する。
|
||||
* - 競合コピー名は `openSync(path, 'wx')`(O_EXCL)で予約してから書く。`while(existsSync)`
|
||||
* スキャンの TOCTOU を排除し、二者が同じ base を同時解決しても別名を得る。
|
||||
* - 旧版退避先は `copyFileSync(..., COPYFILE_EXCL)` で確保してから元ファイルを消す。
|
||||
* これにより、二者が同じ base を同時解決しても old/ 内の既存ファイルを上書きしない。
|
||||
*
|
||||
* 残存制限(spec §10.3): per-file の書込ロックは持たない。同一成果物への真の同時上書きは
|
||||
* last-writer-wins のまま。台帳のアトミック性とは別レイヤーの課題。
|
||||
@@ -66,34 +66,39 @@ export function recordVersion(ws: string, relPath: string, conflictDir: string):
|
||||
}
|
||||
|
||||
export interface WriteResolution {
|
||||
/** 実際に書き込むべき相対パス(競合時は競合コピー名) */
|
||||
/** 実際に書き込むべき相対パス */
|
||||
targetRelPath: string;
|
||||
/** 競合時に退避した旧ファイルの相対パス */
|
||||
archivedRelPath?: string;
|
||||
conflict: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 競合コピー名を O_EXCL で予約する。`{stem} (競合コピー N){ext}` を N=1 から順に試し、
|
||||
* 排他作成に成功した最初の名前を返す。空ファイルを予約として作るので、呼び出し側は
|
||||
* その名前へ後から書き込む(盲目上書きの窓が無い)。
|
||||
* 既存ファイルを同じ階層の old/ へ退避する。退避名は `{stem}_oldN{ext}`。
|
||||
* COPYFILE_EXCL により、既存の退避ファイルを上書きしない。
|
||||
*/
|
||||
function reserveCompetingCopy(ws: string, relPath: string): string {
|
||||
function archiveExistingFile(ws: string, relPath: string): string {
|
||||
const ext = extname(relPath);
|
||||
const stem = relPath.slice(0, relPath.length - ext.length);
|
||||
const baseName = relPath.slice(relPath.lastIndexOf('/') + 1);
|
||||
const stem = baseName.slice(0, baseName.length - ext.length);
|
||||
const parent = dirname(relPath);
|
||||
const oldDir = parent === '.' ? 'old' : join(parent, 'old');
|
||||
const sourceAbs = join(ws, relPath);
|
||||
// 同一プロセス内の暴走に対する上限(実用上は到達しない)。
|
||||
for (let n = 1; n < 100000; n++) {
|
||||
const candidate = `${stem} (競合コピー ${n})${ext}`;
|
||||
const candidate = join(oldDir, `${stem}_old${n}${ext}`);
|
||||
const abs = join(ws, candidate);
|
||||
try {
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
const fd = openSync(abs, 'wx'); // O_EXCL: 既存なら EEXIST
|
||||
closeSync(fd);
|
||||
copyFileSync(sourceAbs, abs, constants.COPYFILE_EXCL);
|
||||
unlinkSync(sourceAbs);
|
||||
return candidate;
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw new Error('reserveCompetingCopy: exhausted candidate names');
|
||||
throw new Error('archiveExistingFile: exhausted candidate names');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +106,7 @@ function reserveCompetingCopy(ws: string, relPath: string): string {
|
||||
* - 対象が存在しない → そのまま書く(競合なし)。
|
||||
* - 対象が存在し、ディスク版が台帳の記録と一致 → 普通に上書き(競合なし)。
|
||||
* - 対象が存在するが台帳に記録が無い、または記録と食い違う → 競合。
|
||||
* O_EXCL で予約した `{name} (競合コピー N).{ext}` を返し、元ファイルは触らない。
|
||||
* 既存ファイルを同じ階層の `old/{name}_oldN.{ext}` へ退避し、元パスへ新しい内容を書く。
|
||||
*/
|
||||
export function resolveWriteTarget(
|
||||
ws: string,
|
||||
@@ -115,7 +120,6 @@ export function resolveWriteTarget(
|
||||
const base = led[relPath];
|
||||
const diskHash = hash(readFileSync(abs));
|
||||
if (base && base === diskHash) return { targetRelPath: relPath, conflict: false };
|
||||
// 競合: O_EXCL で空いている連番のコピー名を予約する(TOCTOU 排除)。
|
||||
const candidate = reserveCompetingCopy(ws, relPath);
|
||||
return { targetRelPath: candidate, conflict: true };
|
||||
const archivedRelPath = archiveExistingFile(ws, relPath);
|
||||
return { targetRelPath: relPath, archivedRelPath, conflict: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user