Skip to content

fix(cli): isPortFree が :: を probe するのにサーバは 127.0.0.1 に bind するので、二重起動ガードが既定構成で発火しない #1876

Description

@isamu

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 だけです。

承知のうえで残る穴

  • 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)の担当で、そちらは動いています
  • probe と実 bind の間のレース。main のコメントが既に認めているもので、ここだけは spawn するため理論上まだ偽バナーが出ます。waitUntilReady が「自分の起動したサーバか」を確認する(nonce / pid 照合)という hardening が考えられますが、本件とは別です

ドリフト再発の防止

今のコメントは事実と逆を書いていて、それ自体が次の事故の種です。書き換えたうえで、BIND_HOST同じ env 変数を読む形にすればサーバ側がまた動いても probe が追随します。

"127.0.0.1" という既定値リテラルが bin/(素の JS、TS を import 不可)に重複しますが、この repo には前例と対処法があります: PORT_IN_USE_EXIT_CODE = 75bin/mulmoterminal.jsserver/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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingminor軽微な不具合

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions