Перейти к содержанию

Путь запроса в Node.js: клиент, DNS, TCP и колбэки

Источник: theNodeBook — Node.js Request Path: DNS, TCP, libuv & Callbacks

Путь запроса в Node.js проходит DNS, маршрутизацию, установку TCP, очереди ядра, готовность libuv и JavaScript-колбэки — и только потом код приложения видит данные. Механика включает разрешение имён, выбор адреса, выделение локальной конечной точки, маршрутизацию, TCP connect, очереди listen, очереди accept и доставку колбэков в JavaScript. Каждый слой добавляет состояние и свои режимы отказа.

Путь запроса от клиента до процесса Node.js

Трассировка пути помогает точнее локализовать сбой. Ошибки DNS возникают до TCP. ECONNREFUSED — на этапе connect. Давление на backlog — до того, как JavaScript примет соединение. События data — после появления сокета и сигнала libuv о читаемости. HTTP начинается уже после того, как транспортный путь выдал подключённый stream.

Вызов на клиенте возвращает net.Socket до того, как соединение существует. Объект может держать слушателей и ставить записи в очередь, пока Node разрешает имя, выбирает адрес, просит ОС маршрут, выделяет локальную конечную точку и ждёт завершения TCP handshake.

1
2
3
4
5
6
7
import net from 'node:net';

const socket = net.connect(3000, 'api.internal');

socket.on('connect', () => {
  socket.write('ping\n');
});

Сначала создаётся состояние на стороне JavaScript. У объекта могут быть слушатели. Может стоять запись в очереди. Может прийти error. Подключённый TCP-сокет появляется позже — после разрешения имени, выбора адреса, поиска маршрута, выделения локальной конечной точки и handshake.

Исходящий путь соединения — эта клиентская последовательность от net.connect до подключённого сокета. В неё входят JavaScript-объект, нативный путь сокета Node, libuv, путь резолвера ОС при семантике dns.lookup(), таблица маршрутизации, таблица сокетов ядра и ответ удалённого узла.

У сервера свой путь.

1
2
3
4
5
6
import net from 'node:net';

net.createServer(socket => {
  console.log(socket.remoteAddress, socket.remotePort);
  socket.end('pong\n');
}).listen(3000, '0.0.0.0');

Входящий путь accept — серверная последовательность от приходящего SYN до колбэка connection в JavaScript. Пакет попадает на интерфейс. Ядро сопоставляет его с listening-сокетом. Состояние TCP продвигается. Завершённое соединение ждёт в очереди accept. libuv видит готовность. Node принимает соединение, оборачивает дескриптор и эмитит событие.

Два пути. Одно соединение.

Держите схему узкой:

1
2
3
4
5
6
7
8
client JS
  -> lookup
  -> address selection
  -> route and local address
  -> TCP connect
  -> server SYN/accept queues
  -> libuv watcher
  -> server JS callback

Трассировка — смысл этого подраздела. Ранние куски главы сходятся в один путь. Успех DNS даёт кандидатов адресов, но не гарантирует, что какой-либо адрес примет TCP. Успех TCP даёт поток байт, но не гарантирует протокол, который ждёт приложение. Адрес, который видит сервер, может отличаться от того, что использовал клиентский процесс, если между ними NAT, прокси или балансировщик.

Короткие блоки JavaScript ниже используют уже объявленные net, server или socket, когда повторять import или setup из предыдущего примера не нужно — они изолируют обсуждаемую операцию.

Исходящий путь: от hostname к TCP-сокету

net.connect() принимает host и port. При hostname Node должен разрешить адреса, прежде чем ядро сможет подключиться.

1
2
3
4
const socket = net.connect({
  host: 'example.com',
  port: 80,
});

Hostname — вход приложения. Путь connect в ядре нуждается в удалённом socket address: IP, порт и семейство адресов. Node доходит до этого через поведение lookup из более раннего материала главы. Кратко: разрешение в стиле dns.lookup() идёт через резолвер ОС, а порядок результатов зависит от опций Node, поведения резолвера ОС и возвращённых записей.

Когда есть кандидаты адресов, нужна локальная конечная точка. Выбор локального адреса — решение ОС, какой source IP использовать для исходящего соединения. Оно опирается на адрес назначения, семейство, маршруты, настроенные интерфейсы и явный localAddress в коде.

1
2
3
4
5
const socket = net.connect({
  host: 'example.com',
  port: 80,
  localAddress: '192.168.1.20',
});

Опция сужает source address. ОС всё равно должна принять его как локальный адрес нужного семейства с маршрутом до назначения. Если адреса нет, он привязан не к тому интерфейсу или непригоден для этого назначения — соединение падает до того, как удалённый сервер что-либо решит.

Большинство клиентского кода не задаёт localAddress. Тогда выбор source ведёт lookup маршрута. Ядро смотрит таблицу маршрутизации для IP назначения. Выбранный маршрут указывает выходной интерфейс или next hop. Локальный адрес обычно берётся с этого интерфейса. Ядро также выбирает эфемерный порт — появляется адрес клиентского сокета.

У стека TCP достаточно состояния, чтобы отправить SYN:

1
2
3
4
remote address: 93.184.216.34:80
local address:  192.168.1.20:52744
protocol:       TCP
state:          connecting

Точный локальный порт меняется. Точный source зависит от конфигурации хоста. В контейнере source сначала может быть адресом внутри контейнера, а позже переписан NAT. На ноутбуке с VPN выбранный маршрут может идти через туннельный интерфейс. Node не владеет этими политиками. Node просит соединение; ОС строит локальную конечную точку.

JavaScript-объект проходит состояния, отражающие нижние уровни, но не каждый нижний переход. До завершения connect записи могут стоять в очереди внутри Node. При успехе TCP сокет эмитит 'connect'. При сбое lookup или connect — 'error'.

Несколько кусков состояния существуют одновременно:

1
2
3
4
5
6
JavaScript net.Socket
  -> native TCP wrapper
  -> libuv TCP handle
  -> kernel socket table entry
  -> route-selected local endpoint
  -> remote endpoint candidate

JavaScript-объект — тот, что держит ваш код. Нативная обёртка — мост в C++ Node. Handle libuv связывает сокет с готовностью I/O в event loop. Запись в таблице сокетов ядра владеет состоянием протокола и локальным дескриптором. Локальная конечная точка по маршруту — source address и порт, которые ядро поставит в исходящие пакеты. Кандидат удалённой конечной точки — один разрешённый адрес плюс запрошенный порт.

Эти состояния могут ломаться в разное время.

