◉ANTIGRAVITY LABEN
記事一覧/連携・プラグイン
⬡ 連携・プラグイン/2026-08-08上級

止めたはずの MCP サーバが残り続ける — 停止方法 4 種を 84 回測って見えた setsid の壁

セッションを閉じたのに MCP サーバがポートを握ったまま残る。停止方法 4 種を 84 試行で測ると、プロセスグループ宛の SIGTERM は孤児率 0% でも、子が setsid した瞬間に 100% へ戻りました。/proc の ppid を辿る子孫スイープの実装まで。

mcp15processsigtermsetsidteardownmeasurement

✦ プレミアム記事

朝いちばんにセッションを開いたら、MCP サーバが 1 台だけ立ち上がりませんでした。

ログには address already in use の一行。ポートを握っていたのは、前夜のセッションで止めたはずの、まさに同じサーバでした。

セッションは確かに閉じています。ホスト側のプロセスも消えています。それでも子は生きています。個人開発で回している構成なので、朝の一台が止まっていることに気づけました。台数と人数が増えれば、この手の取り残しは静かに積み上がります。

手元の ps を眺めながら、「止めた」と「止まった」の間にどれだけの隙間があるのかを、一度きちんと測っておきたくなりました。

この手の取り残しを「たまたま」で片付けていたのだと気づいたのは、条件を揃えて数え直したあとでした。

停止方法だけを変えて、同じツリーを 84 回作り直す

MCP サーバは多くの場合、ホストから子プロセスとして起動されます。sh -c を挟み、ランチャを挟み、その先に実体がいます。この途中の一段でもシグナルを飲み込めば、末端は取り残されます。

そこで、実運用に近い 3 段のツリーを最小構成で再現しました。末端は TCP ポートを掴んだまま待ち続けるだけのサーバです。ポートを持たせたのは、「生きているかどうか」を主観ではなく次の bind の成否で判定したかったからです。

# srv.py — ポートを掴んで待つだけの末端プロセス
import socket, sys, os, time
 
port, mode, tag = int(sys.argv[1]), sys.argv[2], sys.argv[3]
if mode == "own_group":
    os.setsid()                     # 自分で新しいセッション/プロセスグループを作る
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port))
s.listen(8)
open(tag + ".srv.pid", "w").write(str(os.getpid()))
while True:
    time.sleep(0.05)
# wrap.py — 子を起動して待つだけのラッパー(シグナルを転送しない)
import subprocess, sys, os
 
port, mode, tag = sys.argv[1], sys.argv[2], sys.argv[3]
open(tag + ".wrap.pid", "w").write(str(os.getpid()))
subprocess.Popen([sys.executable, "srv.py", port, mode, tag]).wait()

シグナルを転送しないラッパーは、意地悪な仮定ではありません。パッケージランナーやシェルスクリプト経由でサーバを起動している構成では、むしろこちらが既定の振る舞いです。

ホスト側は start_new_session=True でツリーを起動し、LISTEN を確認してから停止方法を適用します。停止後は 1.5 秒を上限に、末端プロセスの生存とポートの再 bind 可否を 10ms ごとに観測しました。

各条件 12 試行、全 7 条件で 84 試行。起動から LISTEN までは中央値 41.5ms で、条件間の差はありませんでした。

PID に SIGTERM を送る — 12 回中 12 回、孤児が残る

最初はいちばん素朴な方法です。ホストが把握している先頭の PID に SIGTERM を送ります。

os.kill(top.pid, signal.SIGTERM)

結果は、孤児率 100%、ポート占有率 100%。12 回すべてで末端サーバが生き残り、12 回すべてで次の bind が失敗しました。

理由は単純です。SIGTERM はプロセス 1 つに届くだけで、子孫には伝播しません。ラッパーは素直に終了し、その瞬間に末端は親を失って PID 1 に引き取られます。ホストから見れば「起動したプロセスは消えた」ので、成功に見えてしまいます。

ここが厄介なところでした。停止処理の戻り値は正常です。ログにも異常は出ません。翌朝の address already in use まで、誰も失敗に気づけません。

✦

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
✦停止方法4種×84試行の実測。PID 宛 SIGTERM は孤児率 100%、プロセスグループ宛なら 0%、ただし子が setsid した瞬間に 100% へ戻る
✦TERM→猶予→KILL のエスカレーションは猶予 500ms が丸ごと無駄になる。12 回中 0 回が猶予中に終了し、正しく届けば 10.4ms で終わる
✦/proc の ppid を辿る子孫スイープの完全な実装。zombie 判定と PID 再利用ガードを含めて 10.8ms。/proc を持たない macOS 向けに ps で同じ木を組み直す版まで
Stripe による安全な決済 · いつでもキャンセル可能
✦

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

⬡ 連携・プラグイン2026-11-06
Conductor を Gemini CLI 拡張から Antigravity CLI のプラグインへ。spec.md と plan.md は動かさず、導入の前後だけ見比べる
Conductor が Gemini CLI の拡張機能からプラグインになり、Antigravity CLI でも使えるようになりました。conductor/ に溜めた spec.md と plan.md は移す必要がなく、導入の前後で何も変わっていないことを確かめる小さなスクリプトと手順をお伝えします。
⬡ 連携・プラグイン2026-11-03
スプレッドシートの手作業を Apps Script に置き換える——非エンジニアが Antigravity に出す最初の依頼
毎週のコピー&貼り付けを Apps Script に任せたい非エンジニア向けに、Antigravity への依頼文、消さない設計、ドライラン、トリガー設定、つまずく所の切り分けまでを一本の流れでまとめます。
⬡ 連携・プラグイン2026-11-02
Marketplace のパートナープラグインを入れる前に、導入ページで確かめる三つのこと
Antigravity 2.28.2 で Marketplace にパートナープラグインが並びました。入れる前に導入ページの開発元とリポジトリを見て、入れたあとは Accounts タブで接続を管理する——その線引きと、リポジトリの状態を確かめる短いコマンドをまとめます。