UDP パケット通信を行う

tokio の UdpSocket で、応答待ちと再送、ブロードキャストでの機器探索、固定ポートでの待ち受けとイベント通知を行う。Windows 特有のエラーとファイアウォールの注意も示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約9分 net-017
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. 送って応答を待つ(再送付き)
  4. ブロードキャストで機器を探す
  5. 決まったポートで受信し続ける
  6. 2. フロントエンドから呼び出す (TypeScript)
  7. 動作確認
  8. よくあるエラーと対処法
  9. 注意点
  10. 関連レシピ

LAN 内の機器を探す、センサーや計測器から値を受け取る、機器に短い制御コマンドを送るといった用途では UDP がよく使われます。接続の手順がなく、1 回の送信(データグラム)が区切りを保ったまま届く一方で、届く保証も順番の保証もありません。Webview の JavaScript には UDP を扱う API がないので、Rust 側で tokio の UdpSocket を使い、結果を invoke の戻り値かイベントで画面に渡します。接続を保って確実に届けたいなら TCP ソケット通信を行う を使います。

前提条件

プラグインと capability の権限は不要です。受信の通知に使う listen の権限は core:default に含まれます。Tauri の非同期ランタイムは tokio なので、ソケットとタイマーの機能を有効にした tokio を加えるだけで使えます。

cd src-tauri
cargo add tokio --features net,time

この記事では 3 つの使い方を示します。

使い方ソケットの作り方
機器に送って応答を 1 つ待つポート番号は OS に任せ、connect() で相手を固定
ブロードキャストで機器を探すポート番号は OS に任せ、set_broadcast(true)
決まったポートで受信し続けるポート番号を指定して bind()、受信タスクを State で管理

1. バックエンドから実装する (Rust)

送って応答を待つ(再送付き)

UDP ではパケットが途中で失われても誰も教えてくれないので、応答が来なければタイムアウトして送り直します。connect() は接続を作るわけではなく、送信先を決めて、それ以外のアドレスから届いたものを受け取らないようにする設定です。相手のポートで誰も待ち受けていないと、少なくとも Windows ではタイムアウトを待たずに受信がエラーになります。

use std::time::Duration;
use serde::Serialize;
use tokio::net::UdpSocket;
use tokio::time::{timeout, timeout_at, Instant};

/// 送って応答を 1 つ待つ。1 秒で来なければ 3 回まで送り直す
#[tauri::command]
async fn udp_request(target: String, message: String) -> Result<String, String> {
    let socket = UdpSocket::bind("0.0.0.0:0").await.map_err(|e| e.to_string())?; // ポートは OS に任せる
    socket.connect(&target).await.map_err(|e| e.to_string())?;
    let mut buf = vec![0u8; 2048];
    for _ in 0..3 {
        socket.send(message.as_bytes()).await.map_err(|e| e.to_string())?;
        match timeout(Duration::from_secs(1), socket.recv(&mut buf)).await {
            Ok(Ok(n)) => return Ok(String::from_utf8_lossy(&buf[..n]).into_owned()),
            Ok(Err(e)) => return Err(e.to_string()), // 相手のポートが閉じているなど
            Err(_) => continue, // 行きか帰りのパケットが失われた
        }
    }
    Err(format!("{target} から応答がありません"))
}

送り直しを前提にするので、同じ要求が 2 回届いても困らない内容(「値を返せ」「出力を 50% にせよ」など)にしておきます。送りっぱなしでよい通知なら、send_to() だけで済みます。

ブロードキャストで機器を探す

宛先を 255.255.255.255 にすると、同じネットワークの全機器に届きます。set_broadcast(true) を忘れると送信の時点でエラーになります。応答はいくつ来るか分からないので、決めた時間だけ集めて返します。

#[derive(Clone, Serialize)]
struct Datagram {
    from: String,
    text: String,
}