Выделение может упасть до DNS, если процесс не может создать больше дескрипторов. Lookup может упасть, пока сокет не дошёл до удалённой сети. Попытка connect может упасть после того, как ядро создало локальный сокет, но до accept на стороне peer. Успешный connect всё равно может сразу закрыться, если сервер принял и отверг сессию на уровне приложения.

socket.pending показывает малую часть этого состояния из JavaScript:

1
2
3
4
const socket = net.connect(3000, '127.0.0.1');

console.log(socket.pending);
socket.on('connect', () => console.log(socket.pending));

Пока соединение устанавливается, сокет pending. После connect — живой подключённый stream. Если ошибка на lookup или connect, сокет бесполезен для read/write. Обработчик error получает результат неудачного пути, затем идёт очистка.

Исходящий путь также фиксирует семейство адресов до вызова сокета в ядре. IPv4 и IPv6 используют разные семейства сокетов ниже Node. Hostname может дать оба — Node может создавать разные попытки connect для разных семейств. Явный числовой host пропускает DNS, но всё равно проходит lookup маршрута и выбор локальной конечной точки.

1
2
net.connect(5432, '::1');
net.connect(5432, '127.0.0.1');

Эти вызовы бьют в разные loopback-адреса разных семейств. Сервер, слушающий только IPv4 loopback, не получит IPv6-соединение. Dual-stack listener может принять оба — в зависимости от опций сокета ОС из предыдущего подраздела. Клиентский путь должен совпадать с формой bind на сервере.

Явные локальные порты редки, но выставляют ещё один этап:

1
2
3
4
5
net.connect({
  host: '127.0.0.1',
  port: 3000,
  localPort: 40000,
});

Клиент просит конкретный локальный порт вместо выбора ядром. Это может упасть, если тот же локальный tuple уже занят или платформа отвергает паттерн reuse. Исходящие клиенты обычно не трогают localPort — эфемерный порт как раз та работа ядра, которая здесь нужна.

Выбор локального адреса усложняется на хостах с несколькими маршрутами.

У разработческого ноутбука могут быть Wi‑Fi, loopback, VPN-туннель и bridge контейнера. У облачной ВМ — основной интерфейс, дополнительный приватный и IPv6 только на одном из них. У контейнера — урезанный вид интерфейсов с правилами host networking. Один и тот же net.connect() идёт против таблицы маршрутов, видимой процессу. Перенесите процесс в другой network namespace — видимая таблица может измениться при том же байт-в-байт JavaScript.

Lookup маршрута стартует от кандидата назначения. Для публичного IPv4 назначения default IPv4-маршрут может выбрать Wi‑Fi. Для корпоративного приватного адреса может победить VPN. Для 127.0.0.1 — loopback. Для ::1 — IPv6 loopback. Локальный адрес следует этому выбору, если код не закрепил localAddress.

Закреплённый локальный адрес полезен в тестах и multi-homed системах, но превращает выбор маршрута в явный контракт. Закрепите адрес чужого интерфейса — connect упадёт. Закрепите IPv4 при IPv6-кандидате назначения — семейства не сойдутся. Закрепите адрес на хосте без маршрута до назначения — сбой похож на connect, хотя ошибка была локальной.

1
2
3
4
5
net.connect({
  host: '2001:db8::10',
  port: 443,
  localAddress: '192.168.1.20',
});

Вызов смешивает IPv6 удалённый адрес с IPv4 локальным. ОС не может собрать один TCP-сокет из этих семейств. Node сообщает об ошибке через путь connect, потому что запрос так и не стал валидным исходящим сокетом.

Локальная конечная точка также влияет на серверные логи после промежуточных границ. Без NAT и прокси сервер видит выбранный клиентом source и эфемерный порт. С NAT — переведённый tuple. С прокси — tuple прокси. Выбор локального адреса реален на клиенте, но может исчезнуть из вида backend-сокета.

Для отладки логируйте до и после connect с разными метками:

1
2
3
4
5
const socket = net.connect({ host, port });

socket.on('connect', () => {
  console.log('selected local', socket.address());
});

Здесь host и port — конечная точка операции, которую вы трассируете.

Строка лога доступна только после connect: к этому моменту ядро выбрало финальный локальный tuple. До connect у кода есть намерение. После connect у сокета есть назначенное состояние.

В конце успешного исходящего пути у подключённого сокета есть четвёрка:

1
2
3
4
local address
local port
remote address
remote port

Для TCP эта четвёрка идентифицирует соединение в состоянии TCP хоста. Много клиентских сокетов могут подключаться к одному удалённому адресу и порту — у каждого свой локальный порт. Много удалённых клиентов могут подключаться к одному серверному порту — у каждого соединения свой удалённый endpoint. Listening-сокет владеет локальным listen address и портом. Accepted-сокеты владеют полными connected tuple.

1
2
3
4
5
const socket = net.connect(80, 'bad.invalid');

socket.on('error', err => {
  console.error(err.code);
});

Неудачный lookup имени часто даёт ENOTFOUND. Временный сбой резолвера — EAI_AGAIN. Эти ошибки до TCP. SYN к серверу приложения не ушёл, потому что Node не получил пригодный адрес назначения.

Другой сбой:

1
2
3
4
5
const socket = net.connect(9, '127.0.0.1');

socket.on('error', err => {
  console.error(err.code);
});

Если на порту никто не слушает, connect обычно даёт ECONNREFUSED. Это сбой этапа connect. Адрес был. Маршрут локальный. TCP дошёл до стека хоста, который отклонил соединение для этого порта.

Таймауты сидят в другом месте. Connect timeout значит: клиент ждал достаточно долго без успешного соединения. Причина может быть потеря пакетов, firewall, сброс трафика, маршрутизация, молчаливый удалённый хост или промежуточная система, поглотившая попытку. Место ошибки — «путь connect не завершился». Причину всё равно нужно подтверждать фактами.


Гонка адресов (Address racing)

Разрешённые адреса — кандидаты, не план.

Hostname может дать IPv6 и IPv4. Клиент, пробующий только первый адрес, может ждать на сломанном пути, пока другое семейство подключилось бы быстро. Порядок адресов влияет на первую попытку, но одного порядка мало, когда одно семейство частично сломано.

Happy Eyeballs — стратегия клиентского соединения: гонка семейств или кандидатов с короткими задержками, чтобы один медленный или падающий путь не стопорил всё соединение. Типичная форма: попробовать IPv6-кандидата, подождать немного, затем, если первый путь не завершился, попробовать и IPv4. Победитель становится соединением. Проигравший закрывается или бросается.

Гонка соединений — перекрытие нескольких in-flight попыток connect, где клиент фиксируется на первой успешной и сносит остальные. Гонка про установление соединения, не про порядок DNS-записей. DNS даёт кандидатов. Логика connect решает, насколько агрессивно их пробовать.

