4 minute read

일단 저번에 가상환경 밖에서 돌렸을 때 에러가 떴던 테스트의 에러 메세지부터 살펴보자.

  • node/test/parallel/test-net-socket-connect-without-cb.js
=== release test-net-socket-connect-without-cb ===                            
Path: parallel/test-net-socket-connect-without-cb
node:events:498
      throw er; // Unhandled 'error' event
      ^

Error: getaddrinfo ENOTFOUND localhost
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26)
Emitted 'error' event on Socket instance at:
    at emitErrorNT (node:internal/streams/destroy:170:8)
    at emitErrorCloseNT (node:internal/streams/destroy:129:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:82:21) {
  errno: -3007,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'localhost'
}
  • node/test/parallel/test-tcp-wrap-listen.js
=== release test-tcp-wrap-listen ===                                          
Path: parallel/test-tcp-wrap-listen
(node:377826) internal/test/binding: These APIs are for internal testing only. Do not use them.
(Use `node --trace-warnings ...` to show where the warning was created)
node:events:498
      throw er; // Unhandled 'error' event
      ^

Error: getaddrinfo ENOTFOUND localhost
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (node:dns:109:26)
Emitted 'error' event on Socket instance at:
    at emitErrorNT (node:internal/streams/destroy:170:8)
    at emitErrorCloseNT (node:internal/streams/destroy:129:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:82:21) {
  errno: -3007,
  code: 'ENOTFOUND',
  syscall: 'getaddrinfo',
  hostname: 'localhost'
}

localhost에 연결을 시도하고 있는 것 같다. node/test/parallel/test-net-socket-connect-without-cb.js 코드에 로그를 찍어서 client.connect 함수에 뭔 인자가 넘어가는지 한 번 보자.

'use strict';
const common = require('../common');

// This test ensures that socket.connect can be called without callback
// which is optional.

const net = require('net');

const server = net.createServer(common.mustCall(function(conn) {
  conn.end();
  server.close();
})).listen(0, common.mustCall(function() {
  const client = new net.Socket();

  client.on('connect', common.mustCall(function() {
    client.end();
  }));

  const address = server.address();
  if (!common.hasIPv6 && address.family === 'IPv6') {
    // Necessary to pass CI running inside containers.
    client.connect(address.port);
  } else {
    console.log(address);      // added
    client.connect(address);
  }
}));

Output

{address: '::', family: 'IPv6', port: 38065}

뭔가 이상하다. localhost라는 DNS 주소가 아니라 ::라는 IPv6 주소가 넘어가는데… 또 이상한 게 있는데, 아래 코드를 main branch의 node로 실행시킨 후 nc :: 12345 명령어로 서버에 접속하면 접속이 잘 된다.

'use strict';

const net = require('net');

const server = net.createServer((conn) => {
    conn.write("Hello World\n");
    conn.end();
    server.close();
}).listen({
    host: '::',
    port: 12345,
    family: 'IPv6'
}, function () {
    const client = new net.Socket();

    client.on('connect', function () {
        // send message and close
        client.write("Hello World\n");
        client.end();
    });
});

추가로, 테스트 코드의 에러 내용으로 구글링을 해 보니까 /etc/hosts를 수정해 localhost:: 주소로 resolve 될 수 있게 하면 된다고 하는데, 그 전에 Node.js에서 굳이 IP 주소가 아니라 DNS 주소로 연결을 시도하는 것 부터가 문제인 것 같아서 일단 놔두기로 했다.

node/lib/net.js 코드에 있는 net.Socket.connect 함수(node/lib/net.js에서는 프로토타입 함수인 Socket.prototype.connect)에 중단점을 열심히 찍어서 디버깅을 해 봤고, 함수의 마지막인 아래 부분 이전에서는 에러가 안 터지는 걸 찾았다.

  if (pipe) {
    validateString(path, 'options.path');
    defaultTriggerAsyncIdScope(
      this[async_id_symbol], internalConnect, this, path,
    );
  } else {
    lookupAndConnect(this, options);
  }
  return this;

또한 node/test/parallel/test-net-socket-connect-without-cb.js 테스트 코드에서는 else 쪽으로 분기가 되고, lookupAndConnect에 전달되는 options 인자가 테스트 코드의 client.connect(address);와 같다는 것도 발견했다. 정확히는 address가 경우에 따라 약간 전처리가 되고 lookupAndConnect에 전달되는데, 지금의 경우에는 그대로 전달된다.