/// ブロードキャストを 1 回送り、wait_ms の間に届いた応答をすべて集める
#[tauri::command]
async fn udp_discover(port: u16, message: String, wait_ms: u64) -> Result<Vec<Datagram>, String> {
    let socket = UdpSocket::bind("0.0.0.0:0").await.map_err(|e| e.to_string())?;
    socket.set_broadcast(true).map_err(|e| e.to_string())?;
    socket
        .send_to(message.as_bytes(), ("255.255.255.255", port))
        .await
        .map_err(|e| e.to_string())?;

    let deadline = Instant::now() + Duration::from_millis(wait_ms);
    let mut found = Vec::new();
    let mut buf = vec![0u8; 2048];
    loop {
        match timeout_at(deadline, socket.recv_from(&mut buf)).await {
            Ok(Ok((n, from))) => found.push(Datagram {
                from: from.to_string(),
                text: String::from_utf8_lossy(&buf[..n]).into_owned(),
            }),
            Ok(Err(_)) => continue, // 1 件の失敗では打ち切らない
            Err(_) => break,        // 待ち時間が終わった
        }
    }
    Ok(found)
}

決まったポートで受信し続ける

受信し続けるソケットは受信タスクに持たせ、タスクの JoinHandle を State に置いて止められるようにします。受信ループはエラーでも抜けないようにします。Windows では、同じソケットから送った先のポートが閉じていると次の受信が 1 回だけエラーになり、そこで抜けると以後何も受け取れなくなるためです。

use tauri::async_runtime::JoinHandle;
use tauri::{AppHandle, Emitter, State};

/// 待ち受け中のポートと受信タスク
#[derive(Default)]
struct UdpListener(std::sync::Mutex<Option<(u16, JoinHandle<()>)>>);

#[tauri::command]
async fn udp_listen(app: AppHandle, state: State<'_, UdpListener>, port: u16) -> Result<(), String> {
    {
        let mut current = state.0.lock().map_err(|e| e.to_string())?;
        if current.as_ref().is_some_and(|(p, _)| *p == port) {
            return Ok(()); // 同じポートで待ち受け中(再読み込みで二重に呼ばれた)
        }
        if let Some((_, old)) = current.take() {
            old.abort(); // 別のポートに切り替える
        }
    }
    let socket = UdpSocket::bind(("0.0.0.0", port))
        .await
        .map_err(|e| format!("ポート {port} で待ち受けできません: {e}"))?;
    let task = tauri::async_runtime::spawn(async move {
        let mut buf = vec![0u8; 65536]; // UDP の 1 件の最大サイズまで受けられる大きさ
        loop {
            match socket.recv_from(&mut buf).await {
                Ok((n, from)) => {
                    let text = String::from_utf8_lossy(&buf[..n]).into_owned();
                    let _ = app.emit("udp-message", Datagram { from: from.to_string(), text });
                }
                Err(e) => eprintln!("udp recv error: {e}"), // 抜けずに受信を続ける
            }
        }
    });
    *state.0.lock().map_err(|e| e.to_string())? = Some((port, task));
    Ok(())
}