Низкоуровневый модуль net в современном Node эволюционировал: попытки connect могут использовать auto-selection для нескольких адресов в зависимости от опций и умолчаний. Точные тайминги — детали API, но механизм стабилен для рассуждений: у Node может быть несколько кандидатов и несколько connect до финального результата.

Гонка адресов меняет отладку.

1
2
3
4
const socket = net.connect({
  host: 'localhost',
  port: 3000,
});

На одной машине localhost может разрешиться в ::1 раньше 127.0.0.1. На другой IPv4 придёт первым. Если сервер слушает только 127.0.0.1, попытка IPv6 на ::1 может упасть, а IPv4 — успешно. Поведение приложения может выглядеть нормально с небольшой задержкой. В трассировке — отказ IPv6, затем успешное IPv4.

Бывает и наоборот: сервис слушает только IPv6; клиент сначала бьёт в IPv4, потом восстанавливается через IPv6. Или один адрес идёт через VPN, другой — по локальной сети. Имя одно. Путь разный.

Сбои становятся агрегированными или поэтапными. Один адрес даёт ECONNREFUSED, другой — timeout, третий — успех. Ошибка в JavaScript зависит от политики, собравшей попытки. Финальную connect-ошибку трактуйте как итог выбора кандидатов и попыток connect, а не как доказательство, что «упал» один конкретный DNS-ответ.

Практическое правило простое: после connect логируйте выбранные локальный и удалённый адреса.

1
2
3
4
socket.on('connect', () => {
  console.log(socket.address());
  console.log(socket.remoteAddress, socket.remotePort);
});

socket.address() — локальный адрес после выбора ОС. remoteAddress и remotePort — peer подключённого сокета. При address racing эти значения показывают, какой кандидат победил.

Node также решает, что делать с проигравшими попытками. Проигравшая может ещё идти, когда победитель уже подключился. Node закрывает или бросает проигравший handle, чтобы приложение видело один connected socket. Проигравший всё равно может дать нижний трафик: ушёл SYN, пришёл отказ или ждёт timeout. API скрывает большую часть этого — приложение просило одно соединение.

Скрытая работа видна в захватах пакетов и серверных логах. Dual-stack сервер может увидеть короткую попытку на одном семействе и реальную сессию на другом. Firewall может логировать заблокированный IPv6, пока приложение работает по IPv4. Локальный тест проходит, но тратит время на обречённого первого кандидата.

Тайминг намеренно мал. Длинная задержка вернула бы видимую пользователю паузу, от которой Happy Eyeballs как раз избавляет. Нулевая задержка и полный fanout дали бы больше шума в сети и churn соединений. Современное клиентское поведение обычно использует смещённые попытки. Точная задержка — политика, механизм тот же: достаточное перекрытие, чтобы не ждать слишком долго на плохом пути.

Отчёт об ошибках менее аккуратен, когда падают все кандидаты. Одни API отдают последнюю ошибку. Другие агрегируют. Поведение низкого уровня Node зависит от опций connect и версии. Устойчивая дисциплина чтения: отделяйте генерацию кандидатов от connect к кандидатам. Сбой резолвера — нет пригодных кандидатов. Сбой connect — кандидаты были. Сбой после гонки может суммировать несколько неудачных кандидатов.

Небольшой скрипт делает разницу видимой там, где localhost разрешается в оба семейства:

1
2
3
4
5
6
7
8
import net from 'node:net';

const socket = net.connect(3000, 'localhost');

socket.on('error', err => console.error(err.code));
socket.on('connect', () => {
  console.log(socket.remoteFamily, socket.remoteAddress);
});

remoteFamily показывает, какое семейство победило. Если сервер bind только на 127.0.0.1, успешный вывод IPv4 127.0.0.1 говорит, что IPv4 выиграл или восстановился. Если bind только на ::1 — наоборот для IPv6. Тесты на localhost становятся понятнее, когда печатаете семейство.


Входящий путь: от SYN к connection

Серверный путь начинается до того, как что-либо видит JavaScript.

1
2
3
4
5
const server = net.createServer(socket => {
  console.log('accepted', socket.remoteAddress);
});

server.listen(3000, '0.0.0.0');

listen() создаёт listening-сокет в ядре, с handle Node и libuv сверху. С этого момента входящие TCP-попытки — сначала состояние ядра. Колбэк JavaScript сохранён и ждёт.

Входящий accept для обычного TCP выглядит так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
client SYN
  -> server network interface
  -> IP and TCP receive path
  -> listening socket match
  -> TCP handshake completion
  -> accept queue
  -> readiness notification
  -> libuv I/O watcher
  -> accept loop
  -> JavaScript connection callback

Уведомление о готовности (readiness notification) — сигнал ядра event-системе, что по дескриптору можно продвинуть операцию вроде accept, read или write. Уведомление не переносит данные приложения в JavaScript. Оно говорит: у дескриптора есть состояние, которое стоит обработать.

I/O watcher — состояние libuv, регистрирующее интерес к готовности дескриптора или эквивалента платформы. Для TCP-сервера libuv следит за читаемостью listening-сокета: читаемый listening-сокет означает, что есть завершённые соединения для accept.

Когда ядро завершает TCP handshake, новый connected socket ждёт в очереди accept. Listening-сокет становится читаемым с точки зрения event-системы. libuv получает уведомление во время I/O-обработки event loop. Нативный колбэк TCP-сервера Node выполняется. Он принимает одно или несколько ожидающих соединений и оборачивает каждый accepted descriptor в net.Socket.

Accept loop — нативный цикл, сливающий доступные завершённые соединения с listening-сокета, пока ядро не скажет, что больше нет немедленно доступных, или Node не упрётся в лимиты за итерацию. Readiness может означать «есть хотя бы одно соединение». Их может ждать несколько.

Деталь важна под нагрузкой. Всплеск соединений может заполнить очередь accept быстрее, чем бегут колбэки JavaScript. Backlog из предыдущего подраздела задаёт часть ёмкости вместе с лимитами платформы и отдельным поведением SYN-очереди ниже. Когда очередь accept полна, новые handshake могут задерживаться, сбрасываться или отбрасываться — по политике ОС и условиям сети.

JavaScript видит результат после нативного accept:

1
2
3
4
server.on('connection', socket => {
  console.log(socket.localAddress, socket.localPort);
  console.log(socket.remoteAddress, socket.remotePort);
});

К моменту колбэка у сокета уже есть метаданные локальной и удалённой конечных точек. Он уже потребляет дескриптор. Если колбэк сразу уничтожает сокет, соединение всё равно существовало: процесс принял его и закрыл.

