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

TCP в Node.js: поток данных и сбои

Источник: theNodeBook — TCP Flow & Failure

Поведение TCP-соединений в Node.js вытекает из состояния TCP в ядре, которое доступно через события и ошибки сокета. Ниже — handshake, flow control, упорядоченная доставка, FIN, RST, ECONNRESET, EPIPE, ECONNREFUSED и ETIMEDOUT. В JavaScript вы видите connect, data, end, error и close, а ОС отслеживает номера последовательности, окна, повторные передачи и состояние завершения.

TCP-соединения и режимы сбоя в Node.js

Обратное давление (backpressure) пересекает эту границу. socket.write(), возвращающий false, означает, что Node локально поставил в очередь достаточно исходящих данных. Flow control TCP и буферы ядра решают, когда можно передать ещё байты. Медленный читатель и «сломанный» пир создают разное состояние сокета.

ECONNRESET — это отчёт об ошибке на уровне JavaScript о более низком состоянии TCP. Stack trace указывает на Node, потому что именно Node доставляет ошибку в ваш код. Решение на транспортном уровне уже принято в слое сокета.

1
2
Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20)

Пир отправил reset. Или локальная запись попала в состояние сокета, которое уже перешло в reset. Node прочитал результат из ядра, обернул его в системную ошибку и эмитировал через net.Socket.

Та же форма относится к ECONNREFUSED, ETIMEDOUT и EPIPE. Ошибка JavaScript — отчёт. Состояние TCP-соединения — источник.

В разделе про TCP/IP и сетевой стек ОС описана граница сокетов ОС. В разделе про DNS — как имена становятся адресами. После того как адрес есть, работу берёт на себя TCP: создаёт соединение, нумерует байты, повторно отправляет пропущенное, применяет flow control, завершает оба направления и сообщает о сбое, когда автомат состояний доходит до «сломанного» края.

TCP-соединение — это состояние в ядре

TCP-соединение — транспортное состояние ОС для одного упорядоченного потока байтов между двумя адресами сокетов. Соединение идентифицируется протоколом плюс локальный IP, локальный порт, удалённый IP и удалённый порт. На каждом конце хранится своё состояние для одного и того же соединения.

Node оборачивает один конец.

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

const socket = net.connect(5432, '127.0.0.1');

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

net.connect() просит ОС создать TCP-сокет и подключить его к удалённому адресу сокета. Напечатанный локальный порт выбирает ОС, если программа не указала свой. Объект JavaScript становится полезным после того, как соединение в ядре достигло состояния established и Node эмитировал connect.

Состояние соединения — текущая позиция в жизненном цикле TCP для одного конца. Сюда входят установка, передача в established, завершение, reset и состояния ожидания. Имена различаются в выводе инструментов и на платформах, но знакомые состояния из ss или netstat узнаваемы: SYN-SENT, SYN-RECEIVED, ESTABLISHED, FIN-WAIT, CLOSE-WAIT, LAST-ACK, TIME-WAIT и CLOSED.

Node даёт меньшую поверхность. connect — локальный конец дошёл до established. data — байты прочитаны из приёмного пути сокета. end — пир упорядоченно завершил свою сторону записи. error — операция с сокетом не удалась. close — обёртка JavaScript завершила закрытие.

Эти события сжимают много состояния.

1
2
3
4
5
6
JavaScript net.Socket
  -> нативная TCP-обёртка Node
  -> libuv TCP handle
  -> TCP-сокет ОС
  -> TCP-сокет пира в ОС
  -> программа пира

TCP представляет упорядоченный поток байтов. Сохраняет порядок байтов по соединению. Доставляет байты приложению по порядку. Отслеживает пропущенные диапазоны и повторно передаёт. Скрывает границы TCP-сегментов от приложения. Пир, читающий из Node, видит чанки, которые формируют его собственный приёмный путь, буферы и чтения потока.

1
2
socket.write('abc');
socket.write('def');

Пир может получить 'abcdef' в одном событии data, 'abc' и 'def' в двух или более мелких чанках. TCP сохранил порядок байтов. Он дал непрерывную последовательность байтов, а не границы записей приложения.

От этого зависят все протоколы поверх TCP. HTTP, Redis, Postgres и ваш собственный бинарный протокол нуждаются в правилах кадрирования выше TCP. Раздел про HTTP в nodebook посвящён кадрированию HTTP; здесь достаточно транспортного правила: TCP несёт упорядоченные байты.

Handshake создаёт соединение

Трёхсторонний handshake — обмен управлением TCP, который синхронизирует состояние на обоих концах до передачи байтов приложения. Три управляющих сообщения:

1
2
3
client -> server: SYN
server -> client: SYN-ACK
client -> server: ACK

SYN запрашивает начало соединения и несёт начальный номер последовательности отправителя. SYN-ACK принимает начало и несёт начальный номер сервера, подтверждая SYN клиента. ACK подтверждает начало сервера. После этого у обеих сторон достаточно состояния последовательности для упорядоченной отправки байтов.

Для исходящего клиента Node путь примерно такой:

1
2
3
4
5
6
net.connect()
  -> локальный сокет создан
  -> SYN отправлен
  -> SYN-ACK получен
  -> ACK отправлен
  -> эмитирован 'connect'

Колбэк JavaScript выполняется после успешного handshake в ядре. Если пир отклоняет попытку, до connect дело не доходит.

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

const socket = net.connect(1, '127.0.0.1');

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

На типичной машине без слушателя на порту 1 обычно печатается ECONNREFUSED. Целевой хост ответил на TCP-попытку отказом — часто reset, потому что ни один слушающий сокет не принял этот адрес и порт. Точный путь зависит от политики ОС и firewall, но для Node смысл узкий: удалённый конец активно отклонил connect.

