無人で走らせている運転の二重起動を防ぐために、起動の最初に fs.openSync(lock, "wx") でロックファイルを作っています。"wx" は『無ければ作る。既にあれば失敗する』という1手で、既にあったときは EEXIST が返る、というのが前提でした。
その前提で、ロックを取る関数は EEXIST だけを『ほかの誰かが持っている』と読み、それ以外の失敗は例外として投げる形にしていました。
function createIfAbsent(file, text) {
let fd;
try {
fd = fs.openSync(file, "wx");
} catch (e) {
if (e.code === "EEXIST") return false;
throw e;
}
ところが、3つのプロセスを同じ瞬間に走らせるテストを足したところ、テストを全部まとめて流す全数テストを6回流して2回、子の1つが例外で終わりました(そのテスト単独では8回とも通っていました)。テストの補助スクリプトが例外の code を捨てていたので、その2回が何の例外だったかは記録に残っていません。
代わりに、ロックの関数一式を3つのプロセスから同時に呼ぶ計測を別に流しました。単独で20回、その計測を3本並べて負荷をかけた形で各20回、合わせて80回のうち1回、EPERM(syscall は open)が出ました。出たのは3本並べた側で、単独の20回では0回です。この1件が wx と readFileSync のどちらで出たのかは分けていません(どちらも syscall は open です)。この数は計測の出力から手で写したものです。
この記事は、EPERM がどの形で出るのかを、作り物で切り分けて数えた結果です。直し方はまだ入れていません(最後に書きます)。
1. 測った形
子プロセスを3つ、同じ時刻の合図で走り出させ、決めた時間(1500ミリ秒)だけ、一時フォルダの中の同じ1ファイルに同じ手を繰り返させました。
| 形 | 子3つがやること |
|---|---|
| keep | 親が最初にファイルを1つ作り、**誰も消さない。**子は openSync(file, "wx") を繰り返すだけ |
| churn | 子は wx で作れたら閉じてすぐ消す(作る・閉じる・消す の繰り返し) |
| reader | 子0は作る・閉じる・消す を繰り返し、子1と子2は readFileSync で読み続ける
|
例外は『どの手で(open / read / unlink)・どの code・どの syscall』で数えました。EEXIST と ENOENT は想定どおりの失敗として、別に数えています。
2. 結果(2回流した)
| 回 | 形 | open 成功 | open EEXIST | open EPERM | read 成功 | read ENOENT | read EPERM | unlink 成功 | 想定外の例外 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | keep | 0 | 21,819 | 0 | — | — | — | — | 0 |
| 1 | churn | 7,350 | 5,500 | 657 | — | — | — | 7,350 | 657 |
| 1 | reader | 5,974 | 0 | 0 | 18,034 | 34,544 | 2,858 | 5,974 | 2,858 |
| 2 | keep | 0 | 22,375 | 0 | — | — | — | — | 0 |
| 2 | churn | 7,313 | 5,545 | 669 | — | — | — | 7,313 | 669 |
| 2 | reader | 5,903 | 0 | 0 | 17,696 | 35,654 | 2,951 | 5,903 | 2,951 |
- 誰も消さない keep では、2回とも
EEXIST以外の例外は0回でした -
作って消す churn では、
openSync(file, "wx")がEEXISTではなくEPERMを返しました(2回とも)。unlinkSyncは2回とも1回も失敗していません - **reader では、読むだけの
readFileSyncもEPERMを返しました。**ファイルが無いときのENOENTとは別にです - どちらの
EPERMも、例外のsyscallはopenでした
つまり、この3形のうち EPERM が出たのは誰かがファイルを消している形だけで、出たのは作る側(wx)と読む側の両方でした。
私のロックの関数は、古いロックの持ち主が死んでいるときに消してから作り直す手を持っています。その手が走ると、ほかのプロセスの wx や readFileSync に EPERM が返りうる、ということになります。EEXIST だけを『誰かが持っている』と読む関数は、それを例外として投げていました。同じ一式の中でロックを読む関数も、ENOENT(無い)以外は投げる形です。上の表では、読む側の EPERM のほうが wx の側より多く出ています。
3. 再現スクリプト(全文)
依存はありません。読み書きするのは一時フォルダの中だけで、最後に消します。通信はしません。
// repro-wx-eperm.mjs — Windows で openSync(file, "wx") を複数プロセスが同時に回すと、EEXIST 以外に何が返るかを数える
// 依存0。読み書きするのは一時フォルダの中だけ(最後に消す)。通信しない。パスも時刻も出さない。
// 使い方: node repro-wx-eperm.mjs [各形のミリ秒=1500] [--json]
//
// 形は3通り。子は3つ、同じ時刻の合図で走り出し、決めた時間だけ同じ1ファイルに同じ手を繰り返す:
// keep : ファイルは親が最初に1つ作り、誰も消さない。子は wx で作ろうとし続けるだけ
// churn : 子は wx で作れたら閉じてすぐ消す(作る・閉じる・消す の繰り返し)
// reader : 子0は作る・閉じる・消す を繰り返し、子1と子2は readFileSync で読み続ける
// 例外は『どの手で(open/read/unlink)・どの code・どの syscall』で数える。EEXIST と ENOENT は想定どおりの失敗として別に数える。
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const SELF = fileURLToPath(import.meta.url);
const KINDS = ["keep", "churn", "reader"];
function key(step, e) {
return `${step}:${e.code || "?"}/${e.syscall || "?"}`;
}
if (process.argv[2] === "child") {
const [, , , kind, role, file, startAt, ms] = process.argv;
const tally = {};
const add = (k) => { tally[k] = (tally[k] || 0) + 1; };
while (Date.now() < Number(startAt)) {}
const until = Number(startAt) + Number(ms);
const writer = kind !== "reader" || role === "0";
while (Date.now() < until) {
if (writer) {
let fd;
try { fd = fs.openSync(file, "wx"); add("open:ok"); }
catch (e) { add(key("open", e)); continue; }
fs.closeSync(fd);
if (kind === "keep") continue;
try { fs.unlinkSync(file); add("unlink:ok"); } catch (e) { add(key("unlink", e)); }
} else {
try { fs.readFileSync(file); add("read:ok"); } catch (e) { add(key("read", e)); }
}
}
process.stdout.write(JSON.stringify(tally) + "\n");
process.exit(0);
}
function run(kind, dir, ms) {
const file = path.join(dir, "autorun.lock");
if (kind === "keep") fs.writeFileSync(file, "held");
const startAt = Date.now() + 700;
return Promise.all([0, 1, 2].map((role) => new Promise((resolve) => {
const c = spawn(process.execPath, [SELF, "child", kind, String(role), file, String(startAt), String(ms)], { stdio: ["ignore", "pipe", "ignore"] });
let out = "";
c.stdout.on("data", (b) => { out += b; });
c.on("close", () => { try { resolve(JSON.parse(out.trim())); } catch { resolve({ "child:no-output": 1 }); } });
})));
}
const ms = Number(process.argv.find((a) => /^\d+$/.test(a)) || 1500);
const json = process.argv.includes("--json");
const EXPECTED = new Set(["EEXIST", "ENOENT"]);
const result = { node: process.version, platform: process.platform, msPerKind: ms, children: 3, rows: [] };
for (const kind of KINDS) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wx-eperm-"));
try {
const tallies = await run(kind, dir, ms);
const total = {};
for (const t of tallies) for (const [k, v] of Object.entries(t)) total[k] = (total[k] || 0) + v;
const unexpected = Object.entries(total)
.filter(([k]) => !k.endsWith(":ok") && !EXPECTED.has(k.split(":")[1].split("/")[0]))
.reduce((n, [, v]) => n + v, 0);
result.rows.push({ kind, total: Object.fromEntries(Object.entries(total).sort()), unexpected });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
if (json) console.log(JSON.stringify(result, null, 2));
else {
console.log(`Node ${result.node} / ${result.platform} / 子3つ・各形 ${ms}ミリ秒`);
for (const r of result.rows) console.log(`${r.kind}: 想定外の例外 ${r.unexpected} ${JSON.stringify(r.total)}`);
}
node repro-wx-eperm.mjs で形ごとに1行(数え上げを JSON の形で並べたもの)が、--json で全体が出ます。
4. 測っていないこと
- **なぜ
EPERMになるのかは確かめていません。**Windows では、ほかのプロセスが消している最中のファイルを開くと『削除待ち』の状態に当たる、という説明を見かけますが、私が測ったのは『消す手がある形にだけ出た』ところまでです - 測ったのは Windows 11・Node v24.18.1 の1台だけです。Linux や macOS では走らせていません
- 子は3つ・各形1500ミリ秒・2回だけです。回数は1回ごとに変わります(上の表の2回のとおり)。題の範囲は、この2回の値です
- 子は起動から700ミリ秒後の合図で走り出します。起動がそれより遅れた子は、そのぶん繰り返す時間が短くなります。子ごとの実際の時間は測っていません
- **
EPERMを『誰かが持っている』として扱えば直るかは、まだ試していません。**数十ミリ秒待って取り直す形も含めて、直し方はこれから入れます -
EPERMのあいだに、2つのプロセスが同時にwxを成功させた形は、このスクリプトでは数えていません(数えたのは例外の種類だけです)
5. やめたこと
-
wxの失敗をEEXISTか否かの2択で読むこと。少なくともこの PC では、消す手が混ざると3つ目の答え(EPERM)が返ります - 同時に走らせるテストを、1回流して緑だったことで済ませること。全数テストを6回流すと、2回は子の1つが例外で終わりました(何の例外だったかは記録していません。これも直す側に入れます)
無人運転の構成で同じような落とし穴が無いかを読むだけで調べる道具を、無料で置いています: https://h26005.tailf680df.ts.net:10000/free.html