server.maxConnections и проверки admission на уровне приложения живут выше accept-пути. Они могут закрыть работу после того, как процесс уже принял соединение. Они не меняют решение ядра завершить TCP handshake. Это видно в метриках: сервер может принимать соединения и сразу закрывать, пока клиенты видят reset или ранний EOF.

Listening-сокет и accepted-сокеты живут разными жизненными циклами.

1
2
3
4
5
6
const sockets = new Set();

const server = net.createServer(socket => {
  sockets.add(socket);
  socket.on('close', () => sockets.delete(socket));
});

Закрытие сервера останавливает будущие accept. Уже принятые сокеты остаются открытыми, пока код их не завершит или не уничтожит. При shutdown часто вызывают server.close(), чтобы остановить входящий accept, затем сливают или завершают уже принятые сокеты.

У входящего пути есть ещё скрытое разделение: завершение TCP handshake и admission в JavaScript — разные события.

Ядро может завершить handshake и положить соединение в очередь accept до запуска вашего колбэка. С точки зрения клиента connect может успеть в этом окне. С точки зрения серверного приложения JavaScript ещё не «принял» соединение. ОС приняла его от имени listening-сокета. Node всё ещё должен вытащить его из очереди и создать JavaScript-обёртку.

Отсюда типичный паттерн нагрузочного теста. Клиенты сообщают об успешных TCP connect. Сервер в тот же момент показывает меньше accept на уровне приложения. Пропавший интервал может быть очередью accept, задержкой event loop, CPU процесса, давлением на дескрипторы или лагом логирования. Слой TCP и слой JavaScript связаны, но это не одна временная метка.

Давление на дескрипторы бьёт и здесь. Каждое принятое TCP-соединение потребляет дескриптор. У лимита процесса accept может упасть, хотя listening-сокет читаем. Node может эмитить ошибку сервера или закрывать состояние в зависимости от места сбоя. Клиент может увидеть reset или close после handshake. Нижний факт прост: у ядра было соединение, а процесс не смог чисто прикрепить всё нужное user-space состояние.

Accept loop также взаимодействует с уже поставленной в очередь работой JavaScript. Всплеск завершённых соединений даёт много событий connection. Каждый колбэк может вешать слушателей, аллоцировать буферы, ставить таймеры и добавлять сокет в структуры приложения. Тяжёлая синхронная работа в колбэке забирает время у возврата к I/O readiness. Ядро продолжает принимать пакеты, очереди растут, следующая партия колбэков приходит позже.

Поэтому колбэки connection лучше держать лёгкими:

1
2
3
4
5
const server = net.createServer(socket => {
  socket.setNoDelay(true);
  socket.on('error', logSocketError);
  handOff(socket);
});

Здесь logSocketError и handOff — функции приложения.

Колбэк задаёт политику сокета, вешает обработку сбоев и передаёт сокет дальше. Он не парсит большой конфиг, не гоняет тяжёлую аутентификацию на CPU и не блокируется синхронным I/O файловой системы. Более высокие протоколы могут потребовать больше работы сразу после accept, но сам колбэк accept при большом объёме соединений должен оставаться маленьким.

pauseOnConnect показывает ещё одну полезную границу:

1
2
3
const server = net.createServer({ pauseOnConnect: true }, socket => {
  socket.resume();
});

С pauseOnConnect принятые сокеты приходят в paused-состоянии. Node принял дескриптор и создал net.Socket, но читаемые данные не пойдут в JavaScript, пока сокет не resume(). Process manager и паттерны handoff могут использовать это, чтобы передать сокет или донастроить его до событий data. TCP-соединение уже есть. Read-сторона удерживается на границе stream Node.


Путь готовности внутри Node

Глубина сидит между ядром и JavaScript-колбэком.

libuv не просит ядро запускать JavaScript. Он регистрирует нативный интерес к готовности дескриптора и сообщает о готовности в колбэки Node внутри event loop. API событий ОС различается по платформам: на Linux часто epoll, на macOS и BSD — kqueue, на Windows — IOCP с другой семантикой completion. libuv прячет этот разрыв за handles, watchers и колбэками, на которых строит Node.

Для серверного сокета на Unix-подобных системах listening-дескриптор смотрят на читаемость. Читаемость listening-сокета значит: accept() может вернуть connected descriptor без блокировки. libuv держит watcher, связанный с TCP handle. Когда event loop доходит до poll provider и ядро сообщает, что дескриптор читаем, libuv запускает нативный connection callback для этого handle.

Нативный код TCP-сервера Node зовёт machinery accept libuv. Путь accept вытягивает connected socket из ядра. Node создаёт нативную обёртку, связывает её с JavaScript net.Socket, инициализирует состояние stream и эмитит событие через объект сервера. Эмиссия использует механику EventEmitter из главы 7. Сетевая специфика — нижняя передача: готовность становится accept, accept — дескриптором, дескриптор — обёрнутым stream.

Чтение следует тому же паттерну готовности с другой работой. Connected socket становится читаемым, когда в приёмном буфере ядра есть байты или когда изменилось состояние TCP, которое read-путь должен показать — например shutdown peer. libuv получает читаемость. Node запрашивает байты. Ядро копирует их в буфер, который даёт runtime. Node проталкивает байты через readable-сторону net.Socket. Слушатель 'data' или async iterator видит чанки.

Форма чанка принадлежит stream и read-пути, а не границам TCP-сегментов. Один TCP-сегмент может дать несколько JavaScript-чанков при малых read. Несколько сегментов могут слиться в один чанк, если байты накопились. Протоколам с границами сообщений нужен разбор байт выше TCP. Глава 10 владеет разбором HTTP. Для сырого net.Socket framing — ваша зона.

Запись стартует в JavaScript и идёт вниз. socket.write() кладёт байты в writable-путь Node. Если Node может отдать их libuv, а ядро принимает в send buffer сокета, запись продвигается. Если user-space буферизация растёт выше порога stream, socket.write() возвращает false, а позже 'drain' говорит, что на writable снова есть место. TCP flow control и send buffers ядра сидят ниже этого сигнала JavaScript: локальный true от write() значит «принято локальным writable-путём», а не «прочитано peer».

Shutdown — тоже готовность. FIN peer в конце концов становится end-of-stream на readable. RST peer обычно даёт ошибку или резкое закрытие. Локальный socket.end() просит Node дописать и отправить graceful TCP close. Локальный socket.destroy() рвёт локальное состояние агрессивнее. Глава про TCP владеет автоматом состояний. Для трассировки пути достаточно: изменения нижнего TCP становятся готовностью, libuv сообщает, Node переводит в события stream.