Для входящего сервера нижний путь начинается до появления сокета в JavaScript:

1
2
3
4
5
6
7
слушающий сокет
  -> SYN получен
  -> SYN-ACK отправлен
  -> ACK получен
  -> подключённый сокет в очереди
  -> Node accept
  -> эмитирован 'connection'

Backlog и очередь accept — в разделе про опции сокетов и backlog. Handshake важен и здесь: net.createServer() вызывает колбэк соединения после того, как ОС накопила достаточно состояния для принятого подключённого сокета.

Отказ до установления — до появления полезного net.Socket. Reset может случиться после соединения. Таймаут — пока локальная ОС пытается завершить setup. Эти различия важны в логах: они указывают на разные переходы состояния.

Отказ происходит до того, как сокет становится полезным

ECONNREFUSED — сбой на этапе установления. Клиент выбрал локальный конец, нацелился на удалённый адрес сокета, отправил попытку соединения и получил активный отказ. Объект сокета в JavaScript существует, но соединение не дошло до established для передачи.

Случай loopback — самый наглядный:

1
2
3
4
client: SYN на 127.0.0.1:65000
kernel: нет подходящего listener
kernel: отклонение попытки
Node: error ECONNREFUSED

«Подходящий listener» — тот же протокол, семейство адресов, правила привязки локального адреса и порт. Сервер на 127.0.0.1:3000 принимает IPv4 loopback на этом порту. Клиент на ::1:3000 бьёт в IPv6 loopback. Порт совпадает, семейство адресов — нет. Разделение семейств из TCP/IP и сетевой стек напрямую участвует в setup TCP.

Удалённый отказ — та же категория этапа соединения, но с большим путём в сети. SYN доходит до целевого хоста или устройства от его имени. Что-то возвращает сигнал отказа. Node получает неуспешный connect. Обработчик connect молчит — сокет не стал established.

Firewall меняет картину. Может отклонить — быстрый сбой. Может дропнуть — тишина. Тишина ведёт к повторным SYN и затем к таймауту. В приложении оба случая выглядят как «connect failed», но нижнее поведение разное.

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

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

const started = Date.now();
const s = net.connect(65000, '127.0.0.1');

s.on('error', (err) => {
    console.error(err.code, Date.now() - started);
});

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

Номера последовательности делают байты восстанавливаемыми

Номер последовательности — позиция байта в потоке TCP. Каждый конец нумерует исходящий поток. ACK сообщают, какие байты приёмник принял по порядку. TCP обнаруживает дыры, удерживает более поздние данные, пока не придут пропущенные, и повторно передаёт диапазоны.

Полезная модель — диапазоны байтов, а не записи приложения.

1
2
3
4
client sends bytes 1000..1499
server ACKs 1500
client sends bytes 1500..1999
server ACKs 2000

ACK означает, что приёмник принял все байты до указанного номера. ACK 1500 — байты через 1499 пришли по порядку. Если байты 1500..1999 пропали в сети, у отправителя достаточно состояния, чтобы отправить диапазон снова.

Повторная передача — повторная отправка диапазона байтов, когда отправитель считает предыдущую передачу неудачной. Триггер — таймаут, дубликаты ACK или детали реализации. Реализация TCP в ядре владеет этой работой.

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

RTT (round-trip time) — измеренное время до пира и обратно с подтверждением. TCP использует оценки RTT для таймеров повторной передачи и поведения отправки. Высокий RTT растягивает интервал между отправкой и сведением о доставке. Переменный RTT усложняет таймауты в ядре.

1
2
3
4
5
6
write accepted locally
  -> segment sent
  -> ACK delayed or lost
  -> retransmission timer adjusted
  -> missing bytes sent again
  -> peer delivers ordered bytes

Node видит верхний край процесса. Событие data приходит с опозданием. Запрос кажется медленным. Сокет остаётся открытым. Ошибки нет — соединение по мнению TCP ещё работает.

Номера последовательности объясняют, почему TCP может принимать данные не по порядку внутри, а приложение читает упорядоченно. Ядро может получить 2000..2499 раньше 1500..1999, удержать поздний диапазон и ждать пропущенный. JavaScript получает байты после заполнения дыры.

Это полезно и скрывает боль. Сервис в продакшене может иметь потери, ретрансмиссии и плохую пропускную способность, пока Node показывает здоровый connected-сокет. Восстановление идёт ниже приложения. Нужны счётчики ОС, захват пакетов или тайминги.

ACK — транспортное состояние. Они сообщают позиции байтов на слое TCP. Ноль информации о том, разобрал ли удалённый процесс байты, сохранил, закоммитил транзакцию или отправил ответ.

Граница важна для каждого write-heavy клиента:

1
2
3
4
socket.write(payload, (err) => {
    if (err) throw err;
    markSent(payload.id);
});

markSent() там опасное имя. Колбэк может означать лишь завершение локального пути записи. Лучше назвать то, что программа знает: байты приняты локально или запись локально не удалась. Доставка на уровне приложения требует ответа протокола от пира.

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

Пространство последовательности включает управляющие сигналы. SYN и FIN занимают позиции в учёте TCP. В коде Node это редко нужно, но объясняет, почему handshake и shutdown — часть того же упорядоченного автомата, что и передача данных.

Повторная передача создаёт дубликаты ниже приложения. Принимающий TCP по номерам отбрасывает уже принятые диапазоны. JavaScript дубликаты обычно не видит. Если пакет потерялся после ACK приёмника, отправитель может повторить из-за потери ACK. Приёмник распознает повтор и сохранит чистый поток для приложения.

1
2
3
4
5
receiver gets bytes 1000..1499
receiver sends ACK 1500
ACK disappears
sender retransmits 1000..1499
receiver discards duplicate range

