定期実行している確認用のスクリプトが、表示も処理もすべて正しく終わったあとに、失敗の終了コードを返していました。
Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 94
最後の1行が process.exit(0) で、その前に同じサーバへ fetch を何回か投げていました。この行は、30分ごとに走らせている稼働確認のログに2回残っています(2026-08-17 と 2026-08-19)。2回とも、確認の結果を表示し終えたあとに出ています。
今回、何回 fetch したときに落ちるのかを、依存のない再現スクリプトで数え直しました。
測った形
127.0.0.1 に使い捨てのサーバを立て、そこへ fetch を N 回投げて本文を読み切り、done と出力してから終わります。終わり方は2通りです。
-
process.exit(0)で終わる -
process.exitCode = 0を置き、サーバを閉じて、関数が終わるのを待つ
それぞれを子プロセスとして20回ずつ起動し、子の終了コードを数えました。このスクリプトを5回流しています。測ったのは Windows 11 / Node v24.18.1 の1台だけです。
結果
1回目に流した結果です。
| fetch の回数 | process.exit(0) | process.exitCode = 0 |
|---|---|---|
| 0 | 0 が 20回 | 0 が 20回 |
| 1 | 0 が 20回 | 0 が 20回 |
| 2 | 0 が 20回 | 0 が 20回 |
| 3 | 3221226505 が 20回 | 0 が 20回 |
| 4 | 3221226505 が 20回 | 0 が 20回 |
| 5 | 0 が 18回 / 3221226505 が 2回 | 0 が 20回 |
| 8 | 0 が 20回 | 0 が 20回 |
- 落ちた回も含めて、280回すべてで子は
doneを出力していました。仕事は終わっています。落ちているのは、そのあとです - 再現スクリプトは、欄ごとに最初に落ちた1回の標準エラーだけを残しています。残した3行は、どれも上の
Assertion failedの行でした(他の落ちた回の標準エラーは見ていません) -
process.exitCode = 0で終わる形は、7通りの回数で合わせて140回、すべて 0 でした - **5回の欄だけは、流すたびに変わりました。**同じスクリプトを5回流すと、落ちた回は 2, 2, 5, 4, 1 回(各20回中)でした。残りの13欄は5回とも上の表と同じ数でした
**回数が増えるほど落ちる、という形ではありませんでした。**3回と4回は毎回落ち、5回はたまに落ち、8回は1度も落ちていません。なぜこの回数で落ちるのかは確かめていません。このPCとこの版でこう出た、というところまでです。
3221226505 は16進で 0xC0000409 です。符号付きで読むと -1073740791 になります。
シェルによって見え方が違う
同じ落ち方(fetch 3回 → process.exit(0))を、3つのシェルから起動して終了コードを表示させました。対照として、正常に終わる形(process.exitCode = 0)も同じ経路で起動しています。
| シェル | process.exit(0)(落ちる形) | process.exitCode = 0(対照) |
|---|---|---|
Git Bash の $?
|
127 | 0 |
PowerShell の $LASTEXITCODE
|
-1073740791 | 0 |
cmd の %ERRORLEVEL%
|
-1073740791 | 0 |
Git Bash では 127 と出ました。127 は bash では『コマンドが見つからない』ときにも出る値です。対照の形で 0 が出ているので、同じ経路で node は見つかっています。なぜ 127 になるのかは確かめていません。
定期実行で何が起きるか
タスク スケジューラは、起動したものの終了コードを前回の結果として記録します。起動したものが node の終了コードをそのまま返す作りなら、仕事は全部済んでいるのに、失敗の終了コードが記録されることになります。(タスク スケジューラ経由では今回は測っていません。間に挟む .cmd が最後に exit /b 0 していれば、記録は 0 になります)
これは成功を失敗として返す形です。毎回出るなら気づけますが、上の表のとおり回数によっては出たり出なかったりするので、fetch の回数が変わる処理(未読があるときだけもう1回問い合わせる、など)では、ある日から失敗が記録されはじめる、という見え方になりえます。
直し方
process.exit() を呼ばず、process.exitCode に終了コードを置いて、自分で開いたものを閉じて関数が終わるのを待ちます。
// 落ちる回があった形
process.exit(0);
// 替えた形
process.exitCode = 0;
server.closeAllConnections(); // 自分で開いたものは自分で閉じる
server.close();
上の表の右の列が、この形で測ったものです。
**閉じ忘れると、今度は終わりません。**上の再現の子から閉じる2行だけを消して process.exitCode = 0 で終わらせると、done を出したあとプロセスが終わらず、15秒の打ち切りで止められました(1回だけ確かめました)。定期実行なら、タスクの実行時間の上限まで居座ることになります。
AbortSignal.timeout() だけでは説明できなかった
原因の候補として AbortSignal.timeout() の未発火のタイマーを疑い、コードのコメントにそう書いたことがあります。今回、自分のサーバ(別のプロセスで動いているもの)相手に fetch を2回投げて process.exit(0) する形で、signal を付けない / AbortSignal.timeout() を付ける / 自前の AbortController で付けて必ず clearTimeout する、の3通りを10回ずつ試すと、3通りとも10回中10回落ちました。AbortSignal.timeout() を外しても直らないので、少なくともそれだけが原因ではありません。今日の測り方で言えるのは、process.exit(0) をやめれば出なかった、までです。
その測り方では2回で落ち、上の再現スクリプトでは2回では落ちず3回で落ちています。2つの測り方は、サーバが別のプロセスか同じプロセスか・相手のパスと応答・終わるときにサーバを開いたままか、など何か所も違うので、どれが境目を動かしたのかは分かりません。言えるのは、測り方が違うと境目が変わった、までです。
測っていないこと
- 測ったのは Windows 11 / Node v24.18.1 の1台だけです。他の版の Node、Linux や macOS では測っていません
- 相手は 127.0.0.1 の使い捨てサーバだけです。外のサーバ相手では測っていません
- 再現の表は各欄20回です。0回と書いた欄も、21回目に落ちないとは言えません
- シェルの表は各欄1回、
signalを変えた測り直しは各10回です - なぜこの回数で落ちるのか、なぜ Git Bash で 127 になるのかは確かめていません
再現スクリプト
依存はありません。ファイルの読み書きはせず、通信は 127.0.0.1 の使い捨てサーバだけです。全部で 280回 の子プロセスを起動します。
// fetch のあとに process.exit(0) で終わると、Windows の Node が
// 仕事を終えたあとに終了コード 3221226505(= -1073740791)で落ちることがあるかを数える。
// 依存0。ファイルを読み書きしない。通信は 127.0.0.1 の使い捨てサーバだけ。
//
// 使い方: node repro-exit-after-fetch.mjs [各条件の回数(既定20)] [--json]
//
// 子プロセスとして自分自身を呼び、fetch の回数と終わり方(process.exit(0) / process.exitCode = 0)
// を変えて、子の終了コードを数える。
import http from "node:http";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const self = fileURLToPath(import.meta.url);
if (process.argv[2] === "--child") {
const n = Number(process.argv[3]);
const end = process.argv[4];
const srv = http.createServer((req, res) => res.end("ok"));
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
const url = `http://127.0.0.1:${srv.address().port}/`;
for (let i = 0; i < n; i++) {
const r = await fetch(url);
await r.text();
}
console.log("done");
if (end === "exit") process.exit(0);
process.exitCode = 0;
srv.closeAllConnections();
srv.close();
} else {
const runs = Number(process.argv.find((a) => /^\d+$/.test(a)) || 20);
const rows = [];
for (const n of [0, 1, 2, 3, 4, 5, 8]) {
for (const end of ["exit", "exitCode"]) {
const codes = {};
let printedDone = 0;
let firstStderr = null;
for (let i = 0; i < runs; i++) {
const r = spawnSync(process.execPath, [self, "--child", String(n), end], {
encoding: "utf8",
timeout: 20000,
});
const k = String(r.status);
codes[k] = (codes[k] || 0) + 1;
if (r.stdout.includes("done")) printedDone++;
if (r.status !== 0 && firstStderr === null) firstStderr = r.stderr.trim().split(/\r?\n/)[0] || "";
}
rows.push({ fetches: n, end, runs, codes, printedDone, firstStderr });
}
}
const out = { node: process.version, platform: process.platform, arch: process.arch, rows };
if (process.argv.includes("--json")) {
console.log(JSON.stringify(out, null, 2));
} else {
console.log(`Node ${out.node} / ${out.platform} ${out.arch} / 各条件 ${runs} 回`);
for (const r of rows) {
const c = Object.entries(r.codes).map(([k, v]) => `${k}:${v}`).join(" ");
console.log(`fetch ${r.fetches}回 ${r.end.padEnd(8)} 終了コード ${c} (done を出した回 ${r.printedDone})`);
}
}
}
Windows で AI や定期処理を無人で走らせている方向けに、タスク スケジューラの設定を読み取りだけで点検する診断スクリプトを、次のページの後半に置いています。
追記(2026-10-19): fetch 8回の欄も、落ちる回がありました
題と本文で『8回で0回』『8回は1度も落ちていません』と書きました。これは 2026-10-15 に流した5回(各20回)についての話で、8回なら落ちないという意味では成り立っていませんでした。
2026-10-18 に同じ再現スクリプトを流し直したところ、**fetch 8回 → process.exit(0) の欄が、20回中1回 3221226505 で終わりました。**残した標準エラーは、本文と同じ Assertion failed: !(handle->flags & UV_HANDLE_CLOSING) の行でした。続けて同じスクリプトを5回流すと、この欄は5回とも0回でした。
| 流した日 | 流した回数(各20回) | fetch 8回 → process.exit(0) で落ちた回 |
|---|---|---|
| 2026-10-15 | 5回 | 0, 0, 0, 0, 0 |
| 2026-10-18 | 6回 | 1, 0, 0, 0, 0, 0 |
合わせて 220回中1回です。落ちる頻度は5回の欄(保存してある10回では毎回1〜5回)よりずっと低いものの、8回の欄も流すたびに変わりうる、と読むのが正確でした。3回と4回は、2026-10-18 の6回でも毎回20回中20回落ちました。process.exitCode = 0 で終わる形は、2026-10-18 の6回でも1度も落ちていません。
本文の結論(process.exit(0) をやめて process.exitCode を使い、自分で開いたものを閉じる)は変わりません。変わるのは『8回なら安全』と読める書き方で、そうは言えませんでした。
測っていないこと: このPC1台だけです。版を記録してある回はどれも Node v24.18.1 でした(2026-10-18 の1回目は版を記録していません)。8回の欄が落ちる割合は、1回しか観測していないので数として出せません。なぜその回だけ落ちたのかも確かめていません。