У перевода есть задержка. Если JavaScript крутит тяжёлую CPU-работу, ядро может продолжать принимать пакеты и заполнять буферы, пока колбэки ждут. Готовность уже записана, но event loop должен вернуться к I/O processing, прежде чем Node запустит обработчик. Поэтому сетевые баги выглядят как «медленный удалённый узел», хотя процесс занят выше libuv.

Модель watcher объясняет, почему за один tick может обработаться несколько accept или read. Готовность говорит: работа доступна. Нативный код может крутить цикл, пока следующий вызов заблокировался бы. Потом управление возвращается вверх. Порядок колбэков JavaScript всё равно следует правилам event loop, но запас работы пришёл из состояния дескриптора ниже.

Level-triggered и edge-triggered readiness — детали API событий ядра; libuv сглаживает их в своё поведение. Операционное следствие видно: когда сработала готовность, нативный код должен слить достаточно работы, чтобы дескриптору не требовалось немедленное обслуживание. Если работа осталась, провайдер событий может снова сообщить готовность. Если слить слишком много за проход, другие handle ждут дольше. Runtime балансирует это циклами per-handle и границами итераций event loop.

JavaScript видит только финальную точку планирования. Событие 'data' может означать, что ядро имело данные готовыми раньше. 'connection' — что handshake завершился раньше. 'drain' — что user-space буферизация упала ниже порога после продвижения нижних записей. Время колбэка — время наблюдения в JavaScript, а не время сетевого события.

Различие ясно, когда процесс CPU-bound:

1
2
3
4
5
server.on('connection', socket => {
  const start = Date.now();
  while (Date.now() - start < 200) {}
  socket.end('late\n');
});

Клиенты могут завершить TCP handshake, пока сервер застрял в таком цикле от предыдущего колбэка. Ядро может ставить их в очередь. libuv доставит их JavaScript-колбэки только после возврата управления. В сетевой трассировке пакеты вовремя. В логах приложения — поздние accept. Оба утверждения верны.


Данные после connect с обеих сторон

После connect и accept у обоих процессов есть connected sockets. Путь становится движением байт и сменой состояния.

1
2
3
4
const socket = net.connect(3000, '127.0.0.1');

socket.write('one\n');
socket.write('two\n');

Peer может получить один чанк, два или другой разрез. TCP несёт поток байт. Streams Node показывают чанки, которые дал read из этого потока. Границы вызовов write на отправителе — события приложения, а не границы протокола на приёмнике.

1
2
3
4
5
server.on('connection', socket => {
  socket.on('data', chunk => {
    console.log(chunk.toString());
  });
});

chunk — Buffer со стороны read Node. В нём байты, доступные в момент read. Если протокол использует сообщения по \n, length-prefix или фиксированные кадры — разбор в вашем коде. TCP даёт упорядоченные байты. Сообщений приложения он не даёт.

Backpressure пересекает слои. Принимающий процесс может перестать читать net.Socket — напрямую через pause() или косвенно, если downstream тормозит. Буферизация readable в Node растёт. Приёмный буфер ядра может заполниться. TCP flow control может сузить окно отправителя. На стороне отправки write() может начать возвращать false, когда растёт writable-буферизация Node.

1
2
3
if (!socket.write(payload)) {
  socket.once('drain', sendMore);
}

Здесь payload — следующие байты вашего протокола, sendMore — продолжение после сигнала Node о месте на writable.

Код обрабатывает давление stream Node. Он не смотрит приёмный буфер peer. Он даёт локальному процессу разумное правило: не добавлять байты, когда writable говорит «переполнен», и продолжать на drain.

Read также показывает разбор соединения.

1
2
3
4
5
6
7
socket.on('end', () => {
  console.log('peer finished writes');
});

socket.on('close', hadError => {
  console.log({ hadError });
});

'end' — readable увидел упорядоченное завершение записей peer. 'close' — handle сокета закрыт. События отвечают на разные вопросы: одно про завершение входящего потока байт, другое про освобождение локального ресурса.

Ошибки в том же пути. ECONNRESET обычно значит, что peer сбросил соединение или промежуточное устройство сгенерировало reset. EPIPE может случиться при записи после того, как peer закрыл достаточно состояния, чтобы локальная запись не продолжалась. Точная форма зависит от тайминга, платформы и операции, на которой увидели сбой.

Полезный ход отладки — положить ошибку на этап пути. Ошибка резолвера. Ошибка connect. Приняли и закрыли. Ошибка read. Ошибка write. Idle timeout. Reset. Это разные расследования.

Ещё деталь байтового пути для request-протоколов: первые байты приложения могут прийти до того, как колбэк закончил setup.

TCP разрешает клиенту слать данные сразу после установления соединения. На сервере ядро может принять байты и держать их в приёмном буфере сокета, пока JavaScript не повесил все слушатели. Состояние stream Node решает, когда данные идут вверх. В flowing mode с уже привязанным слушателем data может сработать быстро. В paused байты ждут, пока код прочитает или сделает resume.

Нормальное поведение:

1
2
3
4
server.on('connection', socket => {
  socket.pause();
  queueMicrotask(() => socket.resume());
});

pause не останавливает отправку peer. Он останавливает эмиссию читаемых данных в JavaScript до resume. Приёмный буфер ядра всё равно может заполняться. TCP flow control всё равно может отталкивать отправителя, если процесс ждёт слишком долго. pause — решение потока на стороне приложения, а не сетевое admission.

Для серверов, передающих сокеты в парсеры протокола, порядок обычно такой: accept, повесить обработку error/close, подключить parser или pipeline stream, затем при необходимости resume. Без обработчика error необработанное 'error' может уронить процесс. Без setup парсера байты не теряются сами по себе, если код их не потребляет без разбора — буферизация stream защищает типичное окно setup, но неосторожный flowing-mode код всё равно может создать беспорядок.


Одно соединение, две точки наблюдения

Клиент и сервер не наблюдают одно и то же соединение в один момент.

Клиентский код видит исходящий сокет. Серверный — accepted socket. Ядро и сеть видят пакеты и состояние TCP между этими двумя JavaScript-объектами. Успешный 'connect' на клиенте и 'connection' на сервере связаны, но ни один колбэк не является распределённой временной меткой.

Минимальная пара:

1
2
3
4
const server = net.createServer(socket => {
  console.log('server accepted');
  socket.end('ok\n');
});

Серверный колбэк срабатывает после того, как ядро уже прошло accept-путь и Node обернул дескриптор. Он поздний относительно TCP handshake. Он ранний относительно разбора протокола приложения.

1
2
3
4
5
const socket = net.connect(3000, '127.0.0.1');

socket.on('connect', () => {
  console.log('client connected');
});