Приложение читает одну копию. TCP понёс дублирующий трафик из-за неопределённости, приёмная сторона подавила дубликаты до Node.

Задержка доставки — обычный симптом приложения. Ядро ждёт пропущенные байты, переупорядочивает диапазоны, повторяет передачу и отдаёт упорядоченные данные. Программа Node видит поздний чанк. Обычно не отличит ретрансмиссию от планирования пира, backpressure приёмника или блокировки процесса пира до чтения. TCP скрывает оба сценария за одним потоком байтов.

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

Одна запись порождает несколько решений TCP

Одна запись подключённого клиента Node:

1
socket.write(Buffer.alloc(32 * 1024));

Вызов отдаёт Node 32 KiB байтов приложения. Слой stream принимает чанк или ставит в очередь. Нативный код отправляет работу через libuv. Путь сокета ОС принимает часть или все байты в send buffer TCP. TCP решает, как разложить байты по сегментам.

Ниже вызова JavaScript действуют несколько лимитов. MTU пути ограничивает размер пакета. Окно приёма пира — насколько отправитель может продвинуться без переполнения приёмника. Congestion control — сколько данных в сеть до ACK. Собственный буфер отправителя ограничивает, сколько ОС может держать.

Нижний след может выглядеть так:

1
2
3
4
5
app bytes 0..32767 accepted locally
TCP sends 0..1447
TCP sends 1448..2895
peer ACKs 2896
TCP sends more ranges

Диапазоны иллюстративны. Реальные размеры сегментов зависят от MSS, offload, пути и настроек платформы. Факт для Node остаётся меньше: одна запись вошла в путь сокета. TCP может отдать много сегментов, получить много ACK, повторить диапазоны и только потом освободить место в send buffer.

Пир читает поток байтов:

1
2
3
4
peer kernel receives ranges
peer TCP orders them
peer receive buffer stores bytes
peer Node process reads chunks

Событие data пира может содержать 32 KiB, 16 KiB, 1 KiB или любое дробление приёмного пути. Состояние последовательности TCP защищает порядок. Механика stream Node решает, какими чанками отдавать данные в JavaScript.

Тайминг ACK может пересекаться с записями приложения. Отправитель пишет 32 KiB, затем сразу ещё 32 KiB. Локальный stream может принять оба чанка, пока ядро ждёт ACK за первые диапазоны. Вторая запись может сидеть в очереди Node, в libuv или в send buffer ядра. Когда окно пира откроется, нижние слои продолжат. JavaScript увидит drain, когда очередь Node опустится ниже порога.

Типичный продакшен-лог:

1
2
3
write returned false
drain after 240ms
response after 900ms

Первая строка — давление локального stream. Вторая — можно снова производить локально. Третья — прогресс протокола приложения. Сводить всё к «сеть была медленной» — терять полезное разделение.

Flow control переходит в backpressure Node

Flow control — приёмнико-ориентированный лимит TCP на объём данных в полёте для соединения. Приёмник объявляет доступное место. Отправитель держит неподтверждённый объём в пределах объявленного окна.

Объявленный лимит — receive window. Он зависит от буфера приёмника и состояния TCP. Быстрое чтение приложением опустошает буфер — окно остаётся открытым. Чтение остановилось — буфер заполняется, окно сжимается. Нулевое или крошечное окно говорит пиру замедлиться на слое TCP.

1
2
3
4
5
6
peer application writes bytes
  -> peer kernel send buffer
  -> network
  -> local kernel receive buffer
  -> Node reads into stream
  -> JavaScript consumes chunks

Receive buffer — память ядра с байтами, пришедшими на сокет и ждущими чтения приложением. Send buffer — память ядра с байтами, принятыми от приложения и ждущими передачи, ACK или ретрансмиссии.

Backpressure stream Node, описанный в основах потоков, сидит выше этих буферов. У stream своя очередь и highWaterMark. У ядра — send и receive buffers. У TCP — receive window. Это разные сигналы, которые могут влиять друг на друга.

1
2
3
const server = net.createServer((socket) => {
    socket.pause();
});

Обработчик принимает соединение и останавливает чтение со стороны JavaScript stream. Байты ещё могут приходить. Receive buffer ядра может заполниться. Node мог уже вытянуть часть байтов в буферы stream до pause(). Когда нижние буферы сжимаются, TCP объявляет пиру меньше места приёма.

Отправитель видит давление через свой путь записи.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import net from 'node:net';
const server = net.createServer((s) => s.pause());
server.listen(0, '127.0.0.1', () => {
    const { port } = server.address();
    const c = net.connect(port, '127.0.0.1', () => {
        while (c.write(Buffer.alloc(64 * 1024))) {}
        console.log('write pressure');
        c.destroy();
        server.close();
    });
});

Цикл пишет, пока writable-сторона Node не скажет остановиться. false — сигнал уровня stream: локальная очередь writable пересекла порог. У приложения и ядра пира своё состояние. Производитель должен ждать drain перед продолжением.

Ниже сигнала локальный send buffer ядра тоже конечен. Node может отдавать байты ОС, пока ОС не примет меньше, ничего пока или не вернёт ошибку. libuv интегрирует это с неблокирующими записями. JavaScript получает колбэки и позже drain, когда верхняя очередь достаточно опустела.

Flow control — удалённый приёмник отталкивает через состояние TCP. Backpressure stream — Node отталкивает через API JavaScript. Оба могут проявиться при одном замедлении, но принадлежат разным слоям.

Слои выстраиваются так:

1
2
3
4
5
6
7
JavaScript producer
  -> Writable stream queue
  -> Node/libuv write request
  -> kernel send buffer
  -> TCP flight governed by peer receive window
  -> peer kernel receive buffer
  -> peer JavaScript consumer

