きっかけ
npm run dev を叩いたら EADDRINUSE 3000。また Node のゾンビが残っている気がする。macOS なら lsof -nP -iTCP:3000 -sTCP:LISTEN、Linux なら ss -Hlntp "( sport = :3000 )"、Windows なら netstat -ano | findstr :3000 の後に tasklist /FI "PID eq ..." — 3 OS それぞれに「同じことを違う呪文で」訊く必要があります。
SSH した先が Alpine と Ubuntu と macOS だったり、CI のランナーが Windows だったりすると、呪文ごとにフラグの順番まで微妙に違う。手癖にならず、その都度 --help を開きに行く羽目になります。
欲しかったのは 1 本だけ:
-
3 OS で同じ引数。
port-finder 3000が macOS でも Linux でも Windows でも動く -
出力も同じ形。
PORT / PID / COMMAND / USER / ADDRESSの 5 列固定 -
dual-stack を嘘つかない。
0.0.0.0:3000と[::]:3000は別のソケットなので、2 行で見せる -
--killで一撃。見てから別コマンドで kill する手間を省く -
--jsonで scripting。jqに流してダッシュボードへ -
cargo installで入る静的バイナリ。C ライブラリ依存なし
書いてみたら、3 OS で使う データフォーマットが本当にバラバラ で、パーサだけ見れば 3 本の別ツールを作ったのとほぼ同じでした。その「バラバラっぷり」が記事になるレベルだったので、残しておきます。
📦 GitHub: https://github.com/sen-ltd/port-finder
使い方
# 単発
$ port-finder 3000
# 複数
$ port-finder 3000 8080 5432
# 範囲
$ port-finder 3000-3100
# 引数なしで「全部見せて」
$ port-finder
# kill まで一撃
$ port-finder 3000 --kill # SIGTERM
$ port-finder 3000 --force # SIGKILL
# スクリプトへ
$ port-finder --json 8080 | jq '.listeners[] | {pid, command}'
終了コードは 3 値:
| Code | 意味 |
|---|---|
| 0 | 1 つ以上マッチ(または引数なしで全件表示) |
| 1 | 指定したポートに listener が無い |
| 2 | 引数不正・コマンド失敗・--kill が届かなかった |
本題 ─ OS ごとに完全に違うレイアウトを 1 本にまとめる
ここからが楽しいところです。各 OS の「誰が listen してるか」を返す仕組みは、インターフェイスも、文字列フォーマットも、PID への紐づけ方すら違います。port-finder は #[cfg(target_os = ...)] で 3 本のバックエンドに分けて、同じ Vec<Listener> にランディングさせます。
1. macOS — lsof -F の「1 行 1 フィールド」形式
macOS では lsof が事実上の標準です。素朴に叩くとこう:
$ lsof -nP -iTCP -sTCP:LISTEN
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
rapportd 1103 me 12u IPv4 ... 0t0 TCP *:57768 (LISTEN)
rapportd 1103 me 14u IPv6 ... 0t0 TCP *:57768 (LISTEN)
この列揃え出力はパースが辛い(COMMAND が Code Helper (Plugin) のように空白込みになるため)ので、-F で 1 行 1 フィールド にしてもらいます:
$ lsof -nP -iTCP -sTCP:LISTEN -F pcLnt
p1103
crapportd
Lme
f12
tIPv4
n*:57768
f14
tIPv6
n*:57768
各行の頭文字がタグです:
| タグ | 意味 | スコープ |
|---|---|---|
p |
PID — ここから新しいプロセスブロック | process |
c |
コマンド名 | process |
L |
ログイン名 | process |
f |
fd — ここから新しいファイルブロック | file |
t |
ファイル型 (IPv4 / IPv6) |
file |
n |
名前 — ソケットなら addr:port
|
file |
パーサは「現在の PID / コマンド / ユーザー」と「現在の fd のアドレスファミリ」を状態変数で持ち、n 行が来たら Listener を 1 つ emit します。f が来たらファイル側の状態をリセット、p が来たら全部リセット。普通のプロセス内ステートマシンで書けます。
for raw in input.lines() {
let (tag, value) = raw.split_at(1);
match tag {
"p" => { pid = Some(value.parse()?); command = None; user = None; ipv6 = false; }
"c" => command = Some(value.into()),
"L" => user = Some(value.into()),
"f" => ipv6 = false, // 新しい fd は型が来るまで不明
"t" => ipv6 = value == "IPv6",
"n" => { /* n<addr:port> を分解して push */ }
_ => {}
}
}
dual-stack の罠はここで処理します。lsof は IPv4 と IPv6 の両方の wildcard を *:57768 と同じ文字列で出してきます。t マーカーで IPv4 か IPv6 か分かるので、* をそれぞれ 0.0.0.0 と [::] に書き戻す。
let address = match addr {
"*" if ipv6 => "[::]".to_string(),
"*" => "0.0.0.0".to_string(),
other => other.to_string(),
};
これを怠ると、同一プロセスが本当に 2 つのソケットを持っている事実が見えなくなり、「なんで kill しても EADDRINUSE が消えないんだ…」という夜に繋がります。
2. Linux — /proc/net/tcp の 16 進とバイトオーダー
Linux は外部ツールを呼びません。/proc/net/tcp と /proc/net/tcp6 を直接読みます:
sl local_address rem_address st ... uid ... inode
0: 00000000:0BB8 00000000:0000 0A ... 1000 ... 987654 ...
小さい罠が 3 つ:
罠 1: LISTEN 状態のフィルタ
st カラムの 0A が TCP_LISTEN です(include/net/tcp_states.h に定義)。ここで絞らないと ESTABLISHED や TIME_WAIT が混ざります。
罠 2: アドレスは __be32 を %X で印字したもの
つまり リトルエンディアンのホストではバイトが逆順 に出力されます。例えば 0100007F は 127.0.0.1 です:
-
u32::from_str_radix("0100007F", 16)→0x0100007F(ホスト u32 値) -
.to_le_bytes()→[0x7F, 0x00, 0x00, 0x01]← 元のネットワーク順のバイト列
Ipv4Addr::from(u32) ではなく .to_le_bytes() 経由で配列にしてから Ipv4Addr::from([u8; 4]) に渡すのが肝です:
let word = u32::from_str_radix(addr_hex, 16)?;
Ipv4Addr::from(word.to_le_bytes()) // `Ipv4Addr::from(word)` じゃない
IPv6 も同じで、32 文字を 8 文字ずつ 4 回同じ処理を回し、16 バイトを作って Ipv6Addr::from([u8; 16]) に渡せば終わり。
罠 3: ポートは ntohs 済みで host order
カーネルは IP アドレスは __be32 をそのまま印字しますが、ポートは ntohs(inet->inet_sport) でホスト順に直してから %X で出しています(net/ipv4/tcp_ipv4.c の get_tcp4_sock)。なので:
let port = u16::from_str_radix(port_hex, 16)?; // そのまま
0x0BB8 = 3000。バイトスワップ不要。ここで気を利かせて swap すると、ポート番号が何故か 0xB80B (47115) になって「動かない…」と数十分溶けます。
inode → PID の紐づけ
/proc/net/tcp が返すのはソケットの inode 番号だけ。どのプロセスが持っているかは別途調べる必要があります。方法は /proc/[0-9]+/fd/* を総なめして readlink()、socket:[<inode>] 形式のリンク先と突合:
fn scan_proc_sockets() -> HashMap<u64, u32> {
let mut map = HashMap::new();
for entry in std::fs::read_dir("/proc")?.flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else { continue };
let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { continue };
for fd in fds.flatten() {
let Ok(link) = std::fs::read_link(fd.path()) else { continue };
if let Some(inode) = extract_socket_inode(&link.to_string_lossy()) {
map.entry(inode).or_insert(pid);
}
}
}
map
}
プロセス名は /proc/<pid>/comm、ユーザー名は /etc/passwd を自前で舐めて UID を名前に変換。全部 stdlib だけで済みます。sudo なしで動かす場合、他ユーザーの /proc/<pid>/fd を読むと permission denied になるので、所有プロセス以外の inode は見つからない可能性があります。これは lsof も同じ制約。
3. Windows — netstat + tasklist の 2 段構え
Windows には lsof も /proc も無いので、外部コマンド 2 本の出力を組み合わせます:
> netstat -ano -p TCP
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 964
TCP [::]:3000 [::]:0 LISTENING 12345
罠: IPv6 リテラルのコロン
127.0.0.1:3000 と [::1]:3000 の両方を同じパーサで扱う必要があります。単純な rsplit(':') だと IPv6 部分が壊れる。ブラケット外の最後のコロンで切る、という書き方になります:
pub fn split_address(s: &str) -> Option<(&str, &str)> {
let mut depth = 0i32;
let mut last = None;
for (i, ch) in s.char_indices() {
match ch {
'[' => depth += 1,
']' => depth -= 1,
':' if depth == 0 => last = Some(i),
_ => {}
}
}
let i = last?;
Some((&s[..i], &s[i + 1..]))
}
次に tasklist で PID をプロセス名に変換します:
> tasklist /FO CSV /NH
"System","4","Services","0","136 K"
"node.exe","12345","Console","1","100,032 K"
罠: メモリ欄のカンマ
最後の列 100,032 K にコンマが入っている。素朴な split(',') で切ると、PID 列が 1 つずれて全部壊れます。クォート対応の CSV パーサを書きます:
fn split_csv_row(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'"' => {
if in_quotes && chars.peek() == Some(&'"') {
cur.push('"'); chars.next(); // "" はエスケープされたクォート
} else {
in_quotes = !in_quotes;
}
}
',' if !in_quotes => out.push(std::mem::take(&mut cur)),
_ => cur.push(ch),
}
}
out.push(cur);
out
}
20 行のミニ CSV パーサですが、仕様通りの "" エスケープまで対応しておくと、万一別のフィールドにクォートが入っても壊れない。
Cross-platform にテストする戦略
3 バックエンドを作ると、CI で困ります。GitHub Actions の Ubuntu ランナーで macOS のパーサを走らせられない、という素朴な問題。
解決策は単純で、パース関数を pub fn parse(input: &str, ...) で外に出して、固定の文字列フィクスチャに対して呼ぶ。ライブコマンド呼び出しはそれぞれ #[cfg(target_os = "...")] pub fn find(...) で分け、parse からは独立しているので、どの OS にいても 3 本のパーサを全部テストできます。
// src/linux.rs
pub fn parse(tcp: &str, tcp6: &str, ports: &[u16]) -> Result<Vec<TcpEntry>, Error> { ... }
#[cfg(target_os = "linux")]
pub fn find(ports: &[u16]) -> Result<Vec<Listener>, Error> {
let tcp = std::fs::read_to_string("/proc/net/tcp").unwrap_or_default();
let tcp6 = std::fs::read_to_string("/proc/net/tcp6").unwrap_or_default();
parse(&tcp, &tcp6, ports)
.map(/* + inode → pid 解決 */)
}
#[cfg(not(target_os = "linux"))]
pub fn find(_: &[u16]) -> Result<Vec<Listener>, Error> { Err(Error::Unsupported) }
テストには実際の /proc/net/tcp / lsof -F / netstat -ano の出力を抜粋したフィクスチャ文字列を用意しました:
const TCP4: &str = "\
0: 00000000:0BB8 00000000:0000 0A ... 1000 ... 987654 ...
1: 0100007F:1F90 0100007F:C442 01 ... 0 ... 111111 ... // ESTABLISHED — スキップ
2: 0100007F:0050 00000000:0000 0A ... 0 ... 222222 ...
";
#[test]
fn listen_rows_only() {
let got = parse(TCP4, "", &[]).unwrap();
assert_eq!(got.len(), 2); // 01 の ESTABLISHED は除外
assert_eq!(got[0].port, 3000);
assert_eq!(got[0].address, "0.0.0.0");
}
これだけで、macOS 開発機でも Linux 専用のヘックス変換ロジックをテストできます。私の場合は実機検証は macOS の ./target/release/port-finder 7000 と、Linux の EC2 インスタンスで cargo test && ./target/release/port-finder、Windows は VirtualBox の Windows 11 VM で同じく、という流れ。
--kill は shell out で済ませる
kill は OS ごとに SIGTERM/SIGKILL の送り方が違いますが、kill(2) を libc で叩くほど大仰な話ではありません。Unix は kill -15 <pid> を Command で呼ぶ、Windows は taskkill /PID <pid> /F を呼ぶ、これで十分:
pub fn kill_pid(pid: u32, force: bool) -> Result<(), Error> {
#[cfg(unix)] {
let sig = if force { "-9" } else { "-15" };
Command::new("kill").arg(sig).arg(pid.to_string()).status()?;
}
#[cfg(windows)] {
let mut cmd = Command::new("taskkill");
cmd.arg("/PID").arg(pid.to_string());
if force { cmd.arg("/F"); }
cmd.status()?;
}
Ok(())
}
port-finder 3000 --kill が呼ばれたら、ユーザーに「何を kill するか」を見せてから、ユニークな PID の集合に対して 1 回ずつ送ります。同じプロセスが複数ポートを握っていても kill は 1 回だけ。
テスト
| 種類 | 数 | 内容 |
|---|---|---|
| lib unit (macos) | 7 | lsof -F パーサ、IPv4/IPv6 の wildcard 書き戻し、fd 境界での状態リセット |
| lib unit (linux) | 10 |
/proc/net/tcp パーサ、hex デコード、LISTEN フィルタ、inode 抽出、uid 解決 |
| lib unit (windows) | 7 | netstat 列パーサ、ブラケット付き IPv6、tasklist CSV(コンマ・"" エスケープ) |
| lib unit (render) | 6 | テーブル幅計算、USER 列の自動省略、JSON 形状、null ユーザーの扱い |
| main unit | 5 | ポート範囲展開、重複デデュープ、不正値の拒否 |
| CLI integration | 6 |
--help / --version / 不正入力 / JSON 出力の wellformed 性 |
合計 42 tests, 0.3 秒で完走。全部スタティックなフィクスチャとポート範囲のテストなので、ネットワークも /proc もコンテナも不要。
test result: ok. 31 passed (lib)
test result: ok. 5 passed (main)
test result: ok. 6 passed (cli integration)
リリースプロファイル
おなじみの Rust バイナリ絞り:
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
依存は clap + serde + serde_json の 3 本だけなので、macOS (arm64) で 488 KB。ring も rustls も入ってないから、これくらい軽くなります。
おわりに
port-finder は「同じ質問を 3 OS で一貫して返す」という、ごく小さなユースケースに特化しています。ですが、その裏で lsof -F の 1 行 1 フィールド、/proc/net/tcp の hex 逆順、netstat の IPv6 ブラケットと tasklist CSV のコンマ、という 3 つの全く違うデータ世界 を通ってきます。
「同じ意味」を持つ情報がこれだけ違う形で保存されているのは OS の歴史そのもので、クロスプラットフォーム CLI を書く醍醐味はここに集約されています。cargo install --path . で入る 488 KB のバイナリが、そのデコーダ 3 本分を全部抱えていると思うと、軽くて地味な道具も少し誇らしい気がします。
次に「port 3000 誰だよ」と叫んだら、ぜひ。