Клиентский колбэк — когда исходящий connect-путь завершился. Серверный процесс мог уже принять сокет, или connected socket ещё сидит в очереди accept, пока Node обрабатывает готовность. В обоих случаях клиентский connect может быть успешным.

Добавьте лог первого байта:

1
2
3
socket.on('data', chunk => {
  console.log('client read', String(chunk));
});

Теперь в трассировке три наблюдения приложения: client connected, server accepted, client read. Под ними больше событий: SYN sent, SYN-ACK received, ACK sent, вставка в accept queue, готовность libuv, запись в send buffer сервера, передача пакета, готовность приёма клиента, событие data в JavaScript. Логи двух процессов могут переплетаться в нескольких валидных порядках — планирование на каждой стороне независимо.

Переплетение влияет на анализ задержки запроса. «Медленный запрос» может тратить время до connect, во время connect, в очереди accept сервера, в ожидании JavaScript сервера, в write-пути сервера, в сети обратно или в ожидании read на клиенте. Высокоуровневый тайминг сжимает это в одну длительность. Сырая трассировка сокета позволяет разложить.

Для сырого TCP-сервиса можно отметить границу, где соединение стало пригодным:

1
2
3
socket.on('connect', () => {
  socket.write('hello\n');
});

Запись идёт после connect на клиенте. Peer может получить байты до того, как код приложения готов их разбирать — приёмный буфер ядра ниже JavaScript. Буферизация stream Node управляет доставкой вверх. В TCP нет правила «колбэк сервера закончил setup». TCP говорит лишь, что состояние соединения может нести байты.

Серверы, ожидающие первое сообщение сразу после connect, должны повесить read до опциональной работы. Порядок важен:

1
2
3
4
5
server.on('connection', socket => {
  socket.on('data', onData);
  socket.on('error', onError);
  startSession(socket);
});

Обработчики есть до startSession(). Если startSession() синхронно тяжёлая, данные всё равно могут ждать в буферах Node или ядра, но у сокета уже есть пути error и read. Для протоколов со строгим таймаутом первого сообщения запускайте таймер после accept и снимайте его, когда пришло достаточно байт.

1
2
3
4
server.on('connection', socket => {
  const timer = setTimeout(() => socket.destroy(), 5000);
  socket.once('data', () => clearTimeout(timer));
});

Таймер измеряет приход первого байта на уровне процесса сервера. Он не измеряет DNS, выбор маршрута, клиентский TCP handshake или очередь accept до колбэка. Для этого нужны метки времени на клиенте, на accept сервера и иногда данные ядра или прокси.

Балансировщик добавляет ещё пару точек наблюдения. Клиент может подключиться к балансировщику, затем балансировщик — к backend. Метка accept backend относится ко второму TCP-соединению, а не к исходному клиентскому. Если балансировщик ждёт выбора backend, health или свободного upstream, клиент может видеть успешный connect, пока backend ещё ничего не видит. Поздние главы про HTTP назовут заголовки и поведение прокси. На этом слое важен факт разделения соединений.

Тот же разрыв объясняет, почему remoteAddress может быть верным и неполным. Он верен для непосредственного TCP peer. Он неполон для пользователя или устройства, начавшего запрос за несколькими границами. Трактуйте его как истину сокета, а не как истину личности.

Жизненный цикл соединения тоже имеет две точки наблюдения. Клиент может вызвать end() и считать записи завершёнными. На сервере в приёмном буфере могут оставаться непрочитанные байты. Сервер может вызвать end(), а клиент прочитает финальные байты позже. Reset может сорвать доставку ожидающего. Логи «closed в 10:00:00.100» на одной стороне и «read в 10:00:00.120» на другой могут быть правдоподобны при разных часах, буферах и планировании.

Для локальной отладки держите компактную шкалу времени:

1
2
3
4
5
6
7
8
client lookup start
client connect start
client connect event
server connection event
server first data
client first data
client close
server close

В продакшен-логах редко нужна каждая строка. При локальном сбое последовательность показывает, какая часть пути пропала. Нет client connect — lookup/connect. Нет server connection — маршрут, bind, firewall, backlog или промежуточные границы. Нет server first data — записи клиента, буферизация или раннее закрытие. Нет client first data — write-путь сервера, закрытие peer или маршрут ответа.


Промежуточные устройства меняют то, что видит каждая сторона

Прямой путь проще всего объяснять. В продакшене между клиентом и сервером часто стоят системы.

NAT (Network Address Translation) переписывает адреса или порты пакетов на границе. Процесс клиента может bind локальный адрес 10.0.0.20:52744, а сервер видит другой source 203.0.113.7:61002. Соединение остаётся TCP end-to-end по переведённому пути, но видимый tuple меняется на границе.

Перепись адресов важна для логов.

1
2
3
server.on('connection', socket => {
  console.log(socket.remoteAddress, socket.remotePort);
});

Эти поля показывают peer, видимый ядру сервера. За NAT это может быть переведённый адрес, а не исходный хост клиента. Для сырого TCP Node не восстановит адрес до перевода, если более высокий протокол или инфраструктура его не передаст. HTTP-заголовки forwarded-адресов — в более поздних главах.

Firewall — политика, разрешающая, отвергающая или отбрасывающая трафик по полям пакета, состоянию соединения, правилам процесса или конфигурации хоста. Для процесса Node поведение firewall часто выглядит как отказ соединения, таймаут или трафик в одну сторону без ответа в другую. Наблюдаемая ошибка зависит от того, отвергает firewall активно или молча отбрасывает.

Граница прокси резче меняет владение. Граница прокси — сетевой hop, где клиент подключается к промежуточному процессу, а тот создаёт или ведёт отдельное соединение к следующему назначению. Клиентское TCP заканчивается на прокси. Серверное TCP начинается от прокси или другого слоя прокси. HTTP-проксирование, CONNECT и reverse proxy — материал главы 10. Здесь достаточно имени границы: оно объясняет, почему remoteAddress на backend — адрес прокси.

Граница балансировщика — hop, где трафик входит в систему балансировки до одного backend-процесса. На уровне TCP балансировщик может пробрасывать соединения, завершать и создавать новые или использовать платформенный forwarding. Алгоритмы балансировки — намного позже. Для этой трассировки важнее меньше: backend может видеть балансировщик как peer; клиент может подключаться к адресу балансировщика; сбои могут случиться до того, как backend что-либо получит.

Source address может меняться больше одного раза. Ноутбук за домашним NAT подключается к облачному балансировщику. Балансировщик форвардит на backend. Процесс Node на backend видит адрес со стороны балансировщика. Клиентский адрес на уровне приложения, если нужен, должен нестись выше TCP протоколом или side channel с ясными правилами доверия.