Успешный socket.write() — Node принял чанк в путь записи. true — буфер stream ещё ниже порога. Колбэк записи — чанк ушёл из пользовательского учёта записи Node в локальный системный путь. Ни один из сигналов не доказывает, что приложение пира обработало байты.

Пир может быть медленным в нескольких местах. JavaScript занят и не читает. Процесс на паузе. Receive buffer ядра полон. Receive window TCP мал. Путь с потерями. Локальный send buffer копит данные. Очередь stream Node пересекла highWaterMark.

Видимый симптом часто только:

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

Паттерн правильный для JavaScript: уважает контракт stream и ограничивает память, пока транспорт согласует буферы. drain — разрешение возобновить локальное производство, не подтверждение семантического прогресса пира.

Давление приёма работает в обратную сторону. Node читает с сокета и пишет в более медленный приёмник — stream.pipeline() связывает давление между потоками. На слое TCP замедление чтения может уменьшить объявленное окно. Пир может держать соединение открытым, отправляя мало данных. Исключение не обязательно — соединение соблюдает flow control.

При отладке это сбивает с толку. Запрос висит, CPU низкий, ошибок нет. Сокет ждёт: в буфере ниже JavaScript нет места, у пира маленькое окно или congestion и ретрансмиссии режут прогресс. Node сообщает об ошибке, когда TCP ломается, а не когда TCP законно ждёт.

Цепочка буферов на стороне отправки — три владельца:

1
2
3
Node stream buffer
libuv write requests
kernel TCP send buffer

Буфер stream — для JavaScript: write() и drain. Запросы libuv — нативные записи, ждущие ОС. Send buffer ядра — для TCP: неотправленные, отправленные без ACK, ожидающие ретрансмиссии.

Владельцы движутся с разной скоростью. Node принимает много чанков из JavaScript, кормит libuv. ОС принимает часть в send buffer. TCP шлёт диапазоны с учётом окна пира и congestion.

Цепочка приёма:

1
2
3
4
kernel TCP receive buffer
Node native read path
Readable stream buffer
JavaScript consumer

Receive buffer ядра заполняет TCP после проверки последовательности. Node читает при готовности libuv. Readable stream буферизует до потребления JavaScript. Потребитель остановился — Node перестаёт тянуть из ядра, буфер заполняется, окно сжимается.

Backpressure может начаться в JavaScript и стать поведением транспорта. Медленный парсер, заблокированный transform или pause() на сокете в итоге уменьшают receive window. Пир видит только окно и паттерн ACK.

И наоборот: транспортное давление становится backpressure JavaScript. Маленькое окно пира — медленно пустеет локальный send buffer, медленно завершаются write requests Node, дольше высока очередь Writable, чаще write() возвращает false.

Поэтому код с учётом backpressure полезен, хотя не видит все слои.

1
2
3
4
5
6
7
8
import { once } from 'node:events';

async function send(socket, chunks) {
    for (const chunk of chunks) {
        if (!socket.write(chunk))
            await once(socket, 'drain');
    }
}

Цикл слушает сигнал stream, не даёт памяти приложения расти без границ, пока нижние слои проходят лимиты TCP. О прогрессе на пире он ничего не утверждает.

Неправильный код — плотный producer без проверки возвращаемого значения:

1
2
3
for (const chunk of chunks) {
    socket.write(chunk);
}

При замедлении соединения в user space может накопиться огромная очередь. Ядро применяет валидный flow control. Процесс пира жив. Ваш процесс создаёт давление на память, потому что TCP-сокет воспринимается как бесконечная раковина.

Congestion делает рабочие соединения медленными

Congestion control — поведение отправителя TCP, ограничивающее агрессивность помещения данных в сеть. Flow control защищает буферы приёмника. Congestion control защищает путь между концами от перегрузки этим соединением.

Ядро владеет congestion control. Linux, macOS, Windows и контейнеры могут иметь разные умолчания. Опции сокетов упоминают socket options; алгоритмы congestion в ядре обычно вне обычного API Node.

Для отладки бэкенда достаточно модели: отправитель держит лимит отправки по ACK, потерям, RTT и состоянию алгоритма. Потери или задержки намекают на congestion — отправитель снижает темп. Пропускная способность падает, соединение открыто.

1
2
3
4
5
ACKs arrive steadily
  -> sender grows usable sending rate
loss or delay appears
  -> sender retransmits
  -> sender reduces sending rate

Медленная загрузка по TCP может быть perfectly connected сокетом под congestion control и ретрансмиссией. Node пишет. Ядро принимает часть данных. Прогресс есть, но медленнее. Таймауты приложения могут сработать выше транспорта, если вы их задали. TCP может продолжать, пока ОС считает соединение жизнеспособным.

RTT меняет форму задержки. Низкий RTT — быстрая обратная связь о доставленных байтах. Высокий RTT — каждый цикл обратной связи длиннее. Та же доля потерь на длинном пути ощущается хуже.

Острый край — тишина. Восстановление TCP часто тихое в JavaScript: поздний data, задержанный drain или дедлайн запроса из вашего кода. Сокет может оставаться established.

Если в логе «socket connected» и потом 30 секунд ничего, разделите три случая:

1
2
3
peer application is slow
peer receive path is backed up
network transport is recovering or constrained

Симптомы в Node похожи. Нужны разные доказательства. Логи приложения — прогресс обработчиков. Счётчики буферов и TCP — давление транспорта. Захват пакетов — ретрансмиссии и ACK. Один объект Node не скажет, какой владелец сейчас лимитирует прогресс.

Упорядоченное завершение использует FIN

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

Типичный путь закрытия:

