ファイルの条件付き削除は rename で場所を押さえてから
2026.09.04 09:00
2026.08.30 00:36
「ファイルの内容が想定どおりなら削除する」という処理を書きました。読み込んで、値を照合して、一致したら消す。最初はそれで十分に見えます。
ただ、同じファイルを別プロセスも更新する環境では、確認してから削除するまでの一瞬に内容が入れ替わります。古い内容を確認したのに、新しく置かれたファイルを消してしまう。いわゆるTOCTOU競合です。
const current = await readFile(path, 'utf8');
if (current.revision !== expected) return;
await unlink(path);
照合後に別プロセスが同じパスへ新しい内容を書けば、確認したのは古い内容なのに、新しい内容を削除してしまいます。
renameで先に占有する
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
await rename(path, temp);
const current = JSON.parse(await readFile(temp, 'utf8'));
if (current.revision === expected) {
await unlink(temp);
} else {
await restore(temp, path);
}
同一ファイルシステム内の一意なパスへrenameした時点で、対象を原子的に確保できます。その後に元パスへ新しいファイルが作られても、こちらが操作するのはtempだけです。
復元にはlinkを使う
tempを元のパスへrenameすると、新しいファイルを上書きする可能性があります。link() が既存パスに対して EEXIST になる性質を使い、非上書きで戻します。
async function restore(temp, path) {
try {
await link(temp, path);
await unlink(temp);
} catch (e) {
if (e.code === 'EEXIST') {
await unlink(temp);
return;
}
throw e;
}
}
競合テストでは照合後のフックから元パスへ新候補を書き込み、その候補が残ることをassertします。
check → use 危険
occupy → check → use 安全
この処理で大事だったのは、「確認してから実行する」をそのまま安全だと思わないことでした。先にrenameで対象を押さえ、押さえたファイルだけを確認して使う。戻すときも、新しい候補を上書きしないところまで考えて、初めて一連の対策になります。
ファイル操作に限らず、確認と実行が別のタイミングになる処理には誰かが割り込めます。check → use を見かけたら、先に占有できないか考える。この見方を持っているだけでも、競合の見落としは減りました。
今回は以上です。