#[tauri::command]
fn udp_unlisten(state: State<'_, UdpListener>) -> Result<(), String> {
    if let Some((_, task)) = state.0.lock().map_err(|e| e.to_string())?.take() {
        task.abort(); // ソケットはタスクと一緒に閉じる
    }
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(UdpListener::default())
        .invoke_handler(tauri::generate_handler![udp_request, udp_discover, udp_listen, udp_unlisten])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

State の Mutex は .await をまたがないので標準ライブラリのもので足ります(State と Mutex でアプリの状態を管理する)。

2. フロントエンドから呼び出す (TypeScript)

受信の通知は udp_listen の前に登録します。引数名 wait_ms は JS では waitMs です。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

type Datagram = { from: string; text: string };

// 送って応答を 1 つ待つ
const reply = await invoke<string>('udp_request', { target: '127.0.0.1:9001', message: 'ping' });
console.log('応答:', reply);

// 同じネットワークの機器を探す(1.5 秒の間に来た応答を集める)
const found = await invoke<Datagram[]>('udp_discover', { port: 9001, message: 'DISCOVER', waitMs: 1500 });
found.forEach((d) => console.log('見つかった:', d.from, d.text));

// 9002 番で受信し続ける
await listen<Datagram>('udp-message', ({ payload }) => {
  console.log(`受信 ${payload.from}: ${payload.text}`);
});
await invoke('udp_listen', { port: 9002 });

動作確認

受け取ったものを送り主に返すサーバーを、Node.js で別のターミナルに立てます。全アドレスで待ち受けるので、Windows ではここでファイアウォールの確認が出ることがあります。

node -e "const s = require('dgram').createSocket('udp4'); s.on('message', (m, r) => s.send(m, r.port, r.address)); s.bind(9001)"

npm run tauri dev で起動して上のコードを実行し、さらに別のターミナルから 9002 番へ送ると、コンソールに次のように出ます。ブロードキャストへの応答は、自分の PC からでも 127.0.0.1 ではなく LAN 側のアドレスから届きます。

node -e "const s = require('dgram').createSocket('udp4'); s.send('hello', 9002, '127.0.0.1', () => s.close())"
応答: ping
見つかった: 192.168.1.23:9001 DISCOVER
受信 127.0.0.1:61234: hello

よくあるエラーと対処法

  • 「アクセス許可で禁じられた方法でソケットにアクセスしようとしました。 (os error 10013)」: set_broadcast(true) をせずにブロードキャストアドレスへ送っています(Windows の文言)。
  • 「通常、各ソケット アドレスに対してプロトコル、ネットワーク アドレス、またはポートのどれか 1 つのみを使用できます。 (os error 10048)」: そのポートを別のプログラムか、自分のアプリの前の待ち受けが使っています。空いているポートの探し方は 空いているポート番号を探して使う を参照してください。
  • 「既存の接続はリモート ホストに強制的に切断されました。 (os error 10054)」: UDP には接続がありませんが、Windows では送った先のポートが閉じていたことがこの文言で返ります。宛先のポート番号と、相手のプログラムが動いているかを確かめます。
  • os error 10040: 受信バッファーが届いたデータグラムより小さいと、Windows ではエラーになります(ほかの OS でもはみ出た分は捨てられます)。バッファーは想定する最大サイズより大きくします。
  • 自分の PC からは届くのに、他の PC からは届かない: 受信側のファイアウォールで止められています。下の「注意点」を確認します。

注意点

  • Windows のファイアウォール: 全アドレスで待ち受けると、初回に通信の許可を求める確認が出ることがあります。許可はプログラムのファイルの場所ごとに記録されるので、開発中の実行ファイル(src-tauri/target/debug の中)で許可しても、インストールしたアプリでは改めて確認が出ます。確認をキャンセルすると受信をブロックする規則が作られ、以後は確認なしで届かなくなるので、Windows セキュリティの「ファイアウォールとネットワーク保護」から許可し直します。
  • macOS / Linux のファイアウォール: macOS はファイアウォールを有効にしている場合に受信の許可を確認されます。Linux で ufw などを使っている場合は、sudo ufw allow 9002/udp のようにポートを開けます。
  • ブロードキャストが届く範囲: ルーターを越えず、同じネットワークの中だけに届きます。VPN や仮想ネットワークのアダプターがある PC では目的の LAN に出ないことがあるので、192.168.1.255 のようにそのネットワークのブロードキャストアドレスへ送ると確実です。機器が対応していれば、標準的な探索の仕組みである mDNS も使えます。
  • 1 回に送る大きさ: 大きなデータグラムは途中で分割され、1 片でも失われると全体が届きません。1,400 バイト程度までに収め、大きなデータは TCP で送ります。
  • 送り主は偽れる: recv_from() の送信元アドレスは簡単に偽装できるので、認証には使いません。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する