1
2
3
4
5
6
local app ends writes
  -> local TCP sends FIN
  -> peer receives end-of-stream
  -> peer sends its own FIN later
  -> both FINs are ACKed
  -> connection closes

Node сопоставляет FIN пира с концом читаемой стороны stream. На net.Socket readable может эмитировать end, когда пир закончил отправку. Сторона записи может ещё быть активна в зависимости от тайминга и опций API. Методы net.Socket и allowHalfOpen — в разделе про сокеты и модуль net; транспортная идея здесь.

Half-open соединение — TCP, где одно направление закрыто, другое открыто. На транспорте одна сторона отправила или получила FIN, другая ещё может слать данные. Half-open нормален при упорядоченном shutdown. Баг — когда приложение считает, что оба направления закрылись вместе.

1
2
3
peer -> local: FIN
local readable side ends
local write side may still send

Некоторые протоколы используют это намеренно. Многие прикладные протоколы трактуют как полное завершение соединения. Node даёт события для решения; TCP разделяет направления.

TIME-WAIT — состояние TCP после активного закрытия, чтобы поздние пакеты старого соединения истекли и финальные ACK обработались. Конец, выполнивший active close, часто входит в TIME-WAIT. Длительность и правила повторного использования зависят от ОС.

TIME-WAIT часто виден в локальных тестах с множеством коротких соединений. Процесс закрыл сокеты, ядро ещё держит состояние. Могут заниматься эфемерные порты. В коде процесса уже нет, ядро защищает старую идентичность соединения.

1
2
3
4
5
ESTABLISHED
  -> FIN-WAIT-1
  -> FIN-WAIT-2
  -> TIME-WAIT
  -> CLOSED

После нагрузочного теста в выводе инструментов много TIME-WAIT. Это след teardown TCP. Операционное давление — когда кончаются эфемерные порты или ёмкость таблицы сокетов.

Риск приложения при упорядоченном shutdown: пир шлёт FIN после частичного сообщения. TCP доставил упорядоченные байты и конец потока. Парсер протокола решает, полное ли сообщение. TCP говорит, что поток байтов кончился, а не что кадр приложения цел.

Активный закрывающий обычно платит цену TIME-WAIT. «Обычно» важно: одновременное закрытие и детали платформы сдвигают путь, но типичная схема клиент–сервер узнаваема. Клиент открывает много коротких исходящих соединений, шлёт запросы, активно закрывает, накапливает TIME-WAIT на эфемерных портах.

1
2
3
4
5
client local port 50100 -> server 443
client closes
client keeps TIME-WAIT for that tuple
client opens more short connections
ephemeral range gets pressured

Пул соединений снижает давление, переиспользуя established TCP для нескольких запросов приложения. HTTP-агенты и пулы БД — позже в книге; на уровне TCP причина проста: меньше teardown — меньше недавно закрытых кортежей в ядре.

Сервер тоже копит состояния закрытия. Пир прислал FIN — локальный TCP может перейти в CLOSE-WAIT, пока приложение не закроет свою сторону. Куча CLOSE-WAIT обычно значит: пир закрыл запись, локальное приложение не закрыло сокет. В Node обработчик мог остановиться до end() или destroy(). Ядро ждёт закрытия от процесса.

1
2
3
4
5
peer sends FIN
local TCP enters CLOSE-WAIT
Node emits end
application leaves socket open
CLOSE-WAIT remains

Это не TIME-WAIT. TIME-WAIT — ожидание после завершённого active close. CLOSE-WAIT — пир завершил запись, локальное приложение ещё владеет сокетом. Одно — нормальный осадок teardown. Другое — часто утечка cleanup в приложении.

Порядок событий Node может прояснить:

1
2
socket.on('end', () => console.log('peer ended'));
socket.on('close', () => console.log('closed'));

end без ожидаемого close заслуживает проверки. Протокол допускает half-open. Код забыл закрыть. Ещё сбрасывается pending write. API — в сокеты и net; транспортное состояние объясняет симптом.

FIN пересекается с буферизованными записями. socket.end('bye') ставит байты в очередь и завершает сторону записи. Локальный TCP шлёт данные перед FIN в упорядоченном потоке. Пир читает байты, затем видит конец потока. Reset до отправки или ACK ломает упорядоченную модель — включается обработка ошибок.

Резкое завершение использует RST

RST — сигнал сброса TCP. Прерывает состояние соединения вместо упорядоченного end-of-stream. Reset говорит пиру отбросить состояние соединения. Node часто сообщает это как ECONNRESET.

Reset бывает по разным причинам:

1
2
3
4
write reaches a peer that has reset state
peer process destroys socket abruptly
middlebox rejects existing flow
local OS receives data for a closed connection

Средний пункт возможен и в Node. Для явного TCP reset в современных версиях Node:

1
socket.resetAndDestroy();

resetAndDestroy() закрывает TCP, отправляя RST, затем уничтожает состояние stream. destroy() — общий teardown stream; точные пакеты для destroy() зависят от тайминга и платформы. Подробности API — в сокеты и модуль net. Пир теряет состояние соединения; поздние операции могут падать.

Клиентская форма:

1
2
3
4
5
6
7
const c = net.connect(port, '127.0.0.1', () => {
    c.write('hello');
});

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

Если сервер сразу сбрасывает, клиент может увидеть ECONNRESET. Тайминг меняет операцию, которая это сообщит: чтение, поздняя запись, reset между тиками JavaScript.

Ловушка отладки: строка с ECONNRESET часто ниже по стеку причины. Причина — резкое закрытие пира, нарушение протокола, idle timeout на пути, локальный destroy() из верхнего слоя.