Границы также влияют на таймауты. Клиентский таймаут может быть локальным таймером. Балансировщик может закрывать idle-соединения. Firewall может сбрасывать состояние для idle flow. Backend может уничтожать сокеты при shutdown. Один и тот же код ошибки JavaScript может стоять после разных нижних событий — важны тайминг и адресная картина.

NAT также держит состояние вне обоих endpoint-процессов. Переводчик должен помнить, как внутренний tuple сопоставлен внешнему. Idle-маппинги могут истекать. После истечения поздние пакеты могут отбрасываться или мапиться иначе. Долгоживущие TCP обычно опираются на реальный трафик, TCP keep-alive или пинги приложения, чтобы держать middle state живым. TCP keep-alive разбирался в предыдущем подразделе; здесь это факт пути: соединение может умереть, потому что исчезло промежуточное состояние, хотя оба процесса ещё держат объекты сокетов.

Firewall добавляет другую неоднозначность. Политика reject шлёт ответ, который стек клиента превращает в быструю ошибку. Политика drop даёт тишину — клиент ждёт до таймаута или политики TCP retransmit. Обе политики могут защищать один порт. Тайминг ошибки клиента подсказывает, какое поведение вероятнее.

Границы прокси ломают предположения endpoint. Backend видит TCP-соединение прокси. Исходный клиент может быть представлен только в метаданных протокола — и их нужно доверять согласно границе развёртывания. У сырого TCP нет стандартного поля «исходный клиент». Некоторые proxy-протоколы добавляют его до байт приложения. HTTP использует заголовки во многих развёртываниях — позже. Сетевой вывод уже достаточен: socket.remoteAddress — непосредственный TCP peer.

Границы балансировщика могут скрывать отсутствие backend. Клиент подключается к балансировщику, пока нет здорового backend. Клиентское TCP может успеть, затем балансировщик закрывает, сбрасывает или держит соединение — по продукту и режиму протокола. У backend Node нет connection, потому что соединение до него не дошло. У клиента удалённый endpoint был достижим. Сбой сидит на границе балансировщик–backend.

Middlebox также меняют MTU, маршруты или idle-политики. Детали быстро становятся платформенными. Держите вопрос отладки меньше: какой TCP peer ваш процесс реально подключил, и какая граница могла изменить tuple до прихода.


Размещение ошибок на пути

Коды ошибок полезнее, когда сидят на этапе.

Этап DNS:

1
2
net.connect(80, 'missing.invalid')
  .on('error', err => console.error(err.code));

ENOTFOUND — имя не разрешилось в пригодный ответ. EAI_AGAIN — временный сбой резолвера. Оба до TCP-сокета к целевому сервису. Правка listen-кода сервера их не исправит.

Этап bind:

1
2
3
4
5
net.createServer().listen(3000);

net.createServer()
  .on('error', err => console.error(err.code))
  .listen(3000);

EADDRINUSE — локальный bind конфликтует с существующим сокетом. EADDRNOTAVAIL — запрошенный локальный адрес недоступен для bind на этом хосте или в namespace. Ошибки старта сервера или явного локального bind клиента.

Этап connect:

1
2
net.connect(65000, '127.0.0.1')
  .on('error', err => console.error(err.code));

ECONNREFUSED — удалённый стек отклонил соединение для этого адреса и порта. Локальный repro: connect на порт без listener. ETIMEDOUT — путь connect не завершился до срабатывания политики таймаута. Причина может быть маршрут, firewall, потеря пакетов или неотвечающий endpoint.

Давление на accept:

Нет аккуратной JavaScript-ошибки «очередь accept была полна мгновение». Клиенты могут видеть таймауты, reset или медленные connect. Логи сервера — меньше колбэков connection, чем входящих попыток. Локальные инструменты таблицы сокетов могут показать глубину очередей или много half-open — по ОС и правам. Детали очередей — в главе про backlog; здесь достаточно размещения.

Этап read и write:

ECONNRESET обычно на read, write или при idle после того, как соединение существовало. EPIPE — при записи в сокет, чей peer уже ушёл достаточно далеко, чтобы локальный стек отверг запись. Важен тайминг. Reset может прийти, пока код занят другим, и всплыть на следующем read или write.

Этап close:

'end', 'close' и 'error' — разные сигналы. Упорядоченный FIN peer может дать readable end, затем close. Reset может дать error и close. Локальный destroy даёт close с локальным намерением. Логи close без адресов endpoint и состояния сокета — слабое доказательство.

Этап timeout:

1
2
3
socket.setTimeout(5_000, () => {
  socket.destroy(new Error('idle socket'));
});

setTimeout() на сокете — таймер неактивности на слое socket Node. Он отделён от TCP keep-alive и от idle-таймаута балансировщика. Когда срабатывает, колбэк решает, что делать. destroy создаёт локальный разбор; peer может увидеть резкое закрытие в зависимости от ожидающих данных и платформы.

Самый чистый отчёт об ошибке включает четыре поля: операция, локальный адрес, удалённый адрес, этап. «connect к 203.0.113.10:443 истёк с 10.0.0.5» указывает на другую проблему, чем «write в accepted socket сброшен после 12 минут idle».

Та же разметка компактной таблицей:

1
2
3
4
5
6
7
stage        common signal
lookup       ENOTFOUND, EAI_AGAIN
bind         EADDRINUSE, EADDRNOTAVAIL
connect      ECONNREFUSED, ETIMEDOUT
accept       missing callback, reset, slow connect
read/write   ECONNRESET, EPIPE, unexpected close
idle         socket timeout, keep-alive failure, middlebox close

Таблица не таксономия каждого кода. Это стартовая позиция для следующей команды. Ошибки lookup ведут к конфигурации резолвера. Bind — к локальному состоянию сокетов. Connect — к маршруту, firewall, listener и семействам адресов. Давление accept — к backlog, лимитам дескрипторов, CPU и задержке event loop. Read/write — к разбору peer и состоянию протокола.

Один код может сменить этап по таймингу. ECONNRESET во время connect — попытка получила reset до пригодного stream. ECONNRESET во время write — ранее подключённый peer или middlebox сбросили установленное соединение. Код совпадает с reset на уровне пакета. Операция говорит, где процесс это увидел.

Для сырых сервисов на net.Socket добавляйте контекст этапа в лог рядом с операцией:

1
2
3
4
5
6
socket.on('error', err => {
  console.error('socket error', {
    stage: socket.connecting ? 'connect' : 'connected',
    code: err.code,
  });
});

Крошечное различие отсекает много ложных следов. Reset на этапе connect — про достижимость или listener. Reset на connected — про жизненный цикл сессии, поведение peer или разбор на middlebox.


Как сделать путь видимым

Самая быстрая полезная трассировка начинается внутри Node.