그래서 lookupAndConnect에도 중단점을 계속 찍어가면서 에러가 발생하는 부분을 찾아봤다. 이 부분이었다. 정확히는 lookup 함수에서 에러가 발생한 후 콜백 함수인 emitLookup에 에러 내용이 전달되고, process.nextTick으로 throwing을 하는 것 같다.

  defaultTriggerAsyncIdScope(self[async_id_symbol], function() {
    lookup(host, dnsopts, function emitLookup(err, ip, addressType) {
      self.emit('lookup', err, ip, addressType, host);

      // It's possible we were destroyed while looking this up.
      // XXX it would be great if we could cancel the promise returned by
      // the look up.
      if (!self.connecting) return;

      if (err) {
        // net.createConnection() creates a net.Socket object and immediately
        // calls net.Socket.connect() on it (that's us). There are no event
        // listeners registered yet so defer the error event to the next tick.
        process.nextTick(connectErrorNT, self, err);
      } else if (!isIP(ip)) {
        err = new ERR_INVALID_IP_ADDRESS(ip);
        process.nextTick(connectErrorNT, self, err);
      } else if (addressType !== 4 && addressType !== 6) {
        err = new ERR_INVALID_ADDRESS_FAMILY(addressType,
                                             options.host,
                                             options.port);
        process.nextTick(connectErrorNT, self, err);
      } else {
        self._unrefTimer();
        defaultTriggerAsyncIdScope(
          self[async_id_symbol],
          internalConnect,
          self, ip, port, addressType, localAddress, localPort,
        );
      }
    });
  });

위의 코드 블럭에서 lookup에 넘어가는 인자들을 보니까 host'localhost'였고, dnsopts에는 IP 버전 정보와 DNS resolver 관련 정보가 들어있었다. 앞서 말했듯 DNS 주소인 localhost가 IPv6 주소로 resolve되도록 hosts 파일을 설정하지 않아서 저런 인자가 넘어가면 에러가 나는 건 이상하지 않은데, 애초에 host에 IP 주소 대신 'localhost'가 들어가는 게 이상하다.

다시 lookupAndConnect 함수를 전체적으로 살펴봤다. 그리고 lookupAndConnect에 인자로 넘어간 optionsoptions.address 값을 함수 내부에서 전혀 이용하지 않는 것을 발견했다. 그 대신 server.address의 리턴값에는 존재하지 않는 값인 options.host를 사용해서 lookup에 전달될 host를 정의하고 있었다.

function lookupAndConnect(self, options) {
  const { localAddress, localPort } = options;
  const host = options.host || 'localhost';
  let { port, autoSelectFamilyAttemptTimeout, autoSelectFamily } = options;

  ...

}

이제 이유는 알아냈으니까 host 값으로 options.address 사용할 수 있게 만들자. host 값으로 options.host가 사용되는 것을 가정하고 만든 코드도 많을 테니까 위 코드에서 options.hostoptions.address로 바꿔버리면 안 될 것이다. 대신 options.host가 존재하지 않을 때만 options.address 값을 host로 이용하고, options.address 값도 없을 때 이전처럼 host'localhost'로 만들면 될 것이다.

function lookupAndConnect(self, options) {
  const { localAddress, localPort } = options;
  let host = options.host;
  if (!host) {
    host = options.address;
  }
  if (!host) {
    host = 'localhost';
  }
  let { port, autoSelectFamilyAttemptTimeout, autoSelectFamily } = options;

  ...

}

이러고 다시 bulid 후 test를 돌려보니 처음 고치기로 했던 test-net-socket-connect-without-cb 뿐만 아니라 node/test/parallel/test-tcp-wrap-listen.js 까지 고쳐졌고, 새로 발생한 에러는 없어서 모든 test를 통과했다.

이제 GitHub에 Issue랑 PR을 어떻게 올릴 지 좀 알아봐야 될 것 같다.

그나저나 저런 버그가 있으면 server.address()의 리턴 값 형식으로 서버 주소를 알아왔을 때, localhost가 아닌 서버에 접속하면 항상 에러가 터질 텐데… 이게 어떻게 지금까지 남아있지.

Leave a comment