EPIPE — ошибка «сломанной трубы» при записи в соединение, которое больше не принимает записи. На Unix имя из pipe-поведения; Node может выставить и для сокетов. Практический смысл: сторона записи сломана на границе ОС.

1
2
3
4
peer has closed or reset
local code writes anyway
OS rejects the write
Node reports EPIPE or ECONNRESET by timing and platform

Код — подсказка; смотрите порядок событий. Сокет мог эмитировать end до записи. Ваш таймаут мог вызвать destroy(). Пир мог прислать ошибку протокола и закрыться. Прокси мог оборвать idle-соединение. Имя TCP ошибки называет неудачную операцию, не всю историю.

RST также отклоняет данные для состояния, которое нельзя принять. Хост получает сегмент для кортежа, которого уже нет, и может послать reset. У отправителя соединение было established, пока не пришёл reset. У приёмника кортеж уже невалиден.

Асимметрия после падений, рестартов и быстрых переподключений: сервер вышел, сокеты пропали. Клиент ещё считает соединение established. Следующая запись попадает на хост без matching state или на новый listener без памяти о старом кортеже. Клиент видит reset или broken pipe.

1
2
3
4
5
client thinks ESTABLISHED
server process exits
server TCP state disappears or resets
client writes again
client observes reset or write failure

«Сервер перезапустился» часто скрывает эту последовательность. Новый процесс на том же порту принимает новые соединения, не наследует старые established без специальной передачи дескрипторов слушателя. Обычный рестарт рвёт существующие соединения.

Тайминг reset влияет на повторы запросов. Клиент отправил запрос, получил ECONNRESET до байтов ответа — запрос мог дойти до приложения пира или нет. TCP не отвечает. Reset говорит лишь об аварийном конце транспорта. Безопасный retry — семантика протокола, идемпотентность и политика запросов (в nodebook — отдельные главы про HTTP и надёжность).

Отказ, reset, таймаут, broken pipe

ECONNREFUSED — попытка соединения дошла до хоста, который активно отклонил целевой адрес сокета. Локально — закрытый порт.

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

const s = net.connect(65000, '127.0.0.1');

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

Без слушателя на loopback обычно быстрый отказ. Firewall может дропнуть вместо отказа — ожидание и ETIMEDOUT.

ETIMEDOUT — операция превысила таймаут ОС или Node без завершения. На connect часто: локальный TCP слал SYN и не получил пригодный ответ. Firewall, маршруты, мёртвые хосты, фильтрация портов.

1
2
3
4
5
SYN sent
  -> no SYN-ACK
  -> retransmit SYN
  -> still no response
  -> timeout reported

У Node есть таймауты сокета на уровне API (ниже). Разделяйте источники. TCP connect timeout — setup не завершился. socket.setTimeout() — таймер неактивности в JavaScript. Дедлайн HTTP-клиента — выше TCP.

ECONNRESET — established-состояние прервано. Reset пира, локальный reset, устройство на пути. Сбой часто на read/write после прихода reset.

EPIPE — запись в уже закрытый или сломанный путь записи. Пир мог закрыться. Локальный сокет уже знает, что писать нельзя. Приложение всё равно пишет.

Таблица для чтения логов:

Код Обычная позиция TCP Практический смысл
ECONNREFUSED при connect цель активно отклонила адрес сокета
ETIMEDOUT при connect или OS-level send/keepalive операция слишком долго ждала прогресса транспорта
ECONNRESET после установления соединения состояние соединения прервано
EPIPE при записи сторона записи уже была сломана

Это системные ошибки. Node выставляет строки code на объектах ошибок. Один и тот же баг может дать разные коды на платформах или при другом тайминге. Код — подсказка состояния; сопоставляйте с адресами концов, недавними событиями сокета и логами протокола выше.

Таймауты — отдельный стек, потому что слово одно:

1
2
3
4
5
TCP retransmission timeout
TCP connect timeout
Node socket inactivity timeout
HTTP request deadline
application cancellation

Таймаут ретрансмиссии TCP — внутри ядра. Node обычно видит следствие — задержку.

Таймаут connect — setup не завершился вовремя. ОС шлёт SYN, ждёт, повторяет по политике, сообщает о сбое.

Таймаут неактивности сокета Node — вызовы API JavaScript. Следит за неактивностью, эмитирует timeout. Сокет открыт, пока код не закроет или не уничтожит. Это таймер, не TCP-пакет.

1
2
3
4
socket.setTimeout(5000);
socket.on('timeout', () => {
    socket.destroy(new Error('idle socket'));
});

Через пять секунд неактивности код уничтожает сокет. У пира симптом может быть похож на reset, потому что локально соединение оборвали. Источник — таймер приложения.

Дедлайн HTTP-запроса выше. Может закрыть TCP, хотя TCP здоров. Ошибка на пире выглядит транспортной; причина — политика протокола.

Отмена (AbortSignal) — та же форма. Удалённая сторона может видеть ECONNRESET, локально — «user aborted». Оба верны на своём слое.

Хорошие логи называют слой:

1
2
3
4
connect timeout to 203.0.113.10:443
socket idle timeout after connect
HTTP response deadline exceeded
operation aborted by caller

Сообщения экономят время: кто действовал первым. TCP, таймер сокета Node, клиент протокола и отмена вызывают teardown. TCP-ошибка у пира может совпадать; локальная причина — в другом слое.

Владение таймаутом влияет на cleanup. Connect timeout — сокет без connect. Idle после established — вы сами уничтожили connected-сокет. Дедлайн запроса может убить соединение из пула, которое другая часть клиента хотела переиспользовать.

Метрики таймаутов делите по фазам: connect, TLS (позже), запись запроса, заголовки ответа, тело, idle пула. Здесь — только куски TCP; привычка начинается здесь: назовите фазу, закройте состояние сокета, которым владеете.

