Open9

denoland/deno #7824 調査メモ

magurotunamagurotuna
import { serve } from "https://deno.land/std/http/server.ts";
const s = serve({ port: 8000 });
console.log("http://localhost:8000/");
for await (const req of s) {
  req.respond({ body: "Hello world\n" });
}

このコードを deno run --watch --unstable --allow-net net.ts で起動して、その後ファイルを編集してwatcherによる再起動をやると AddrInUse が出るとのこと。
手元の環境(Linux)では再現しなかったのと、issueではWindowsを使っている方からの報告が相次いでいることから、Windows固有のエラーと思われる。

magurotunamagurotuna

serve({ port: 8000 }) したときに呼び出される op は

cli/rt/30_net.js
  function listen({ hostname, ...options }) {
    const res = opListen({
      transport: "tcp",
      hostname: typeof hostname === "undefined" ? "0.0.0.0" : hostname,
      ...options,
    });

    return new Listener(res.rid, res.localAddr);
  }

より op_listen だと分かる。

op_listen は以下のように定義されている

cli/ops/net.rs
fn op_listen(
  state: &mut OpState,
  args: Value,
  _zero_copy: &mut [ZeroCopyBuf],
) -> Result<Value, AnyError> {
  let permissions = state.borrow::<Permissions>();
  match serde_json::from_value(args)? {
    ListenArgs {
      transport,
      transport_args: ArgsEnum::Ip(args),
    } => {
      {
        if transport == "udp" {
          super::check_unstable(state, "Deno.listenDatagram");
        }
        permissions.check_net(&args.hostname, args.port)?;
      }
      let addr = resolve_addr(&args.hostname, args.port)?;
      let (rid, local_addr) = if transport == "tcp" {
        listen_tcp(state, addr)?
      } else {
        listen_udp(state, addr)?
      };
      debug!(
        "New listener {} {}:{}",
        rid,
        local_addr.ip().to_string(),
        local_addr.port()
      );
      Ok(json!({
      "rid": rid,
      "localAddr": {
        "hostname": local_addr.ip().to_string(),
        "port": local_addr.port(),
        "transport": transport,
      },
      }))
    }
// 以下略

listen_tcp(state, addr)? が怪しそう

magurotunamagurotuna

listen_tcp の先頭の行に let std_listener = std::net::TcpListener::bind(&addr)?; がある

std::net の中身を追っていってみる。
ここで知りたいのは、LinuxだとOKでWindowsだとだめなのはなぜなのか、ということ。watcherで再起動する際、特別な処理はしていない(押さえたポートを解放するとか)が、Linuxではこれでも問題なく動いている。bindしたポートの解放処理はどこで行われるのか?(予想は何かがDropされたとき、だが、どうだろうか)

Rust標準ライブラリの TcpListener::bind が以下の通り

pub fn bind(addr: io::Result<&SocketAddr>) -> io::Result<TcpListener> {
    let addr = addr?;

    init();

    let sock = Socket::new(addr, c::SOCK_STREAM)?;

    // On platforms with Berkeley-derived sockets, this allows to quickly
    // rebind a socket, without needing to wait for the OS to clean up the
    // previous one.
    //
    // On Windows, this allows rebinding sockets which are actively in use,
    // which allows “socket hijacking”, so we explicitly don't set it here.
    // https://docs.microsoft.com/en-us/windows/win32/winsock/using-so-reuseaddr-and-so-exclusiveaddruse
    #[cfg(not(windows))]
    setsockopt(&sock, c::SOL_SOCKET, c::SO_REUSEADDR, 1 as c_int)?;

    // Bind our new socket
    let (addrp, len) = addr.into_inner();
    cvt(unsafe { c::bind(*sock.as_inner(), addrp, len as _) })?;

    // Start listening
    cvt(unsafe { c::listen(*sock.as_inner(), 128) })?;
    Ok(TcpListener { inner: sock })
}

何やら興味深いコメントがある 👀

On Windows, this allows rebinding sockets which are actively in use,
which allows “socket hijacking”, so we explicitly don't set it here.
https://docs.microsoft.com/en-us/windows/win32/winsock/using-so-reuseaddr-and-so-exclusiveaddruse

magurotunamagurotuna

ポートをあとから奪えるモードにしていると、"socket hijacking" が起きてセキュリティ的に危ないよ、みたいなことが書いてあった(多分)
なので windows ではこのポート奪えるモードにはしていない、ということっぽい。
一方、Windows以外ではポートあとから奪えるモードを設定しているため、以前使っていたポートが使用中であっても問題なく使える、ということが今回の問題の原因と考えられる。

ちなみに、windowsの SocketDrop を探しに行ったら、

// https://github.com/rust-lang/rust/blob/1.48.0/library/std/src/sys/windows/net.rs#L414-L418
impl Drop for Socket {
    fn drop(&mut self) {
        let _ = unsafe { c::closesocket(self.0) };
    }
}

が見つかった。 c::closesocket はWin32 API の
https://docs.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-closesocket
だと思われる。

magurotunamagurotuna

結局、watcherがファイル変更を検知して再起動をしようとするときに、今起動している分の後始末をきちんとしてから再起動をしないといけない、という問題に帰着

cli/file_watcher.rs
      select! {
        _ = debounce.next() => {
          is_file_changed = true;
          info!(
            "{} File change detected! Restarting!",
            colors::intense_blue("Watcher"),
          );
        },
        _ = func => {},
      };

具体的には↑で、ファイル変更検知が先に起こったら (=debounce.next() が先に Ready になったら)、func のほうをキレイにしないといけない。
tokio::select! のドキュメント を見ると、一番最初に完了したブランチ以外は "cancel" される、と書いてあるが、この "cancel" が具体的にどのような処理を表しているのかはよくわからない。

Refs:
https://users.rust-lang.org/t/how-can-i-terminate-a-tokio-task-even-if-its-not-finished/40641

magurotunamagurotuna

debounce.next() が先に終わったことによって select! が完了したら、その後に長い sleep を挟んで、select! 後にバインドしたポートが解放されているか を確認してみる。つまり、func によって押さえられたリソースの解放は select! の "canceling" によって行われるのか、を確認する。

magurotunamagurotuna

Linux上で上記の検証を行ったところ、長い sleep のときにはポートは解放されていた。つまり、リソース解放は問題なく行われているということ。
いよいよWindowsで検証しないとよくわからない状況になってきたので、なんとかする

magurotunamagurotuna

Denoのコードリーディングをしているときに気になった doc commentの修正漏れ
Returns a future. とあるけど実際はただの Result を返しているというもの

cli/resolve_addr.rs
/// Resolve network address. Returns a future.
pub fn resolve_addr(hostname: &str, port: u16) -> Result<SocketAddr, AnyError> {
  // Default to localhost if given just the port. Example: ":80"
// 以下略

https://github.com/denoland/deno/blob/v1.6.0/cli/resolve_addr.rs#L7 より)

これを直そうと思っていたら、to_socket_addrs() が同期APIで、tokioにそれの非同期版APIとして lookup_host という関数 が用意されていることを知った。
そのあたりをまとめて、テストも追加して、PRを立てた。

https://github.com/denoland/deno/pull/8743