0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Node.js の「直接実行されたら main()」判定が、パスによって出力0行・終了コード0で黙る — file:// の手組みと、ジャンクション

0
Posted at

main() が 3 を返すスクリプト old.mjs を、名前だけが違う2つのフォルダに置き、cmd から起動しました
(Windows 11 10.0.26200 / Node v24.18.1)。次の5行を .bat に書いて実行した結果です。

@echo off
node "C:\Users\Public\automos-guard-cmd-QTJABN\plain\old.mjs"
echo ERRORLEVEL=%ERRORLEVEL%
node "C:\Users\Public\automos-guard-cmd-QTJABN\v1#2\old.mjs"
echo ERRORLEVEL=%ERRORLEVEL%
main が呼ばれた
ERRORLEVEL=3
ERRORLEVEL=0

plain に置いたほうは1行出して 3 で終わりました。v1#2 に置いたほうは何も出さず、0 で終わりました。
例外も出ません。「走ったが、することが無かった」と見分けがつきません。

判定は、当社が配っていた診断スクリプトの末尾に実際にあった3行です。

if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, "/")}`).href) {
  process.exit(main());
}

old.mjs は、この3行の前に「1行出して 3 を返す main()」を置いただけのファイルです。
パスによってはこの比較が偽になり、main() が一度も呼ばれません。


再現した結果

同じ中身のファイルを置き場所と起動のしかたを変えて起動し、main() が呼ばれたかを見ました。
表は Node v24.18.1 で、Node から子プロセスとして起動した結果です(フルパス指定・cwd は C:\。cmd は通していません)。
**1 は「1行出して終了コード3」、0 は「出力0行・終了コード0」**です。
**「下の直し方」の列は、この版では import.meta.main を通った結果です。**比較の側の結果は、直し方の節の注意に書きました。

置き場所・起動のしかた 手組みの URL pathToFileURL(argv[1]) fileURLToPath で比較 realpathSync してから pathToFileURL import.meta.main 下の直し方
ASCII のみ 1 1 1 1 1 1
日本語 1 1 1 1 1 1
空白 1 1 1 1 1 1
é 1 1 1 1 1 1
#(v1#2) 0 1 1 1 1 1
~(PROGRA~1 の形) 0 1 1 1 1 1
[ ]([x]) 0 1 1 1 1 1
%(100%) 0 1 1 1 1 1
8.3 短縮名を含む %TEMP%(実在するフォルダ) 0 1 1 1 1 1
ジャンクション経由(実体は ASCII のフォルダ) 0 0 0 1 1 1
ジャンクション経由 + --preserve-symlinks-main 1 1 1 0 1 1
拡張子なしで起動(type: module の .js) 0 0 0 例外(終了コード1) 1 1
  • 手組みの URL で黙ったのは、測った場所のうち # ~ [ ] % を含むところ・ジャンクション経由・拡張子なしの起動です。
    測っていない文字でも起きうるので、この表を「安全な文字の一覧」として読まないでください
  • ジャンクション経由では、手組みを直した2つの書き方(pathToFileURL / fileURLToPath)も黙りました
  • 「黙る」のは書き方と起動のしかたの組み合わせで決まり、どの比較の書き方にも黙る行がありました。
    全部の行で1になったのは import.meta.main と、それを使う下の直し方だけです

なぜ一致しないのか(手組みの URL)

Node v24.18.1 で、import.meta.url と手組みの URL を並べて出しました(フォルダ名の部分だけ抜き出しています)。

フォルダ名 import.meta.url 手組み(new URL)
v1#2 .../v1%232/probe.mjs .../v1#2/probe.mjs
PROGRA~1 .../PROGRA%7E1/probe.mjs .../PROGRA~1/probe.mjs
[x] .../%5Bx%5D/probe.mjs .../[x]/probe.mjs
100% .../100%25/probe.mjs .../100%/probe.mjs
  • # は URL では「ここから先はフラグメント」の意味です。手組みの URL は
    pathname が /C:/work/v1 で切れ、残りの #2/tool.mjs がフラグメントになります(C:\work\v1#2\tool.mjs の場合)
  • ~ [ ] % は、この版の import.meta.url では符号化されていて、new URL() ではそのまま残っていました

文字列として比べているので、1文字違えば偽です。そして偽のときは何も起きません。


ジャンクション経由だと、符号化を揃えても黙る

ASCII だけのフォルダ plain を指すジャンクション junction-to-plain を作り、そちら経由で起動しました。

process.argv[1] : C:\Users\Public\automos-guard-C07cot\junction-to-plain\probe.mjs
import.meta.url : file:///C:/Users/Public/automos-guard-C07cot/plain/probe.mjs

process.argv[1] は呼んだときのパス、import.meta.url は実体のパスになっていました。
符号化の規則をいくら揃えても、指している場所の書き方が違うので一致しません。

そこで argv[1] を fs.realpathSync で実体に直してから比べると、今度は別の起動のしかたで一致しなくなりました。

  • node --preserve-symlinks-main で起動すると、import.meta.url もジャンクションのパスのまま
    (.../junction-to-plain/probe.mjs)になり、実体に直した argv[1] と一致しません。0行・終了コード0 でした
  • 拡張子を付けずに起動すると(node C:\...\noext\probe、中身は package.json が "type": "module" のフォルダの probe.js)、
    process.argv[1] は ...\noext\probe、import.meta.url は .../noext/probe.js でした。
    実在しないパスなので realpathSync が例外を投げ、終了コード1で終わりました

Node の内部でどう処理されているかは確かめていません。見たのはこれらの値です。


同じファイルでも、呼び方で動いたり黙ったりする

このPCでは、環境変数 %TEMP% に 8.3 短縮名の形(C:\Users\XXXXX~1\AppData\Local\Temp)が入っていました。
そこに置いた1つのファイルを、2通りの名前で起動しました。

起動したパス 手組みの URL で判定
短縮名の形(...\XXXXX~1\...) 0行・終了コード0
同じフォルダを長い名前で(...\(元のユーザー名)\...) 1行・終了コード3

ファイルは同じです。process.argv[1] にどういう文字列が入るかで結果が変わります。

cmd で、この短縮名を含むフォルダへ cd してから相対パス(node old.mjs)で起動しても、0行・ERRORLEVEL=0 でした。
cd が表示したカレントディレクトリは、短縮名の形のままでした。相対パスで起動すれば避けられる、というものではありません。


直し方

import.meta.main は、24 系では v24.2.0 以降、22 系では v22.18.0 以降にあります
(公式ドキュメントの Added in: v24.2.0, v22.18.0。Stability: 1.0 - Early development)。
それがある版ではそれを使い、無い版だけ argv[1] を実体のパスに直してから比べます。
元の行の process.exit(main()) はそのまま残します。main() の戻り値が終了コードだからです
(元の行と同じく、main() が終了コードを同期で返す前提の形です)。

import fs from "node:fs";
import { pathToFileURL } from "node:url";

function isEntryPoint() {
  if (typeof import.meta.main === "boolean") return import.meta.main;
  const a = process.argv[1];
  if (a === undefined) return false;
  let p = a;
  try {
    p = fs.realpathSync(a);
  } catch {}
  return import.meta.url === pathToFileURL(p).href;
}

if (isEntryPoint()) {
  process.exit(main());
}

表の「下の直し方」の列が、この形そのものを置いて起動した結果です。注意が4つあります。

  • v24.18.1 では1行目の import.meta.main が使われるので、表の列は右側(比較)を通っていません。
    右側は、1行目を消した写しで別に測りました。12通りのうち10通りで1行・終了コード3、
    --preserve-symlinks-main 付きと拡張子なしの起動では 0行・終了コード0
    でした。
    import.meta.main が無い版の Node そのものでは走らせていません
  • import.meta.main だけで書くと、それが無い版では undefined になり、if が偽のまま同じ形で黙ります。
    上の形は typeof で boolean かを見てから使っています
  • realpathSync は、拡張子なしの起動のように実在しないパスでは例外を投げます(表の「例外(終了コード1)」)。
    上の形はそれを catch で握って直さずに比べるので、import.meta.main が無い版で、このファイル自身を拡張子なしで起動すると、
    例外ではなく黙るほうになります。黙る側を選んでいます。
    (type: module の .js の場合です。.mjs のファイルは
    拡張子なしでは読み込めず、ここへ来る前に終了コード1で止まりました。)理由は、握らないと、このファイルを import しているだけのプログラムが
    拡張子なしで起動されたときに、import の時点で落ちるからです。catch を外した写しを import する app.js を
    node ...\app で起動すると終了コード1で止まり、catch ありの写しでは app.js が最後まで走りました
    (argv[1] は import する側でもメインのパスなので、同じ例外が起きます)
  • fs.realpathSync.native にすると、8.3 短縮名を含む %TEMP% の置き場所で 0行・終了コード0 でした。
    .native は短縮名を長い名前へ展開しますが、このときの import.meta.url は短縮名(~1)のままだったので一致しません。
    使ったのは fs.realpathSync(.native なし)です

確かめ方(数分)

手元の判定を、# を含むフォルダと、そこを指すジャンクションの2か所から起動してみる手順です。
次の8行(先頭は @echo off)を .bat に書き、cmd から実行しました。
実際に流した .bat では、@echo off の次に、%TEMP% を空の新しいフォルダへ向け直す set と mkdir、
your-script.mjs のあるフォルダへの cd /d を置いています。

@echo off
mkdir "%TEMP%\v1#2"
copy your-script.mjs "%TEMP%\v1#2\"
node "%TEMP%\v1#2\your-script.mjs"
echo ERRORLEVEL=%ERRORLEVEL%
mklink /J "%TEMP%\link-to-v1" "%TEMP%\v1#2"
node "%TEMP%\link-to-v1\your-script.mjs"
echo ERRORLEVEL=%ERRORLEVEL%

pathToFileURL(argv[1]) で比べる版(main() は 3 を返す)を置いたときの出力です
(mklink が出す1行は省きました。copy の表示の文言は環境によって違います)。

        1 file(s) copied.
main が呼ばれた
ERRORLEVEL=3
ERRORLEVEL=0

**2回目(ジャンクション経由)だけ、何も出ずに ERRORLEVEL=0 でした。**上の直し方で書いた版を置くと、2回とも1行出て ERRORLEVEL=3 でした。

  • **この手順は、あなたのスクリプトを実際に2回走らせます。**ファイルを書き換える・通信するスクリプトなら、
    main() の中身を「1行出して 3 を返す」だけにした写しで試してください
  • %ERRORLEVEL% は、node と別の行で読んでください。node ... & echo ERRORLEVEL=%ERRORLEVEL% と1行に書くと、
    cmd は node が走る前にその行の %ERRORLEVEL% を展開します。3 で終わる old.mjs で試すと、同じ行では ERRORLEVEL=0、
    次の行で読むと ERRORLEVEL=3 でした。私は一度この測り方でこの記事の証拠を取り、別の目のレビューで指摘されて取り直しました
  • この手順で分かるのは # とジャンクションの2つだけです。表の --preserve-symlinks-main・拡張子なし・
    短縮名(cmd で cd したときを含む)は、それぞれの形で起動して確かめる必要があります

なぜ厄介か

落ちるなら気づけます。この形は落ちません。

  • 例外が出ない
  • 終了コードは 0
  • 出力は 0行

タスクスケジューラなどで無人で走らせていると、「走ったが、することが無かった」回と
同じ顔で記録されます。

私はこれを、当社が無料で配っている診断スクリプトで踏みました。
配った翌日に自分でフルパスで起動して手組みの URL の件を見つけ、pathToFileURL に直しました。
その直し方がジャンクション経由では同じ形で黙ることは、あとから別の目のレビューで指摘され、自分で測り直して確かめました。
その次に入れた realpathSync の形も --preserve-symlinks-main 付きでは黙ったので、いまは上の直し方と同じ形にしています。

結果の先頭に見出しを出す作りや、何も確かめられなければ終了コード 1 にする作りは、この壊れ方には効きません。
どちらも main() の中にあり、main() が呼ばれないからです。
いまは、Node から子プロセスとして(フルパス・~・#・[x]・100%・ジャンクション経由・
--preserve-symlinks-main 付きで)起動し、出力が出ることを見るテストを置いています。
cmd は通していません。テストは v24.18.1 で走るので、通っているのは import.meta.main の側で、比較の側は見ていません。


確かめていないこと

  • 実測は Windows 11(10.0.26200)/ Node v24.18.1 の1台だけです
  • macOS・Linux(シンボリックリンクを含む)では確かめていません
  • CommonJS(require.main === module)の書き方は比べていません
  • 文字は上の表に書いたものしか測っていません
  • import.meta.main が無い版の Node では走らせていません(右側は写しで測りました)
  • PowerShell・Git Bash から起動したときは、この記事では測っていません

この判定を踏んだ診断スクリプトは、Windows で AI を無人で走らせるときの落とし穴を点検する道具です(無料・依存なし・読むだけで書き換えません・通信しません):

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?