Медленный читатель не то же самое, что сломанный пир

Оба делают запись некомфортной. Разница — в состоянии.

При медленном читателе TCP-соединение валидно. Приёмник объявляет ограниченное окно. Отправитель ставит байты в очередь, ждёт ACK и обновлений окна. Node может вернуть false из write() и позже эмитировать drain.

1
2
3
4
write returns false
  -> local queue drains slowly
  -> drain fires
  -> connection remains established

При сломанном пире состояние соединения кончилось или сброшено. Записи падают. Чтения ошибаются или end. drain может уже не быть нужным сигналом.

1
2
3
4
peer resets
  -> local socket records error
  -> next read or write reports ECONNRESET
  -> close follows

Логируйте переходы состояния по порядку:

1
2
3
4
5
6
7
8
9
for (const name of [
    'connect',
    'end',
    'error',
    'close',
    'drain',
]) {
    socket.on(name, (arg) => console.log(name, arg?.code));
}

Грубо, но показывает порядок событий. В реальной отладке добавляйте localAddress, localPort, remoteAddress, remotePort и операцию в момент события.

Тайминг важен: TCP меняется ниже JavaScript. Reset может прийти, пока готовится следующая запись. FIN — после постановки данных в очередь. Таймаут уничтожает сокет, пока Promise ещё держит ссылку. К моменту колбэка ядро может опережать вашу модель.

Чтение, запись и что значит успех

Успех socket.write() — локальное принятие. Данные вошли в writable-путь Node. Колбэк может означать, что байты уже ушли в путь ядра. Чтение приложением пира требует отдельных доказательств протокола.

1
2
3
socket.write('COMMIT\n', (err) => {
    if (err) console.error(err.code);
});

Колбэк — локальная операция записи. Для commit на уровне приложения нужен ответ приложения. TCP доставляет байты; удалённая программа решает смысл.

Чтение — зеркальная граница. data — Node вытянул байты из приёмного пути. Полные сообщения собирает парсер выше TCP по кадрированию протокола.

1
2
3
socket.on('data', (chunk) => {
    parser.push(chunk);
});

Парсер владеет границами сообщений. TCP — порядком и попытками доставки. Streams Node двигают чанки между слоями.

При shutdown успех условен. Пир мог принять байты в receive buffer ядра и упасть до приложения. Локальная запись завершилась до reset. TCP не отчитывается об обработке на пире — только о транспортном состоянии.

Поэтому request-response протоколы ждут ответа. Драйвер БД, HTTP-клиент, продюсер очереди считают ответ протокола осмысленным ACK. Колбэк TCP-записи — прогресс «сантехники».

Локальные демо обманывают полезно

Демо на loopback убирают шум маршрута, DNS и внешних потерь. TCP-состояние всё равно отрабатывается — удобно для порядка событий, слабо для продакшен-диагностики.

Отказ — чистый случай:

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

const s = net.connect(65000, '127.0.0.1');

s.on('connect', () => console.log('connected'));
s.on('error', (err) => console.error(err.code));

Без listener — error, connect нет. Удалённый firewall с дропом SYN — другая шкала времени: без быстрого отказа, повторы SYN, затем таймаут или ваш дедлайн.

Демо reset чувствительно к таймингу:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import net from 'node:net';
const server = net.createServer((s) => s.resetAndDestroy());
server.listen(0, '127.0.0.1', () => {
    const c = net.connect(
        server.address().port,
        '127.0.0.1'
    );
    c.on('error', (err) => console.error(err.code));
    c.on('close', () => server.close());
});

Сервер принимает и шлёт reset. Клиент — ECONNRESET или быстрый close в зависимости от момента и pending-операций. Разный порядок событий между прогонами учит: reset асинхронен относительно JavaScript.

Демо медленного читателя:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import net from 'node:net';
const server = net.createServer((s) => s.pause());
server.listen(0, '127.0.0.1', () => {
    const c = net.connect(
        server.address().port,
        '127.0.0.1'
    );
    c.on('connect', () => {
        console.log(c.write(Buffer.alloc(1e6)));
        c.destroy();
        server.close();
    });
});

Первая большая запись может вернуть false. Если true — пишите ещё чанки. Сервер жив и connected, просто не потребляет. Давление растёт от приложения-приёмника к буферам Node и receive window TCP.

Локальные демо скрывают congestion. Loopback — крошечный RTT и огромная эффективная полоса. Ретрансмиссия и congestion почти не видны без shaping в ОС. Категории API-событий те же; профиль времени на реальной сети другой.

Состояние TCP на хосте — вторая половина картины

События Node — что дошло до JavaScript. Состояние TCP на хосте — что держит ядро. На Linux обычно первый инструмент — ss:

1
ss -tan

Локальный и удалённый адреса плюс состояние TCP. В локальном демо — ESTAB, TIME-WAIT, CLOSE-WAIT или состояния setup, если поймать быстро.

Сопоставляйте с кортежем endpoint. Node залогировал local 127.0.0.1:50100 remote 127.0.0.1:3000 — ищите эти порты в ss. ESTAB — ядро считает соединение установленным. TIME-WAIT — teardown через active close, ядро держит кортеж. CLOSE-WAIT — пир прислал FIN, локальному процессу ещё нужно закрыть.

Представление процесса и таблица сокетов могут ненадолго расходиться. JavaScript уже эмитировал close, а TIME-WAIT ещё в таблице ядра — нормально: обёртка JS завершена, cleanup TCP остаётся. Или JS ещё держит net.Socket, а ядро уже записало reset — следующая read/write покажет состояние.

На загруженном сервере важнее счётчики состояний, чем одна строка:

1
2
ss -tan state time-wait
ss -tan state close-wait

