isPortFree が :: に bind して衝突を確認するのに、サーバは 127.0.0.1 に bind します。まったく同じアドレスでしか EADDRINUSE は起きないので、probe は既定構成の稼働中インスタンスを検出できません。
結果、#611 / #653 で入れたポートレベルの二重起動ガードが、既定構成では一度も発火しません。
症状(observed、2026-08-27 実測)
MulmoTerminal が 34567 で動いている状態で、レジストリが空の HOME から 2 個目を起動:
[mulmoterminal] Starting MulmoTerminal on port 34567...
────────────────────────────────
✓ MulmoTerminal is ready ← 起動していない
→ http://localhost:34567 ← 応答したのは既存のサーバ
────────────────────────────────
[mulmoterminal] Port 34567 is already in use. (exit 1)
最終 exit code は 1 で正しい。壊れているのは途中経過 —— 出るべき Port N is already in use. / Start a second instance anyway? [y/N] が spawn の前に出ないこと、そして代わりに他人のサーバの 200 を根拠に偽の ready バナーが出ること。
根本原因 — 同一ファイル内で住所が食い違っている
| 箇所 |
何を見るか |
結果 |
isPortFree (bin/mulmoterminal.js:249) |
probe.listen(port) = :: |
誰もいない → 「空いている」 |
probeOnce (bin/wait-ready.js:41) |
get({ host: "127.0.0.1", port }) |
既存サーバが 200 → 「準備完了」 |
同じポート番号について、片方は「空いている」、もう片方は「もう動いている」と言っています。両方とも自分の見ている住所については正しく、矛盾しているのは「このポートはどの住所のことか」という前提です。
実測(34567 を 127.0.0.1 で保持している実サーバに対して、macOS 26.4 / Node v24.19.0):
listen(34567) -> free ← 現状の isPortFree。見落とす
listen(34567, '::') -> free
listen(34567, '0.0.0.0') -> free
listen(34567, '127.0.0.1') -> BUSY (EADDRINUSE) ← 同一アドレスだけが検知する
listen(34567, '::1') -> free
流れ:
choosePort(34567, explicit)
└─ isPortFree(34567) → :: に bind できた → true「空いてる」
↓ 衝突を検知できないので二重起動の確認を素通り
runServer(34567)
├─ 子を spawn → 子は listen(34567, "127.0.0.1") → EADDRINUSE → exit 75
└─ 同時に waitUntilReady が 127.0.0.1:34567 を poll
└─ 既存サーバが 200 → onReady → ✓ ready バナー + ブラウザを開く
子が close(75) → cancelReady() → 手遅れ
main が portInUseMessage を出して exit 1
いつ壊れたか — ドリフト
host 無し bind は当時は意図的で正しい判断でした。isPortFree の真上のコメントがそう書いています:
Binds without a host — same as the server's server.listen(port) (the :: dual-stack address) — so the probe and the real bind agree on availability. Probing 127.0.0.1 here let a port held only on :: slip through as "free".
| commit |
日付 |
何が起きたか |
6e6b1207 (#31) |
2026-06-17 |
「サーバと同じ :: を見る」を根拠に probe を host 無しに。当時は正しい |
b696a967 |
2026-07-26 |
サーバが BIND_HOST = 127.0.0.1 既定に変更。bin/mulmoterminal.js は 1 行も触っていない(git show --name-only で 0 hits) |
7/26 に前提が崩れ、コメントは以来ずっと事実と逆のことを主張しています。片側だけが動いて相棒が取り残された形です。
影響の切り分け(誇張しないため)
提案する直し方
probe を固定 host にするのではなく、BIND_HOST に追随させる。
// bin/mulmoterminal.js — server/config/env.ts の BIND_HOST と同じ式
const BIND_HOST = process.env.MULMOTERMINAL_HOST || "127.0.0.1";
probe.listen(port, BIND_HOST); // host 無し(= ::)をやめる
なぜ「127.0.0.1 に固定」ではないか —— それは #31 が直した不具合を逆向きに再発させます。コメントの後半がまさにその警告です("Probing 127.0.0.1 here let a port held only on :: slip through as free")。MULMOTERMINAL_HOST=0.0.0.0 の人はサーバがワイルドカードに bind するので、probe も同じでなければいけません。
なぜこれで十分か —— choosePort が答えるべき問いは「誰かがこのポートを使っているか」ではなく 「子の listen(port, BIND_HOST) は成功するか」 です。同じアドレスを試せばそれを正確に予測できます。そして検知が直れば spawn しなくなるので、偽 ready バナーも自動的に消えます(バナーは検知失敗の結果であって独立したバグではない)。
#1834 の loopback listener は答えを変えません: MULMOTERMINAL_HOST を広げると 127.0.0.1 にも追加 listen しますが、あれは best effort で EADDRINUSE でも致命的にしない(warn して続行)ので、起動の成否を決めるのは primary の bind だけです。
承知のうえで残る穴
ドリフト再発の防止
今のコメントは事実と逆を書いていて、それ自体が次の事故の種です。書き換えたうえで、BIND_HOST と同じ env 変数を読む形にすればサーバ側がまた動いても probe が追随します。
"127.0.0.1" という既定値リテラルが bin/(素の JS、TS を import 不可)に重複しますが、この repo には前例と対処法があります: PORT_IN_USE_EXIT_CODE = 75 が bin/mulmoterminal.js と server/infra/server-exit.ts に重複していて、test/server/infra/server-exit.spec.ts:62 が launcher のソースを読んで両者の一致をテストで固定しています。同じ手を使います。
関連
環境
macOS 26.4 (Darwin 25.5.0) / Node v24.19.0 / mulmoterminal は #1873 マージ後の main (7643b432)
isPortFreeが::に bind して衝突を確認するのに、サーバは127.0.0.1に bind します。まったく同じアドレスでしか EADDRINUSE は起きないので、probe は既定構成の稼働中インスタンスを検出できません。結果、#611 / #653 で入れたポートレベルの二重起動ガードが、既定構成では一度も発火しません。
症状(observed、2026-08-27 実測)
MulmoTerminal が 34567 で動いている状態で、レジストリが空の HOME から 2 個目を起動:
最終 exit code は 1 で正しい。壊れているのは途中経過 —— 出るべき
Port N is already in use./Start a second instance anyway? [y/N]が spawn の前に出ないこと、そして代わりに他人のサーバの 200 を根拠に偽の ready バナーが出ること。根本原因 — 同一ファイル内で住所が食い違っている
isPortFree(bin/mulmoterminal.js:249)probe.listen(port)=::probeOnce(bin/wait-ready.js:41)get({ host: "127.0.0.1", port })同じポート番号について、片方は「空いている」、もう片方は「もう動いている」と言っています。両方とも自分の見ている住所については正しく、矛盾しているのは「このポートはどの住所のことか」という前提です。
実測(34567 を
127.0.0.1で保持している実サーバに対して、macOS 26.4 / Node v24.19.0):流れ:
いつ壊れたか — ドリフト
host 無し bind は当時は意図的で正しい判断でした。
isPortFreeの真上のコメントがそう書いています:6e6b1207(#31)::を見る」を根拠に probe を host 無しに。当時は正しいb696a967BIND_HOST = 127.0.0.1既定に変更。bin/mulmoterminal.jsは 1 行も触っていない(git show --name-onlyで 0 hits)7/26 に前提が崩れ、コメントは以来ずっと事実と逆のことを主張しています。片側だけが動いて相棒が取り残された形です。
影響の切り分け(誇張しないため)
portInUseAction/secondInstancePrompt、refactor: #598 の範囲外に残る判断ルールを pure 関数に出してテストで固定する(棚卸し 第2弾) #611 / feat: 二重起動時に警告して y/n で起動するか聞く #653)。既定 bind に対して発火しないconfirmNoRunningInstance()(bug(windows): a second instance deletes the RUNNING instance's session settings — surviving is always empty without tmux #1061、インスタンスレジストリを読む)。上の再現が素通りしたのは scratch HOME でレジストリが空だったため。壊れているのは二次防御で、一次防御は生きていますMULMOTERMINAL_HOMEを変えている、レジストリ書き込み前のクラッシュ等)提案する直し方
probe を固定 host にするのではなく、
BIND_HOSTに追随させる。なぜ「127.0.0.1 に固定」ではないか —— それは #31 が直した不具合を逆向きに再発させます。コメントの後半がまさにその警告です("Probing 127.0.0.1 here let a port held only on
::slip through as free")。MULMOTERMINAL_HOST=0.0.0.0の人はサーバがワイルドカードに bind するので、probe も同じでなければいけません。なぜこれで十分か ——
choosePortが答えるべき問いは「誰かがこのポートを使っているか」ではなく 「子のlisten(port, BIND_HOST)は成功するか」 です。同じアドレスを試せばそれを正確に予測できます。そして検知が直れば spawn しなくなるので、偽 ready バナーも自動的に消えます(バナーは検知失敗の結果であって独立したバグではない)。#1834の loopback listener は答えを変えません:MULMOTERMINAL_HOSTを広げると 127.0.0.1 にも追加 listen しますが、あれは best effort で EADDRINUSE でも致命的にしない(warn して続行)ので、起動の成否を決めるのは primary の bind だけです。承知のうえで残る穴
MULMOTERMINAL_HOST=0.0.0.0の人が、既定構成の別インスタンス(127.0.0.1)と衝突する場合。上の実測どおりワイルドカードの probe は見落とします。これはレジストリ(bug(windows): a second instance deletes the RUNNING instance's session settings — surviving is always empty without tmux #1061)の担当で、そちらは動いていますmainのコメントが既に認めているもので、ここだけは spawn するため理論上まだ偽バナーが出ます。waitUntilReadyが「自分の起動したサーバか」を確認する(nonce / pid 照合)という hardening が考えられますが、本件とは別ですドリフト再発の防止
今のコメントは事実と逆を書いていて、それ自体が次の事故の種です。書き換えたうえで、
BIND_HOSTと同じ env 変数を読む形にすればサーバ側がまた動いても probe が追随します。"127.0.0.1"という既定値リテラルがbin/(素の JS、TS を import 不可)に重複しますが、この repo には前例と対処法があります:PORT_IN_USE_EXIT_CODE = 75がbin/mulmoterminal.jsとserver/infra/server-exit.tsに重複していて、test/server/infra/server-exit.spec.ts:62が launcher のソースを読んで両者の一致をテストで固定しています。同じ手を使います。関連
isPortFree/waitUntilReady/printReadyBanner/choosePortのいずれも変更しておらず、--port 34567でも envPORT=34567でも同じく再現するため、fix(cli): PORT を launcher が読み、server へは argv で渡す (#1861, #1857) #1873 が作り込んだものではありません(Codex もスコープ外に同意)環境
macOS 26.4 (Darwin 25.5.0) / Node v24.19.0 / mulmoterminal は #1873 マージ後の main (
7643b432)