1
2
3
4
5
6
7
8
const server = net.createServer(socket => {
  console.log('local', socket.localAddress, socket.localPort);
  console.log('remote', socket.remoteAddress, socket.remotePort);
});

server.listen(0, '127.0.0.1', () => {
  console.log('listen', server.address());
});

Порт 0 просит ОС выбрать свободный порт. server.address() печатает bound address после успешного listen(). Accepted socket печатает tuple endpoint, видимый серверу.

Сторона клиента:

1
2
3
4
5
const socket = net.connect(server.address().port, '127.0.0.1');

socket.on('connect', () => {
  console.log('client local', socket.address());
});

Вывод показывает эфемерный локальный порт исходящего соединения. Он подтверждает семейство адресов и source, который ОС выбрала для этого маршрута.

На Linux ss показывает состояние таблицы сокетов ядра:

1
ss -tanp

Сначала важнее поля, чем флаги. Смотрите локальный адрес, peer, состояние TCP и владельца процесса, когда права позволяют. Listening-сокет — локальный адрес и порт. Connected — оба конца. Много сокетов в TIME-WAIT, SYN-SENT или ESTAB ставят процесс на разные части пути.

Маршрутизация тоже видна:

1
ip route get 93.184.216.34

Команда спрашивает ядро, какой маршрут оно возьмёт для назначения. На Linux в выводе часто есть выбранный интерфейс и source address. Сравните source с socket.address() после connect. Если они расходятся из‑за последующего NAT, значение Node всё равно говорит, что выбрало локальное ядро.

Для DNS логируйте и имя, и финальный endpoint сокета. Список разрешённых адресов без подключённого endpoint не показывает address racing и fallback. Подключённый endpoint без исходного hostname теряет контекст резолвера.

1
2
3
4
5
6
7
socket.on('connect', () => {
  console.log({
    host: 'example.com',
    local: socket.address(),
    remote: `${socket.remoteAddress}:${socket.remotePort}`,
  });
});

Этих полей часто хватает для локальной отладки. Захват пакетов подтверждает нижний поток, но быстро разрастается и уместен в узкой задаче. Начинайте с логов процесса, таблицы сокетов, lookup маршрута и точного этапа ошибки.

Небольшая сквозная локальная трассировка полезнее большого framework-теста.

1
2
3
4
5
const server = net.createServer(socket => {
  socket.end('ok\n');
});

server.listen(0, '127.0.0.1', connectBack);

Сервер слушает на порту, выбранном ядром. Колбэк идёт после успешного bind и listen. В этот момент у server.address() есть реальные данные.

1
2
3
4
5
function connectBack() {
  const { port } = server.address();
  const socket = net.connect(port, '127.0.0.1');
  socket.on('data', chunk => console.log(String(chunk)));
}

Клиент идёт через loopback. Маршрут, source, remote, connect, accept, read и close на одном хосте. Если это падает — проблема в локальном процессе или локальном состоянии сокета. Если работает, а удалённая версия нет — разница в DNS, маршрутизации, firewall, middlebox или удалённом listener.

Когда минимальный случай работает, добавьте лог endpoint:

1
2
3
4
socket.on('connect', () => {
  console.log('client', socket.address());
  console.log('server', socket.remoteAddress);
});

Для реального удалённого соединения сопоставьте логи с ss, пока сокет установлен. Вывод процесса — что обернул Node. Вывод ядра — чем владеет ОС. Вывод маршрута — как хост выбрал путь. Три вида должны сойтись, прежде чем винить протокол приложения.

Контейнеры добавляют ещё одну границу namespace. Процесс внутри контейнера может видеть другой список интерфейсов, таблицу маршрутов и локальный адрес, чем хост. 127.0.0.1 внутри контейнера — loopback контейнера. Проброс порта хоста или bridge может переписать видимый путь. Сырой код Node тот же, контекст ядра вокруг — другой. Команды route и socket-table запускайте из того же network namespace, что и процесс, когда возможно.

Когда host-инструментов нет, логируйте то, что видит Node: server.address(), socket.address(), socket.remoteAddress, socket.remotePort и коды ошибок с этапами. Эти значения не показывают каждую сетевую границу, но делают неверные предположения видимыми.

Ещё одно полезное различие в локальных трассировках: достижимость listener и готовность протокола.

Listening TCP-сокет может принять соединение до того, как приложение готово чисто обработать протокол. Процесс может стартовать, греть кэши, грузить конфиг или ждать зависимость. Порт открыт. Accept-путь работает. Сервис более высокого уровня всё ещё не может ответить осмысленными байтами.

Сырой TCP-инструментарий сообщит об успехе на слое сокета:

1
2
3
4
5
const socket = net.connect(port, host);

socket.on('connect', () => {
  console.log('tcp ok');
});

Здесь host и port — точная тестируемая конечная точка.

Лог доказывает, что исходящий и входящий TCP-пути завершились. Клиент выбрал локальную конечную точку, маршрут сработал, сервер принял, JavaScript увидел connected socket. Он ничего не говорит о следующем парсере, обработчике запроса, вызове БД или правиле ответа.

Проверки готовности сервера часто размывают эту линию. Проверка порта верифицирует достижимость listener. Проверка протокола — что сервис разбирает и отвечает на валидный запрос. Проверка зависимостей — больший граф приложения. Они из глав про развёртывание позже, но граница TCP объясняет, почему они разные. Одна использует путь из этой главы. Остальные начинаются после него.

Для сырого сервиса крошечная проверка протокола может быть достаточной:

1
2
3
4
socket.write('ping\n');
socket.once('data', chunk => {
  console.log(String(chunk));
});

Трассировка пересекает границу потока байт. Клиент отправил байты приложения. Сервер прочитал, применил правило протокола и записал ответ. Как только ping обретает смысл, обсуждение вышло из чистого TCP в территорию протокола приложения.


Граница перед HTTP

После завершения входящего accept-пути у Node есть подключённый TCP-поток байт.

Сетевой фундамент на этом останавливается.

По stream могут идти HTTP-запрос, startup-пакет PostgreSQL, команда Redis, свой бинарный протокол или произвольные байты. TCP не знает. net.Socket не знает, пока код выше не разберёт байты. Следующая глава владеет wire format HTTP, семантикой запросов, разбором, agents, pools, прокси и streaming тел.

Последняя трассировка честно держит границу:

1
2
3
4
5
DNS resolved
  -> TCP connected
  -> socket accepted
  -> bytes readable
  -> protocol parser runs

Глава 9 владеет всем до «байты читаемы на подключённом сокете». Глава 10 начинается, когда у этих байт есть смысл HTTP.

Связанное чтение

Комментарии