Много TIME-WAIT после исходящей нагрузки — много коротких соединений. Много CLOSE-WAIT — код получил close пира и не закрыл дескрипторы. Много SYN-SENT — медленные или отфильтрованные исходящие connect. SYN-RECEIVED — backlog и обработка SYN, см. опции сокетов и backlog.

Node не выставляет всё это через net.Socket — состояние принадлежит ОС. Правильный ход — совместить виды: порядок событий Node, состояние TCP на хосте, логи протокола приложения.

Сбой обычно принадлежит одной стороне

Ошибки TCP понятнее, если привязать операцию к стороне.

Сбой исходящего connect:

1
2
3
4
local endpoint picked
remote socket address targeted
handshake fails
Node emits error before connect

Сбой чтения в established:

1
2
3
4
connection established
peer or path resets
local read observes reset
Node emits ECONNRESET

Сбой записи в established:

1
2
3
4
5
connection established
peer closes or resets
local code writes later
OS rejects write
Node emits EPIPE or ECONNRESET

Упорядоченное закрытие пира:

1
2
3
4
peer sends FIN
local readable side sees end
local code decides whether to write or close
close completes after teardown

Одна шкала времени может включать несколько эпизодов. Клиент connect, пишет запрос, получает часть ответа, пир reset. Лог: data, затем error ECONNRESET, затем close. TCP доставил часть упорядоченных байтов и позже оборвал состояние. Парсер решает, пригоден ли частичный ответ. Большинство request-response протоколов отбрасывают его.

Кортеж endpoint из TCP/IP и сетевой стек всё ещё важен. Два соединения на один порт сервера — разные соединения при разных эфемерных портах клиента. Одно может reset, другое — established. Лог только с IP пира без порта теряет идентичность соединения.

1
2
3
4
5
6
7
socket.on('error', (err) => {
    console.error({
        code: err.code,
        local: `${socket.localAddress}:${socket.localPort}`,
        remote: `${socket.remoteAddress}:${socket.remotePort}`,
    });
});

Поля могут быть undefined до connect или после teardown. Когда есть — ошибка привязана к конкретной паре TCP endpoint.

Автомат состояний под сокетом Node

Глубина — в рассогласовании JavaScript и TCP. Node даёт объект с методами. TCP в ядре — автомат с таймерами, номерами, буферами, окнами и teardown. Виды совпадают чаще всего. Баги живут в зазорах.

При connect в JavaScript уже есть net.Socket. ОС может быть в SYN-SENT. Можно вешать слушатели, ставить опции, даже ставить записи в очередь до connect. Node сохранит намерение и сбросит при успехе нативного пути. При провале handshake — ошибка и отброс очереди. Объект JS был всё время; established TCP — только после handshake.

При передаче JavaScript пишет чанки. Stream считает очередь. libuv — write requests. Send buffer ядра — байты на передачу, ACK, ретрансмиссию. Последовательность TCP — outstanding диапазоны. Окно пира лимитирует опережение. Congestion control лимитирует агрессию в сеть. Одна write() в JS может затронуть всё без промежуточных состояний в API.

Чтение отделено. Ядро принимает сегменты, ACK, кладёт в receive buffer, сигнализирует готовность. libuv будит Node, Node тянет в stream. JavaScript видит data по состоянию stream. pause() на stream — Node перестаёт читать; заполняется receive buffer ядра, сжимается окно. Пир видит транспортное давление, не событие JS.

Shutdown добавляет тайминг. socket.end() — приложение закончило писать. Node сбрасывает pending writes и закрывает сторону записи. FIN после очереди по правилам стека. Пир может ещё слать. Локально можно читать после end своей записи. FIN пира — возможен end до close. RST — обрыв упорядоченного пути и ошибки на уже поставленных в JS операциях.

Таймауты рядом с автоматом. У TCP — ретрансмиссия. У сокета Node — неактивность. У протоколов — дедлайны запросов. Пользователь — abort. Любой может уничтожить сокет. Финальный code — кто действовал или что увидела ОС. Два прогона одного кода могут различаться из-за тайминга пакетов.

«Connected» — временный факт: когда-то был established. Дальше каждая read/write гоняется с текущим TCP. FIN, RST, локальный таймаут, выход процесса, потеря маршрута, сбой ретрансмиссии двигают ядро, пока в JS ещё есть ссылка на сокет.

Читаемые логи следуют автомату: старт connect, завершение, кто завершил, когда вызвали destroy, какая запись шла, какой кортеж endpoint. Без порядка ECONNRESET — просто метка «нижнее состояние изменилось до конца операции».

Честные протоколы поверх TCP

TCP даёт надёжную упорядоченную доставку на уровне потока байтов, пока соединение жизнеспособно. Границы сообщений приложения, подтверждение обработки на пире, политика повторов и дедлайны — выше.

Транспорт может доставить половину кадра протокола и чисто завершиться FIN. Может принять локальную запись и позже сообщить reset. Может зависнуть при закрытом receive window пира. Может ретранслировать и затем таймаутиться. Всё это валидные исходы TCP; Node сообщает через события stream и системные ошибки.

Дисциплина для бэкенда проста: TCP — транспорт байтов; протокол приложения доказывает завершение. Кадируйте сообщения. Ждите подтверждений протокола. Уважайте backpressure write(). Логируйте кортежи endpoint и порядок событий. Повторы и circuit breaker — в своём слое: безопасный retry сломанной транспортной операции зависит от того, что протокол уже зафиксировал.

Следующий раздел поднимается на уровень API node:net. То же состояние остаётся внизу. Методы получают более дружелюбные имена, но сокет по-прежнему принадлежит TCP раньше, чем JavaScript.

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

Комментарии