Node.js の spawn が返す ENOENT は、なぜ実行ファイルしか名指ししないのか
cwd が消えても実行ファイルが消えても同じコードになる理由と、try/catch だけでも 'error' ハンドラだけでも取りこぼす、Windows と Linux の実測
デプロイスクリプトの中で、一時的な作業ディレクトリを用意してから外部コマンドを起動する、という処理はよくあります。ある日そのスクリプトが、こんなエラーで落ちたとします。
spawn C:\Program Files\nodejs\node.exe ENOENT
エラーメッセージには実行ファイルのフルパスが書いてあります。そこで「この実行ファイルが見つからないのだろう」と思い、パスを開いて確認します。ところが、ファイルはちゃんとそこにあります。存在するはずのものが「無い」と言われる——このねじれに一度でも遭遇したことがあるなら、この記事は当たりです。
種明かしをすると、無かったのは実行ファイルではなく、spawn に渡した作業ディレクトリ(cwd)のほうでした。エラーメッセージは実行ファイルの名前を出しているのに、実際に消えていたのは別の場所。Node.js の child_process.spawn が返す ENOENT は、見つからなかったパスを名指ししていないのです。
なぜこんな取り違えが起きるのでしょうか。errno(システムコール——プログラムが「ファイルを開いて」「プロセスを起こして」と OS 本体に処理を頼む呼び出しのことです——が失敗したときに返る整数のエラー番号の体系で、ENOENT のような名前がひとつずつ割り当てられています)は「何が失敗したか」を教えてくれるはずのものです。ところが spawn の世界では、この番号だけを見ても「cwd が無いのか、実行ファイルが無いのか」を区別できません。この記事では、その理由を Node.js と libuv(Node.js の下でイベントループとプロセス起動を担っている C のライブラリ)のソースまでたどり、Windows と Linux で実際に何が起きるかを手元で確かめた記録として書きます。
目次
ENOENT は、もともと2つの意味を背負っている
この重なりは、実は Node.js の公式ドキュメントにもはっきり書かれています。child_process.spawn() の cwd オプションの説明を見てみます。
If given, but the path does not exist, the child process emits an
ENOENTerror and exits immediately.ENOENTis also emitted when the command does not exist.
「cwd に渡したパスが存在しないときも ENOENT」「実行するコマンドが存在しないときも ENOENT」。1つのエラーコードに、原因の異なる2つの状況を割り当てると、公式ドキュメント自身が宣言しているわけです。ENOENT はもともと「no such file or directory」、つまり「そのパスが無い」という意味の errno で、UNIX 系 OS では昔から使われてきた汎用の番号です。汎用であることは悪いことではありませんが、汎用であるがゆえに、「どのパスが無いのか」までは背負ってくれません。
問題は、この2つの状況を利用者側が見分ける手立てが、公式ドキュメントに書かれていないことです。エラーオブジェクトのどのプロパティを見れば「cwd 側」だと分かるのか、あるいは分からないのか。ここから先は、実際にコードを走らせて確かめていきます。
以下の実測は、Windows 11 Pro(ビルド 10.0.26200)と、Docker の node:22.12.0 イメージ(Debian bookworm、WSL2 カーネル 6.18.33.2)の両方で確認したものです。Node.js はどちらも v22.12.0、内部で使われている libuv も 1.49.1 にそろえています。OS の違いだけを見たいので、バージョンはあえて固定しました。
名指しされているのは、いつも実行ファイル
まず、実行ファイルが実在する状態で cwd だけを消したケース(A)と、cwd は実在する状態で実行ファイル名だけをでたらめにしたケース(B)を、同じスクリプトで並べて試します。
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const realDir = mkdtempSync(join(tmpdir(), 'probe-'));
const missingDir = join(realDir, 'no-such-dir');
function run(label, file, args, options) {
return new Promise((resolve) => {
const child = spawn(file, args, options);
child.on('error', (err) => {
console.log(label, '|', err.message);
console.log(' code:', err.code, '| errno:', err.errno, '| path:', err.path);
});
child.on('close', resolve);
});
}
// A: 実在する実行ファイル + 存在しない cwd
await run('A', process.execPath, ['-e', 'process.exit(0)'], { cwd: missingDir });
// B: 存在しない実行ファイル + 実在する cwd
await run('B', 'definitely-not-a-real-binary-xyz', [], { cwd: realDir });
Windows で実行すると、こう出ます。
A:
message : spawn C:\Program Files\nodejs\node.exe ENOENT
code : ENOENT
errno : -4058
path : C:\Program Files\nodejs\node.exe
B:
message : spawn definitely-not-a-real-binary-xyz ENOENT
code : ENOENT
errno : -4058
path : definitely-not-a-real-binary-xyz
Linux でも形は同じです。
A:
message : spawn /usr/local/bin/node ENOENT
code : ENOENT
errno : -2
path : /usr/local/bin/node
B:
message : spawn definitely-not-a-real-binary-xyz ENOENT
code : ENOENT
errno : -2
path : definitely-not-a-real-binary-xyz
cwd が無い A も、実行ファイルが無い B も、err.path に入るのは常に実行ファイル側のパスです。実行ファイルが実在していようが、していまいが関係ありません。err.code も err.errno も見分けの手がかりにならず、err.path を見ても「無かったのが cwd かもしれない」という発想にはたどり着けない構造になっています。
これは偶然の実装ではなく、Node.js 本体のソースを読むとそのまま書いてあります。lib/internal/child_process.js の該当箇所です。
if (exitCode < 0) {
const syscall = this.spawnfile ? 'spawn ' + this.spawnfile : 'spawn';
const err = new ErrnoException(exitCode, syscall);
if (this.spawnfile)
err.path = this.spawnfile;
err.spawnargs = ArrayPrototypeSlice(this.spawnargs, 1);
this.emit('error', err);
}
err.path に代入されているのは this.spawnfile、つまり実行ファイル名だけです。cwd を保持している変数はこの近くにどこにも出てきません。Node.js は、失敗の原因が実行ファイル側にあろうと cwd 側にあろうと、エラーオブジェクトの型は同じものを1つしか用意していないのです。ではなぜ cwd 側の失敗がここに紛れ込むのでしょうか。答えは OS ごとのプロセス起動の仕組みにあります。
POSIX では、先に chdir してから exec する
Linux や macOS のような POSIX 系 OS では、新しいプロセスを起こす手順が2段階に分かれています。まず fork() で自分自身のコピーをもう1つ作り、次に子プロセス側で exec 系の関数を呼んで、そのプロセスの中身をまるごと指定した実行ファイルに置き換えます。この2段階のあいだに、作業ディレクトリを変更する chdir()(カレントディレクトリを切り替えるシステムコール)が挟まります。
Node.js のプロセス起動を担う libuv の POSIX 側実装(src/unix/process.c の uv__process_child_init)を見ると、その順番がそのままコードになっています。
if (options->cwd != NULL && chdir(options->cwd))
uv__write_errno(error_fd);
/* … 権限の切り替えや環境変数の差し替え、シグナルマスクの初期化がここに入る … */
#ifdef __MVS__
execvpe(options->file, options->args, environ);
#else
execvp(options->file, options->args);
#endif
uv__write_errno(error_fd);
chdir() は execvp() より前に呼ばれています。つまり cwd の変更に失敗した時点で、実行ファイルを探しにすら行っていません。それでも、失敗した errno はパイプ経由で uv__write_errno() によって親プロセスへ書き込まれ、それを受け取った Node.js 側は「spawn が失敗した」としか記録しません。chdir の失敗も execvp の失敗も、同じパイプの同じ仕組みで運ばれてくるので、親プロセス側からは区別しようがないのです。だからこそ、err.path には常に spawnfile(実行ファイル名)が入り、「本当に無かったのはディレクトリのほう」という事実は、この時点でもう失われています。
fork してから chdir、そのあとで execWindows は1回の呼び出しで済ませる、だから分け方も違う
Windows には fork() に相当するものがありません。libuv の Windows 側実装(src/win/process.c)は、プロセスの生成を CreateProcessW という1つの Win32 API の呼び出しにまとめています。
if (!CreateProcessW(application_path,
arguments,
NULL,
NULL,
1,
process_flags,
env,
cwd,
&startup.StartupInfo,
&info)) {
/* CreateProcessW failed. */
err = GetLastError();
goto done;
}
8番目の引数が lpCurrentDirectory、つまり新しいプロセスの作業ディレクトリです。Microsoft の公式ドキュメントは、この引数について次のように説明しています。
The full path to the current directory for the process. The string can also specify a UNC path.
(UNC パスというのは \\server\share のように、ネットワーク共有上の場所を指す Windows の書き方です。)
POSIX の「fork してから chdir して、それから exec」という3段階に対し、Windows は「作業ディレクトリも実行ファイルも、まとめて1回の呼び出しに渡す」という1段階の設計です。段階が分かれていないぶん、失敗した理由を仕分ける余地も最初から用意されていません。CreateProcessW が失敗すると GetLastError() が Windows 独自のエラーコード(ERROR_ から始まる定数群)を返し、libuv はそれを uv_translate_sys_error() という関数で POSIX 風の errno 相当の値(UV_ から始まる定数)へ変換します。
/* Cleanup, whether we succeeded or failed. */
done:
err = uv_translate_sys_error(err);
この変換表(src/win/error.c)を見ると、UV_ENOENT を返す case がずらりと並んでいます。
case ERROR_BAD_PATHNAME: return UV_ENOENT;
case ERROR_DIRECTORY: return UV_ENOENT;
case ERROR_ENVVAR_NOT_FOUND: return UV_ENOENT;
case ERROR_FILE_NOT_FOUND: return UV_ENOENT;
case ERROR_INVALID_NAME: return UV_ENOENT;
case ERROR_INVALID_DRIVE: return UV_ENOENT;
case ERROR_INVALID_REPARSE_DATA: return UV_ENOENT;
case ERROR_MOD_NOT_FOUND: return UV_ENOENT;
case ERROR_PATH_NOT_FOUND: return UV_ENOENT;
case WSAHOST_NOT_FOUND: return UV_ENOENT;
case WSANO_DATA: return UV_ENOENT;
「パスの形がおかしい」「ディレクトリのはずが違う」「ドライブが無効」「そもそも見つからない」——性質の異なる11種類ものエラーが、ここで一律に UV_ENOENT へ丸められています(末尾の2つはソケット関連のエラーで、プロセス起動とは無関係の経路から来るものです。それでも同じ出口に合流します)。しかも、このファイルには UV_ENOTDIR(「ディレクトリだと思ったら違った」という意味の errno)を返す case が1つもありません。
ここで確認できるのは、あくまで「この変換表には UV_ENOTDIR へ向かう出口が用意されていない」という一点です。Windows という OS の側がその区別を持っているかどうかまでは、この表からは分かりません。分かるのは、変換を通り抜けた後にはもう残っていない、ということだけです。
この差は、実際に cwd を4通りの方法で壊して spawnSync に投げてみると、そのまま数字に出ます。
cwd の壊し方 | Windows | Linux(uid 1000) |
|---|---|---|
| 存在しないディレクトリ | ENOENT(errno -4058) |
ENOENT(errno -2) |
cwd がファイル |
ENOENT(errno -4058) |
ENOTDIR(errno -20) |
| 存在しないドライブ/ルート | ENOENT(errno -4058) |
ENOENT(errno -2) |
| 権限の無いディレクトリ | エラーなし(chmod が効かず未再現) |
EACCES(errno -13) |
Linux は3種類のエラーコードを使い分けているのに対し、Windows は上の3行がすべて ENOENT に潰れています。最後の行「権限の無いディレクトリ」は、Windows では chmodSync(0o000) を呼んでも実効を持たなかったため、権限不足そのものを再現できませんでした。ここから「Windows では cwd の権限不足が EACCES にならない」と結論づけることはできません。ACL(アクセス制御リスト)できちんと権限を絞れば、また違う結果になる可能性があります。ここで言えるのは、少なくとも chmod で権限を落とす方法では、Windows 側の挙動を確かめられなかった、というところまでです。
なお、どの ERROR_* が実際に発火したかは、JavaScript 側からは errno に変換された後の姿しか見えないため、ここでは断定しません。変換表に複数の Windows エラーが並んでいることは確かでも、「今回のケースで実際に飛んできたのは ERROR_DIRECTORY だ」と言い切るところまでは確認していません。
最大の落とし穴:'error' イベントか、同期 throw か
ここまでは「エラーコードが何を意味するか」の話でした。ですが、実務でもっと厄介なのは、そのエラーがそもそもどうやって自分のコードに届くか、という部分です。ここで OS ごとの差がもう一段、深いところにあります。
Node.js の公式ドキュメントは、spawn が失敗したときの届き方を次のように説明しています。
The
'error'event is emitted whenever:The process could not be spawned.
…
素直に読めば「起動に失敗したら 'error' イベントが飛んでくる」と理解します。では、先ほどの cwd がファイルだったケース(つまり ENOTDIR になるはずのケース)を試すと、Windows と Linux でどう変わるでしょうか。
try {
const child = spawn(process.execPath, ['-e', 'process.exit(0)'], { cwd: aFile });
child.on('error', (err) => console.log('error イベント:', err.code, err.path));
} catch (err) {
console.log('同期 throw:', err.message, '| code:', err.code, '| path:', err.path);
}
Windows では、いつもどおり 'error' イベントで届きます。
message : spawn C:\Program Files\nodejs\node.exe ENOENT
code : ENOENT
path : C:\Program Files\nodejs\node.exe
ところが Linux では、'error' イベントが1つも発火せず、spawn() の呼び出し自体が同期的に例外を投げます。
spawn() THREW synchronously
message : spawn ENOTDIR
code : ENOTDIR
errno : -20
path : undefined
spawnargs : undefined
しかも投げられたこの例外には、path も spawnargs も付いていません。同じ「cwd がファイルだった」という1つの原因が、Windows では拾いやすい 'error' イベントに、Linux では try/catch でしか拾えない同期例外に、届き方そのものが変わってしまうのです。'error' ハンドラだけを書いても、try/catch だけを書いても、どちらか一方は必ず取りこぼします。
なぜこんな出し分けが起きるのでしょうか。理由は Node.js のソース(lib/internal/child_process.js)にある、次の許可リストです。
if (err === UV_EACCES ||
err === UV_EAGAIN ||
err === UV_EMFILE ||
err === UV_ENFILE ||
err === UV_ENOENT) {
process.nextTick(onErrorNT, this, err);
// ...
} else if (err) {
// ...
throw new ErrnoException(err, 'spawn');
}
errno が EACCES / EAGAIN / EMFILE / ENFILE / ENOENT のいずれかのときだけ process.nextTick 経由で 'error' イベントへ回され、それ以外はその場で同期に throw されます。Windows で今回起きたのは ENOENT(許可リストに入っている)なのでイベント経由、Linux で起きたのは ENOTDIR(許可リストに入っていない)なので同期 throw。同じ「cwd がファイルだった」という1つの失敗が、たまたま errno の名前だけの都合で、届き方が真逆になっているわけです。err.path や err.spawnargs を代入している処理は 'error' イベント側の経路にしか無いため、throw された側のエラーには実行ファイル名すら残りません。
ちなみに、同期版の spawnSync では今回この揺れが出ませんでした。上の4通りの壊し方すべて(ENOENT / ENOTDIR / EACCES)で、spawnSync は throw も 'error' イベントも使わず、戻り値の result.error に入れて返してきます。届き方が errno によって変わらないぶん、非同期の spawn より扱いは単純でした。とはいえ確かめたのはこの3種類だけなので、あらゆる errno でそうなると言い切るところまでは確認していません。
shell: true を挟むと、名指しされる相手がすり替わる
もう1つ、実務で見落としやすい形があります。shell: true を付けて spawn を呼ぶと、Node.js は指定したコマンドをシェル(Linux なら /bin/sh、Windows なら cmd.exe)経由で実行します。このとき、ENOENT が名指しするのは誰になるのでしょうか。
await run('D', 'definitely-not-a-real-binary-xyz', [],
{ cwd: realDir, shell: true, stdio: 'ignore' });
await run('E', 'echo', ['ok'], { cwd: missingDir, shell: true, stdio: 'ignore' });
D は「存在しないコマンド + 実在する cwd」、E は「実在するコマンド(echo) + 存在しない cwd」です。Linux の結果を見ます。
D: no error event
close : code=127 signal=null
E:
message : spawn /bin/sh ENOENT
code : ENOENT
path : /bin/sh
close : code=-2 signal=null
D(コマンドが存在しない)は 'error' イベントすら発火せず、シェルの終了コード 127(sh の「command not found」の慣例)として返ってきます。一方 E(cwd が存在しない)は ENOENT になり、err.path に入るのは echo ではなく /bin/sh です。Windows も対応する形で、cmd.exe が名指しされます。
D: no error event
close : code=1 signal=null
E:
message : spawn C:\WINDOWS\system32\cmd.exe ENOENT
code : ENOENT
path : C:\WINDOWS\system32\cmd.exe
spawnargs : ["/d","/s","/c","\"echo ok\""]
つまり shell: true を挟んだ瞬間、err.path が名指しする相手は「自分が呼びたかったコマンド」から「シェルそのもの」に一段すり替わります。逆に言えば、これは切り分けの手がかりとして使えます。shell: true の下で ENOENT を見たら、それは自分のコマンドの綴りの話ではなく、cwd かシェル自身の話です。コマンドが見つからないという失敗は、spawn の失敗にすら分類されず、シェルの終了コードとして静かに返ってくるのです。
実務的な結論:手配書の文言より、err.code と自分の前提を見る
ここまでをまとめます。err.path は常に実行ファイル(またはシェル)の名前で、cwd を名指しすることはありません。POSIX では chdir が exec より先に走り、その失敗が同じパイプで運ばれてくるために原因が混ざります。Windows では fork に相当する段階そのものが無く、複数の異なるエラーが1つの errno へ丸め込まれます。
そして最大の落とし穴は、同じ失敗でも Windows では 'error' イベント、Linux では同期 throw という違う届き方をすることです。try/catch だけでも、'error' ハンドラだけでも、どちらか一方の OS では確実に取りこぼします。
では、どう書けばよいのでしょうか。いちばん確実なのは、spawn を呼ぶ前に自分で cwd の実在を確かめる、あるいは必要なら毎回作ってしまうことです。エラーから逆算して原因を当てにいくより、起動前に前提を保証するほうが速く、しかも OS 差に振り回されません。それでもエラーハンドリングを書くなら、try/catch と 'error' イベントの両方を用意し、spawnSync を使えるところでは戻り値の result.error に統一してしまうのが安全です。
もう1つ覚えておきたいのは、エラーメッセージの文言は当てにならない、ということです。今回見てきたとおり、Node.js 自身のメッセージですら「実行ファイルが見つからない」としか読めない文言で、実際には cwd の話だったケースを何度も作れます。ラッパー系のライブラリを挟んでいれば、なおさら独自の言い回しに整形され、もっともらしい物語に変換されていることがあります。見るべきは文言ではなく err.code と、自分がその cwd や実行ファイルについて何を前提にしていたかです。
なお、この記事の実測は Windows と Linux に限っています。macOS は同じ POSIX 系ですが試していないため、ここまでの Linux の結果をそのまま当てはめてよいとは言えません。また execFile や fork、その他のラッパーライブラリの挙動もここでは扱っていません。それらを使っている場合は、内部で spawn をどう包んでいるかによって、届き方がさらに変わる可能性があります。
参考にした一次情報
- Node.js 公式ドキュメント
child_process.spawn()(cwdオプションの説明。「パスが存在しないとき」と「コマンドが存在しないとき」の両方でENOENTを発することを明記) - Node.js 公式ドキュメント
'error'イベント(プロセスを起動できなかったときに'error'イベントが発火するという説明) - Node.js ソース
lib/internal/child_process.js(v22.12.0)(err.path = this.spawnfileの代入箇所、および'error'イベントへ回すerrnoの許可リストとthrowの分岐) - libuv ソース
src/unix/process.c(uv__process_child_initにおけるchdir()とexecvp()の実行順序) - libuv ソース
src/win/process.c(CreateProcessWの呼び出しとcwdの引き渡し、GetLastError()からuv_translate_sys_error()への変換) - libuv ソース
src/win/error.c(UV_ENOENTに写像されるWindowsエラーコードの一覧。UV_ENOTDIRに写像するcaseが存在しないこと) - Microsoft Learn
CreateProcessWfunction(lpCurrentDirectoryパラメータの説明)








