Труба и её обрыв: свой pipeline с ошибками и уборкой
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В статье про обратное давление pipe соединил быстрый диск с медленным клиентом и удержал в памяти два порога вместо файла. Но у соединения есть второй вопрос: что будет, когда оно порвётся. Клиент закрыл вкладку посреди скачивания, диск вернул ошибку чтения — а на сервере остаются два потока, и у каждого свой ресурс: открытый файл и соединение. pipe об этом не заботится: ошибку одного потока другой не видит и остаётся открытым. Сегодня миниатюра получит destroy — способ закончить поток сразу и отпустить ресурс. На ней воспроизведём обрыв голого pipe, соберём свой pipeline, который уничтожает оба потока и сообщает об исходе один раз, и сверим его с настоящим pipeline из node:stream.
Способ закончить поток уже есть — end. Но end — окончание вежливое: «кусков больше не будет, допиши очередь и закрой». Оно годится, пока устройство пишет. Если сокет сообщил, что клиента больше нет, дописывать некуда. Куски в очереди никуда не уйдут, finish не придёт, а очередь и соединение так и останутся занятыми. Для этого случая у потоков Node есть второй способ — destroy: выбросить очередь, отпустить ресурс сейчас и сообщить об этом событием close. Если причина — ошибка, её передают аргументом, и destroy(err) испускает сначала error, потом close.
Что значит «отпустить ресурс», знает только сам ресурс: файл закрывает дескриптор, сокет — соединение. Поэтому поток, как и в случае с write и read, получает функцию destroy в конструкторе и зовёт её из своего метода destroy. Ниже Writable из прошлой статьи с четырьмя правками. Первая — флаг destroyed и сам метод. Вторая — done смотрит на свой аргумент: done(err) значит, что устройство кусок не записало, и поток уничтожает себя с этой ошибкой. В задаче про переполненный диск такую ошибку уже ловили, теперь к ней добавилась уборка. Третья — уничтоженный поток молчит: write ничего не берёт, next ничего не отдаёт, а запись, которая шла в момент уничтожения, счётчик не трогает. Четвёртая — после finish поток уничтожает себя сам: всё записано, и ресурс больше не нужен. Сокет в примере теряет клиента на третьем куске. Прогноз: какие события придут, сколько кусков останется в очереди и что ответит write после обрыва?
Пришли error и close, а finish — нет, хотя end позвали: четвёртый и пятый куски не записались, и объявить «всё записано» было бы ложью. Очередь пуста не потому, что куски ушли, — её выбросил destroy. Соединение закрыла функция destroy из конструктора, а write после обрыва ответил false и кусок не взял.
Порядок событий не случаен: error раньше close. close — последнее событие потока, после него поток ничего не испускает. И приходит оно при любом исходе: и после finish, и после обрыва. Поэтому код, который убирает что-то своё вслед за потоком, слушает close, а причину узнаёт из error, пришедшего раньше.
Читающий поток из статьи про обратное давление получает те же правки. У него появляются опция и метод destroy. Уничтоженный поток молчит: push кусок не берёт, read больше не зовут, а flow не испускает ни данных, ни end. И после end поток уничтожает себя сам. Два файла по 8 символов в куске: первый читают до конца, второй слушатель уничтожает на втором куске. Прогноз: когда закроется каждый файл и что ответит push уже уничтоженному потоку?
Оборванный файл закрылся на втором куске, и третий кусок диск уже не читал: после destroy поток read не зовёт. Кусок, который всё же пришёл, push не взял. Дочитанный файл закрылся сам, сразу после end, — поэтому в обычном случае об уборке думать не нужно. Думать приходится, когда поток оборван посередине. В Node так же: дочитанный и дописанный потоки уничтожают себя сами, за это отвечает опция autoDestroy, включённая по умолчанию.
Emitter, Readable и Writable с destroy теперь лежат в подложке.
Теперь соединение. Функция pipe ниже — три строки из прошлой статьи и одна добавка, которая есть и у pipe в Node: закрылся приёмник — соединение снимается. Слушатели отписываются — закрытому приёмнику куски больше не нужны, — а источник встаёт на паузу, если других приёмников у него не осталось. Файл в примере большой: read отдаёт куски, пока их просят. Сокет, как в прошлом разделе, теряет клиента на третьем куске. Слушатели error стоят на обоих потоках: без слушателя error роняет процесс. Прогноз: получит ли ошибку файл, уничтожат ли его и сколько кусков он прочитает?
Ошибку получил только сокет. Файл о ней не узнал: его слушатель error молчал, destroyed — false, функция закрытия не звана. Разберём шесть кусков. Два записались, третий сорвался, четвёртый ждал в очереди сокета и пропал вместе с ней. Пятый и шестой лежат в буфере файла: pipe снял соединение и поставил файл на паузу, а поток на паузе дочитывает буфер до порога. Так файл и останется — открытым, с полным буфером, в ожидании resume, которого никто не позовёт.
Почему pipe не уничтожает источник? Потому что источник ему не принадлежит. У одного источника бывает несколько приёмников — как два клиента в задаче прошлой статьи, — и обрыв одного клиента не должен обрывать остальных. Настоящий pipe так и поступает: упавший приёмник отсоединяется, остальные получают файл до конца. Решать судьбу источника должен код, который его открыл, а pipe делает единственное, в чём уверен, — снимает соединение. Код, который этого решения не принял, оставляет по открытому файлу на каждое оборванное скачивание.
Обратный случай — сбоит диск. Файл уничтожает себя с ошибкой чтения на третьем куске, сокет здоров. Emitter, потоки с destroy и pipe — в подложке. Прогноз: что будет с сокетом и узнает ли клиент, что файла он не получит?
Файл получил свою ошибку и уничтожил себя, а сокет о ней не узнал: pipe слушает у источника data и end, но не error и не close. Два куска ушли, finish не пришёл, соединение открыто. На сервере это ответ, оборванный на полуслове и повисший: клиент ждёт продолжения, пока не сработает таймаут, и соединение занято всё это время.
Первое, что хочется дописать, — file.on("error", () => socket.end()): читать больше нечего, значит, закончить ответ. Это хуже зависания. end значит «всё отправлено»: сокет допишет очередь и закроет соединение как положено, и клиент получит обрезок файла как целый. Когда статус и часть тела уже ушли, сообщить клиенту об обрыве можно только обрывом — destroy.
Сервер из node:http отдаёт «файл», который сбоит на четвёртом куске, двумя способами: через pipe и end в слушателе error и через pipeline из Node — его миниатюру соберём в следующем разделе. Скачивает тот же процесс через fetch. Прогноз: какой статус получит клиент в каждом случае и узнает ли он об ошибке?
import { createServer } from "node:http"; import { Readable, pipeline } from "node:stream"; // диск отдаёт по куску в 1000 байт и сбоит на четвёртом function openFile() { let made = 0; return new Readable({ read() { made += 1; if (made === 4) { this.destroy(new Error("EIO: i/o error, read")); return; } setTimeout(() => this.push("x".repeat(1000)), 5); }, }); } const server = createServer((req, res) => { const file = openFile(); if (req.url === "/end") { // сбой диска — просто закончить ответ file.on("error", () => res.end()); file.pipe(res); } else { pipeline(file, res, () => {}); } }); server.listen(0, "127.0.0.1", async () => { const base = `http://127.0.0.1:${server.address().port}`; for (const path of ["/end", "/pipeline"]) { const response = await fetch(base + path); try { const body = await response.text(); console.log(`${path}: статус ${response.status}, тело получено, байт: ${body.length}`); } catch (err) { console.log(`${path}: статус ${response.status}, тело не получено: ${err.name}: ${err.message}`); } } server.closeAllConnections(); server.close(); });
/end: статус 200, тело получено, байт: 3000 /pipeline: статус 200, тело не получено: TypeError: terminated
Статус в обоих случаях 200: заголовки ушли с первым куском, задолго до сбоя, и поменять их уже нельзя. С end клиент получил 3000 байт и никакой ошибки — для него это целый файл. С pipeline соединение оборвано, и fetch отклонил чтение тела с TypeError: terminated. Клиент, который узнал об обрыве, скачает файл заново, а получивший обрезок сохранит битый. Как ответ идёт кусками и чем отмечен его конец — в статье про настоящий node:http.
Из двух обрывов выходит правило уборки: ошибка любого потока уничтожает оба. Файл закрывается, соединение обрывается, и тот, кто соединял, узнаёт, чем кончилось. Это и есть pipeline: pipe, слушатели error на обоих потоках и колбэк, который получает ошибку — или ничего, если всё записано. Первая версия прямолинейна: на ошибку — уничтожить оба потока и позвать колбэк, на finish — позвать колбэк без ошибки. destroy получает ту же ошибку: у каждого потока могут быть свои слушатели error, и им полезно знать причину. Сокет снова теряет клиента на третьем куске. Прогноз: сколько раз позовут колбэк и что останется открытым?
Файл закрыт, соединение тоже — уборка работает. Но колбэк пришёл дважды, и первым его позвал не сокет, а файл. Сокет испустил error, fail уничтожил файл с той же ошибкой, и файл испустил свой error — на нём стоит тот же fail. Этот второй вызов уничтожать уже нечего: повторный destroy ничего не делает. Он зовёт колбэк, а потом до своей строки с колбэком доходит и первый вызов.
Двойной колбэк — не косметика. В колбэке сервер пишет журнал, отвечает клиенту, удаляет недописанный файл, считает ошибки. Одна ошибка, записанная дважды, два ответа на один запрос, повторное удаление уже удалённого файла — каждое ломается по-своему. Лечение — флаг: колбэк зовётся один раз, по первой вести, а всё, что приходит следом, отбрасывается.
Флаг чинит двойной колбэк, но у обрыва есть ещё одна дорога, которой pipeline не слышит, — закрытие без ошибки. Клиент закрыл вкладку, и соединение закрылось чисто: ответ сервера на Node в этом случае получает close и никакого error. В миниатюре так закрывает сокет destroy() без аргумента. Ниже pipeline с флагом: колбэк теперь зовётся из одного места, функции report. Прогноз: позовут ли колбэк и что станет с файлом?
Колбэк молчит, файл открыт. Сокет уничтожен, pipe снял соединение, но не было ни error, ни finish, а pipeline слушал только их. Это тот же открытый файл, что в прошлом разделе, только хуже: тот, кто соединял, уверен, что ждёт конца отдачи. Закрытие раньше finish — тоже обрыв, просто без названной причины: данные не дошли, а объяснить почему некому. В Node у этой ошибки есть имя — ERR_STREAM_PREMATURE_CLOSE, «Premature close», преждевременное закрытие.
Значит, pipeline слушает ещё и close обоих потоков. close приёмника после finish — успех, без finish — обрыв. close источника без end — обрыв. Успех объявляется на close приёмника, а не на finish, и из этого есть польза: колбэк приходит, когда приёмник уже отпустил свой ресурс, и в нём, например, можно удалять файл, не опасаясь, что его ещё пишут. А какая весть придёт первой, решает порядок событий в destroy: error раньше close, поэтому настоящая причина обрыва опережает «Premature close». Ниже итоговый pipeline и три отдачи: целиком, со сбоем сокета и клиентом, который ушёл после первого куска. Прогноз: что напечатает каждая?
Все три отдачи кончились колбэком и закрытым файлом. Обрыв без ошибки получил свою, «Premature close». У сбоя сокета осталась настоящая причина: close сокета пришёл вслед за error, когда флаг уже стоял. Итоговый pipeline — два десятка строк, и почти все они про обрыв: само соединение в нём — одна строка pipe. Emitter, потоки с destroy, pipe и pipeline лежат в подложке задач.
Кто соединил потоки, тот за них и отвечает: обрыв одного — уничтожить все и сообщить об исходе ровно один раз.
В Node всё это уже написано: pipeline из node:stream и его версия на промисах из node:stream/promises. Прогон ниже повторяет обрывы на настоящих потоках. Файл и сокет — Readable и Writable с теми же порогами и кусками, что в миниатюре. Функция destroy в опциях так же отмечает, что ресурс отпущен, только у Node она получает ещё колбэк, который зовут, когда ресурс закрыт. Случаев четыре: голый pipe и сбой сокета на третьем куске — как в разделе про pipe; то же через pipeline; pipeline и клиент, ушедший после первого куска; await pipeline(…) и сбой диска. Прогноз: совпадёт ли pipe с миниатюрой и что придёт в колбэк и в catch?
import { Readable, Writable, pipeline } from "node:stream"; import * as promises from "node:stream/promises"; // файл и сокет как в миниатюре: куски по 8 символов, пороги по 16 function openFile(state, { failAt = 0 } = {}) { let made = 0; return new Readable({ encoding: "utf8", highWaterMark: 16, read() { made += 1; state.made = made; if (made === failAt) { this.destroy(new Error("EIO: i/o error, read")); return; } this.push(`кусок-${made}!`); }, destroy(err, callback) { state.fileOpen = false; callback(err); }, }); } function openSocket(state, trouble) { let sent = 0; const socket = new Writable({ decodeStrings: false, highWaterMark: 16, write(chunk, encoding, done) { setTimeout(() => { if (trouble === "error" && sent === 2) { done(new Error("write EPIPE")); return; } // клиент ушёл после первого куска: сокет закрыт без ошибки if (trouble === "close" && sent === 1) { socket.destroy(); return; } sent += 1; done(); }, 5); }, destroy(err, callback) { state.socketOpen = false; callback(err); }, }); return socket; } const later = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // каждый случай ждёт своего исхода, а не часов: на занятой машине таймеры потоков // опаздывали, и строки соседних случаев менялись местами const closed = (stream) => new Promise((resolve) => stream.on("close", resolve)); { const state = { fileOpen: true, socketOpen: true }; const file = openFile(state); const socket = openSocket(state, "error"); file.on("error", (err) => console.log("pipe: error файла:", err.message)); socket.on("error", (err) => console.log("pipe: error сокета:", err.message)); file.pipe(socket); await closed(socket); // файл дочитывает буфер до порога уже после обрыва await later(20); console.log("pipe: файл уничтожен:", file.destroyed, "| открыт:", state.fileOpen); console.log("pipe: прочитано кусков:", state.made, "| в буфере:", `${file.readableLength}/${file.readableHighWaterMark}`); } { const state = { fileOpen: true, socketOpen: true }; const file = openFile(state); const socket = openSocket(state, "error"); await new Promise((resolve) => { pipeline(file, socket, (err) => { console.log("pipeline: колбэк:", err.message, "| файл уничтожен:", file.destroyed, "| открыт:", state.fileOpen); resolve(); }); }); } { const state = { fileOpen: true, socketOpen: true }; const file = openFile(state); const socket = openSocket(state, "close"); await new Promise((resolve) => { pipeline(file, socket, (err) => { console.log("клиент ушёл: колбэк:", err.code, err.message, "| файл открыт:", state.fileOpen); resolve(); }); }); } { const state = { fileOpen: true, socketOpen: true }; const file = openFile(state, { failAt: 3 }); const socket = openSocket(state); try { await promises.pipeline(file, socket); console.log("сбой диска: отправлено целиком"); } catch (err) { console.log("сбой диска: catch:", err.message, "| соединение открыто:", state.socketOpen, "| finish:", socket.writableFinished); } }
pipe: error сокета: write EPIPE pipe: файл уничтожен: false | открыт: true pipe: прочитано кусков: 6 | в буфере: 16/16 pipeline: колбэк: write EPIPE | файл уничтожен: true | открыт: false клиент ушёл: колбэк: ERR_STREAM_PREMATURE_CLOSE Premature close | файл открыт: false сбой диска: catch: EIO: i/o error, read | соединение открыто: false | finish: false
pipe совпал с миниатюрой строка в строку: ошибка только у сокета, файл не уничтожен и открыт, прочитано шесть кусков, буфер полон. pipeline уничтожил файл и отдал ошибку сокета в колбэк. Клиент, ушедший без ошибки, превратился в ERR_STREAM_PREMATURE_CLOSE. А версия на промисах отклонилась с ошибкой диска и оборвала соединение, так и не объявив finish: в асинхронном обработчике обрыв любого из потоков ловит один try.
Чем ещё оригинал отличается от миниатюры — в таблице.
| Миниатюра | node:stream 26.5.0 | |
|---|---|---|
| потоков в цепочке | два: источник и приёмник | сколько угодно: в середине — Transform или Duplex, например сжатие, или асинхронная функция-генератор; возвращает последний поток |
| когда зовут колбэк | сразу, в обработчике первой вести: при обрыве оба потока уже уничтожены, а при успехе источник может уничтожить себя только после колбэка | после close всех потоков, и не внутри события, а после текущего кода |
закрытие без end или finish | new Error("Premature close") | ошибка ERR_STREAM_PREMATURE_CLOSE с тем же текстом; её получают и уничтоженные потоки |
destroy в опциях | закрывает ресурс сразу, close приходит внутри вызова | destroy(err, callback): close приходит, когда ресурс позвал колбэк; fs.createReadStream здесь закрывает дескриптор |
| отмена снаружи | — | у версии из node:stream/promises опция signal: abort() уничтожает цепочку с AbortError |
| только следить | — | finished(stream, callback): колбэк на конец, ошибку или преждевременное закрытие одного потока, без уничтожения |
Правило для кода на Node короткое: потоки соединяет pipeline, а в асинхронном коде — await pipeline(…) из node:stream/promises внутри try. Голый pipe остаётся для случаев, когда источник живёт дольше соединения и его судьбу решают сами, — тогда слушатели error и close ставят руками. Одного pipeline не убирает: данных. Недописанный файл остаётся на диске, и удалять его — дело колбэка: об этом первая задача, а в статье про загрузку файлов обрезок появится и без обрыва, на лимите размера. А чем соединяемые потоки бывают на самом деле — файл, тело запроса, ответ сервера — и как то же чтение пишут через for await вместо слушателя data, разберёт статья про настоящие потоки.
endзаканчивает поток вежливо: очередь дописывается, приходитfinish.destroy— сразу: очередь выброшена, ресурс отпущен, приходятerror, если есть причина, иclose. Дочитанный и дописанный потоки уничтожают себя сами.- Голый
pipeпри обрыве только снимает соединение. Упал приёмник — источник стоит на паузе с полным буфером и открытым файлом. Упал источник — приёмник не закончен и не уничтожен, и клиент ждёт до таймаута. pipeне трогает источник, потому что у источника могут быть и другие приёмники: судьбу потоков решает тот, кто их соединил.- Обрыв уничтожают, а не заканчивают:
endпосле сбоя диска отдаёт клиенту обрезок со статусом 200 как целый файл, аdestroyобрывает соединение, и клиент узнаёт об ошибке. pipeline— этоpipeи слушателиerrorиcloseна всех потоках. Первая весть об обрыве уничтожает цепочку, а колбэк зовётся ровно один раз — в Node послеcloseвсех потоков. Закрытие безendилиfinish— тоже обрыв, в Node этоERR_STREAM_PREMATURE_CLOSE.- В Node потоки соединяет
pipeline, в асинхронном коде —await pipeline(…)изnode:stream/promisesвнутриtry. Недописанные данные он не убирает: обрезок файла удаляет колбэк.
1/4Файл соединён с сокетом через pipe, слушатели error стоят на обоих. Сокет упал с ошибкой. Что стало с файлом?
2/4Посреди отдачи файла сбоит диск, статус 200 и часть тела уже ушли. Почему соединение с клиентом уничтожают, а не заканчивают через end?
3/4Свой pipeline на ошибку уничтожает оба потока с этой ошибкой и зовёт колбэк. Упал сокет, а колбэк пришёл дважды. Откуда второй вызов?
4/4Клиент закрыл вкладку, сокет получил close без error и без finish. Что должен сделать pipeline?
Загрузка без обрезков
Сервер сохраняет загрузки на диск через pipeline. Клиент с dog.jpg пропал посреди загрузки: pipeline честно сообщил «Premature close» и закрыл файл, но на диске остался обрезок — под настоящим именем, неотличимый от целого файла. Перепишите saveUpload: загрузка пишется во временный файл имя.part, при успехе он переименовывается в настоящее имя, при ошибке удаляется. Диск — Map «имя → содержимое», переименовать — значит перенести содержимое под новый ключ.
Колбэк знает исход и приходит, когда поток записи в файл уже уничтожен, — в нём и решается судьба временного файла:
Вывод:
Удаляет обрезок колбэк, а не слушатель error у загрузки: клиент, пропавший без ошибки, error не испускает, а колбэк приходит при любом исходе. Временное имя защищает и от второй беды — чтения посреди записи: пока загрузка идёт, под настоящим именем файла нет вовсе, и другой запрос не отдаст клиенту половину картинки. В Node переименование — rename из node:fs/promises, в пределах одного диска на Linux и macOS оно заменяет файл за один шаг, а удаление — rm.
pipeline на промисах
Обработчики на сервере — асинхронные функции, и колбэк в них неудобен: ошибку из колбэка не поймать try вокруг await. Напишите pipelinePromise(source, dest) — обёртку над pipeline из подложки, которая возвращает промис: он выполняется, когда колбэк позван без ошибки, и отклоняется с ошибкой из колбэка. Сейчас функция пустая, и main рапортует об успехе, не отправив ни куска.
Промис создаётся вокруг вызова pipeline, а колбэк решает, чем он кончится:
Вывод:
Обёртка честна: колбэк зовётся ровно один раз, а код после await продолжается микрозадачей, когда оба потока уже уничтожены, — обработчик идёт дальше только после уборки. В Node такая обёртка уже есть — pipeline из node:stream/promises; прогон последнего раздела ловил её отказ в catch.
Один файл на двоих
Предскажите обе строки вывода и только потом запускайте. Один поток файла раздаётся двум клиентам сразу, каждому через свой pipeline. У второго клиента соединение рвётся на втором куске.
Первый клиент ни в чём не виноват, а получил чужую ошибку. Второй pipeline уничтожил источник с ошибкой своего сокета — так он и должен поступать. Источник испустил её, и первый pipeline, который слушает тот же источник, счёл ошибку своей и уничтожил свой сокет. pipeline считает, что цепочка принадлежит ему целиком. Настоящий pipeline в Node поступает так же: оба колбэка получают ошибку сокета второго клиента. Отсюда правило: у каждого соединения свой источник, и сервер открывает файл заново на каждый запрос, а не держит один поток на всех. Если же источник действительно общий, его соединяют через pipe и уборку пишут сами — ради этого случая pipe источник и не трогает.