Neovim内ターミナル運用のためのnvim-proxyを作った(前編)
まず何を作ったか
Neovimの :terminal から動くgit/codexを、常に「親のNeovim」に接続させるためのプロキシを作りました。
前提として、私は「ターミナルはNeovim内に閉じ込める」派です。
:terminal にこだわる理由や運用のクセについては、以前の記事で触れています
(例: NeovimのTerminalモードをもうちょっと使いやすくする)。
今回はその延長として、Neovim内ターミナルからのプロセス連携を安定させる仕組みをまとめます。
対象読者
- Neovimの
:terminalからgitや外部ツールを使う人 - Denopsでサーバを立てて、Neovimと外部を繋ぎたい人
背景
以前、「未保存バッファのままコミットしそうになるのを防ぐ」ために、
DenopsでHTTPサーバを立ててpre-commitから叩く仕組みを作りました。
詳しくは次の記事で書いています。
":terminal in Neovimでpre-commitフックを使って未保存のバッファをチェックする"
ただ、この方式には課題がありました。
サーバのポートがランダムだったため、
- Denopsの再起動でURLが変わる
- 既存のTerminalからcommitできなくなる
- 設定ファイルなどで接続先を指定しにくい
そこで「terminalから常に同じ入口へ投げられる仕組み」が必要になりました。
解決策: nvim-proxy
やったことはシンプルです。
-
127.0.0.1:37125に常駐のプロキシを立てる- Neovimインスタンスごとの入口ではなく、共通の入口にする
-
pid + pathで登録された転送先に振り分ける - インスタンスごとに起動される子サーバーは起動時に自分の転送先を登録する
転送先の仕様
転送先の登録に際しては、以下のようなデータを受け付けておきます。
-
X-Nvim-Pidヘッダ必須 -
/registerでpid + proxy_path + reverse_port + reverse_pathを受け取って登録 - 以後、pidとpathでreverse proxyの先を選択する
各子サーバーは起動時に/registerを呼び、自分の転送先をnvim-proxyへ登録します。
子サーバー側を起動したあとの処理は次のようになっています。
const payload = {
pid: options.pid,
proxy_path: "/dirty-bufs",
reverse_port: options.port,
reverse_path: "",
};
await fetch(registerUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
proxyに対して、自身のpidとプロキシに使用するpath、自身のportを渡していますね。
参照:
nvim-proxy側は/registerを受けて、pidとpathでルートを登録します。
if (pathname === "/register" && req.method === "POST") {
return await handleRegister(req);
}
routes.set(record.proxy_path, {
reverse_port: record.reverse_port,
reverse_path: record.reverse_path,
failureCount: 0,
});
instances.set(pid, { pid, routes });
参照:
プロキシへのアクセス方法
プロキシの起動時にNVIM_PROXY_URLとNVIM_PIDを環境変数として設定しています。
const pid = await fn.getpid(denops);
await vars.e.set(denops, "NVIM_PROXY_URL", DEFAULT_PROXY_URL);
await vars.e.set(denops, "NVIM_PID", String(pid));
参照:
:terminal から起動したプロセスはこれを引き継ぐので、
git や curl 側は X-Nvim-Pid をヘッダにつければプロキシ先の子サーバーを指定できます。
echo "$NVIM_PID"
echo "$NVIM_PROXY_URL"
curl -H "X-Nvim-Pid:${NVIM_PID}" -Ss http://127.0.0.1:37125/dirty-bufs
実際に使っている例
最初に紹介したpre-commit改め、dirty-bufsもこの仕組みに載せました。
(commit前のチェックだけではなく、git statusにも表示したくなったので名前を変えました)
pre-commitからの呼び出しは、次のようにNVIM_PROXY_URLへ投げています。
payload="$(jq -n --arg dir "${dir}" '{
dir: $dir
}')"
response="$(curl -XPOST -sSL --max-time 5 -H "X-Nvim-Pid: ${NVIM_PID}" \
-w '\n%{http_code}' "${NVIM_PROXY_URL}/dirty-bufs" -d "${payload}" 2>/dev/null)"
参照:
補足
他にも、多少使いやすくするために色々サポートしています
サービス化 (systemd/launchd)
プロキシサーバーは固定ポートで常駐させるためにサービス化しています。
- Linux: systemd user service
- macOS: launchd
サービスとしてのインストール、起動、ステータスなどはコマンドを用意しました。
Neovim側コマンド:
:NvimProxyInstall
:NvimProxyStart
:NvimProxyRestart
:NvimProxyStatus
:NvimProxyLog
checkhealth 連携
:NvimProxyStatusでも見られるんですが、どうせならNeovimの既存の仕組みに乗るべく、
:checkhealth nvim_proxy で状態とルート一覧を表示できるようにしました。
ルート寿命管理と永続化
ルートは登録したら登録しっぱなしになってしまうとどんどんプロキシ先が増えてしまうので、healthチェックによって死活監視、活性のない転送先は削除するようにしました。
-
/healthを10分間隔でチェック - 3回失敗でpath単位削除
-
routes.jsonに保存してプロキシサーバーの再起動時に復元
保存先: ~/.local/state/nvim-proxy/routes.json
実際の監視は/healthを叩いて失敗回数を積み、上限で削除します。
const ok = await checkHealth(route.targetUrl, timeoutMs);
if (ok) {
route.failureCount = 0;
continue;
}
route.failureCount += 1;
if (route.failureCount >= failureLimit) {
instance.routes.delete(path);
changed = true;
}
参照:
まとめ
今回作ったものは、ざっくり言うと
「Neovim内ターミナルのための固定入口」です。
- Neovim内ターミナルからでも確実に親Neovimに届く
- dirty-bufsを同じ入口で安定運用できる
- 複数インスタンスでもPIDで区別できる
- サービスとして安定稼働する
後編では、この仕組みを使って作ったMCPサーバの実装と運用の要点をまとめます。
Discussion