diff --git a/editorial/agent-rewrites/023.json b/editorial/agent-rewrites/023.json index bb2fd36..573ad2f 100644 --- a/editorial/agent-rewrites/023.json +++ b/editorial/agent-rewrites/023.json @@ -1 +1 @@ -{"index":23,"slug":"editorial-2027-05-mechanism-http-tls-guide","title":"Где ломается HTTPS: проверяем запрос по границам DNS, TCP, TLS и HTTP","excerpt":"Браузер показывает один итог, но ошибка возникает на конкретной границе. Разбираем порядок проверок, безопасную запись запроса и признаки, по которым можно отделить TLS, посредника и приложение.","contentHtml":"

Браузер сообщает: «не удалось подключиться». Сервисный клиент пишет certificate verify failed. В третьем месте тот же адрес возвращает 404. Команда видит одно слово — «ошибка» — и меняет маршрут или отключает проверку сертификата. Цена такого решения — потерянное время, неверный владелец исправления и иногда открытое соединение без проверки имени сервера.

Тезис простой: диагностируйте запрос по границам. DNS отвечает за имя и адрес. TCP отвечает за соединение. TLS устанавливает защищённый канал и проверяет сертификат. HTTP передаёт метод, путь и заголовки. Приложение обрабатывает контракт endpoint. Пока предыдущая граница не подтверждена, следующая не даёт фактов.

Механизм: пять границ одного запроса

Клиент начинает с имени из URL. Resolver возвращает адрес. TCP открывает поток к порту. Для HTTPS клиент и сервер проводят TLS handshake, выбирают параметры и проверяют цепочку доверия и имя. Только после этого клиент отправляет HTTP-запрос. Сервер или intermediary возвращает статус, заголовки и тело.

Порядок важен. Если TLS завершился исключением, у приложения нет HTTP-статуса, который можно расследовать. Если TLS завершился успешно, но ответ равен 404, сертификат уже не объясняет отсутствие маршрута. Если посредник вернул 503, этот статус может описывать его собственное состояние, а не состояние origin.

Граница запроса и допустимое утверждение
ГраницаЧто можно утверждатьЧто ещё не доказано
DNSИмя разрешилось в выбранный адресПорт принимает соединения, сертификат подходит имени
TCPСоединение с адресом и портом установленоTLS доверен, HTTP-маршрут существует
TLSЗащищённый канал принят клиентомЗапрос дошёл до нужного origin
HTTPПолучены статус и заголовкиОтвет сформировало ваше приложение
ПриложениеЛог доверенного входа связывает запрос с handlerДругой регион или кэш ведёт себя так же

Симптом → причина → проверка → действие

Первое безопасное действие для типичных симптомов
СимптомВероятная причинаПроверкаДействие
Нет HTTP-статуса, клиент сообщает о сертификатеИмя, срок или цепочка сертификата не прошли проверкуСверить hostname, SAN, срок и локальное хранилище доверияИсправить сертификат или доверенную цепочку; не оставлять отключённую проверку
Имя разрешается, connect завершается отказомПорт закрыт, маршрут недоступен или адрес выбран неверноСравнить адрес DNS и время TCP connectПроверить firewall, listener, балансировщик и выбранный адрес
TLS успешен, пришёл 404Путь, метод или виртуальный хост не совпал с маршрутомСопоставить URL, метод, authority и лог входного proxyИсправить маршрут или контракт; не менять сертификат
Приходит 503 от proxyПосредник не получил рабочий upstream или отказал по лимитуПроверить Via, время ответа и логи upstreamРазделить состояние proxy и origin, затем проверить соединения и лимиты
Заголовок Server указывает на знакомый продуктПоле добавил intermediary или его можно переписатьСопоставить request id с доверенным логом входаСчитать заголовок гипотезой, а не доказательством источника
Повторный запрос даёт другой статусКэш, балансировка, редирект или меняющееся состояниеСравнить Age, Cache-Control, ETag, адрес и времяПроверить маршрут каждого ответа и не объединять их в один результат

Почему заголовки не подтверждают источник

Server, Via и X-Request-Id принадлежат HTTP-сообщению. Посредник может добавить, удалить или переписать их. Даже правильный на вид идентификатор не доказывает, что запрос обработал конкретный handler. Доказательство появляется только там, где доверенный компонент создал идентификатор и записал его вместе с маршрутом, временем и результатом.

Сохраняйте в диагностике логическое имя назначения, а не только строку Host. В HTTP/2 и HTTP/3 используется поле authority, и привычная проверка одного заголовка может дать неполную картину. Если есть proxy, отдельно фиксируйте имя proxy и имя origin. Сертификат проверяет имя, которое использовал TLS-клиент; это не всегда имя, которое позже увидел application handler.

Минимальная безопасная запись содержит метод, нормализованный путь, этап отказа, статус, длительность, размер тела и request id. Уберите Authorization, cookie и секретные query-параметры. Путь должен быть полезен для маршрутизации, но не обязан содержать персональные или платёжные данные.

Учебный пример: отделяем TLS от HTTP

Ниже — локальный HTTP-сервер. Он показывает только границу HTTP: сервер принимает запрос, возвращает статус и идентификатор, клиент читает тело. Сеть, TLS, proxy и production-маршрутизация в пример не входят. Число local-001 не является доказательством доверенного источника.

import { createServer } from 'node:http';\n\nconst server = createServer((request, response) => {\n  response.writeHead(200, {\n    'content-type': 'application/json; charset=utf-8',\n    'x-request-id': 'local-001',\n  });\n  response.end(JSON.stringify({\n    method: request.method,\n    path: request.url,\n  }));\n});\n\nserver.listen(0, 'localhost', async () => {\n  const { port } = server.address();\n  const response = await fetch(`http://localhost:${port}/orders`);\n  console.log(response.status);\n  console.log(response.headers.get('x-request-id'));\n  console.log(await response.json());\n  server.close();\n});

Ожидаемый учебный результат — статус 200, идентификатор local-001 и тело с методом GET и путём /orders. Если убрать x-request-id, HTTP всё равно останется корректным. Это показывает границу поля: идентификатор помогает сопоставлять записи, но не является условием успешного запроса.

Отрицательный путь выглядит иначе. Если заменить URL на HTTPS и получить ошибку проверки сертификата до строки со статусом, код сервера не объясняет отказ. Если HTTPS проходит, а сервер возвращает 404, нужно проверять метод, путь и authority. Флаг вроде --insecure может показать, что удалённая сторона отвечает, но он отключает важную проверку и не исправляет конфигурацию.

TLS меняет порядок диагностики

Для HTTPS проверяйте не «SSL вообще», а три независимых условия. Первое — сертификат выдан для целевого hostname: имя должно совпасть с одним из значений Subject Alternative Name. Второе — цепочка ведёт к центру сертификации, которому доверяет клиент. Третье — текущая дата попадает в срок действия сертификата. Неправильный SAN, неизвестный issuer и истёкший срок требуют разных исправлений.

Подключение к IP вместо имени часто ломает проверку имени, даже если IP ведёт к нужному серверу. Заголовок Host не исправляет это задним числом: TLS завершается раньше, чем клиент отправляет HTTP-заголовки. Через proxy добавляется ещё одна граница. Имя proxy и имя origin нужно проверять отдельно, иначе ошибку промежуточного соединения можно принять за ошибку конечного сервиса.

Кэш также меняет смысл ответа. Age может показать возраст объекта, Cache-Control — правила хранения, ETag — валидатор представления, а Via — участие intermediary. Ни одно поле само по себе не доказывает, кто создал тело. Сопоставляйте заголовки с логом доверенного входа и, если возможно, с ответом origin.

\"Матрица
Каждая граница отвечает только на свой вопрос. HTTP-статус не подтверждает сертификат, а DNS-ответ не подтверждает маршрут приложения.

Порядок проверки

  1. Запишите URL, метод, безопасное имя назначения и момент запроса. Уберите токены, cookie и секретные query-параметры.
  2. Проверьте DNS: зафиксируйте выбранный A/AAAA-адрес и не делайте из этого вывода о доступности порта.
  3. Проверьте TCP connect и порт. При отказе остановитесь на сети, listener или балансировщике.
  4. Для HTTPS сверите hostname, SAN, срок действия и цепочку доверия обычным клиентом. Не используйте отключение проверки как исправление.
  5. После успешного TLS снимите статус, метод, путь, authority и ограниченный набор заголовков. Для ошибки до этого шага HTTP-поля не используйте.
  6. Сопоставьте request id с логом доверенного proxy или входного сервиса. Заголовок от удалённой стороны без такой записи оставьте гипотезой.
  7. Проверьте кэш и посредников по Age, Cache-Control, ETag, Via и времени ответа. Сравните cold и повторный запрос.
  8. Запишите одну подтверждённую причину и один следующий тест. Если граница не наблюдается, укажите «не доказано», а не назначайте виновника по косвенному полю.

Ограничения

Эта модель не заменяет трассировку сети. Она не показывает потери пакетов, особенности HTTP/2 multiplexing, работу CDN, разницу между регионами, настройки корпоративного proxy или состояние локального DNS-кэша. Локальный сервер не доказывает поведение реального origin. Учебные значения статуса, порта и идентификатора нельзя переносить в конфигурацию без проверки вашей среды.

Некоторые клиенты скрывают отдельные фазы и возвращают только итоговую длительность. Не восстанавливайте DNS, TCP и TLS по догадке. Запишите известный факт: например, «клиент прекратил ожидание через 800 мс» или «TLS завершился с ошибкой имени». Для детализации нужен клиент с подходящей диагностикой или наблюдение на доверенном proxy.

Не отключайте проверку сертификата в постоянной конфигурации и не публикуйте полный verbose-вывод. Не принимайте внешний request id как надёжную связь с серверным логом. Не объявляйте origin виновником ответа, который мог создать кэш или proxy. Эти отрицательные правила защищают диагностику от ложной уверенности.

Проверяемый критерий готовности

Проверка готова, когда для выбранного endpoint есть две безопасные записи: успешная и ошибочная. В каждой видны имя назначения, этап, статус или текст ошибки, длительность и ожидаемый следующий шаг. Интеграционный тест или разрешённое наблюдение должны показать, что ошибка до TLS не получает выдуманный HTTP-статус, а ответ 404 после TLS ведёт к проверке маршрута.

Для запроса через proxy дополнительно видны границы proxy и origin, а request id находится в логе доверенного входа. Если ответ может прийти из кэша, запись содержит признаки его участия или честно отмечает, что источник не установлен. Критерий выполнен только тогда, когда команда может повторить проверку и получить тот же вывод о границе отказа.

Проверяемые источники

"} +{"index":23,"slug":"editorial-2027-05-mechanism-http-tls-guide","title":"Почему HTTPS ошибается в разных местах: карта границ DNS, TCP, TLS и HTTP","excerpt":"Ошибка сертификата, таймаут и 404 могут выглядеть одинаково для пользователя, но требуют разных проверок. Разбираем порядок запроса, доверие к серверу, роль proxy и воспроизводимую запись результата.","contentHtml":"

В браузере одна красная страница, в логе клиента другая строка, а инженер уже меняет таймаут или отключает проверку сертификата. Такой ремонт часто начинается раньше факта: запрос мог остановиться на DNS, TCP или TLS и не дойти до HTTP. В обратной ситуации сертификат проверен, но приложение или proxy вернули 404, и поиски проблемы в TLS только уводят в сторону.

У запроса есть наблюдаемая последовательность: имя превращается в адрес, TCP принимает соединение, TLS проверяет защищённый канал и identity сервера, затем HTTP передаёт метод, цель и поля. Диагностика становится воспроизводимой, если на каждой границе задать свой вопрос, записать факт и не переносить вывод на следующий уровень. Эта схема не обещает мгновенно назвать виновника; она сужает место поиска и подсказывает следующий безопасный тест.

Один URL — несколько независимых границ

Начните с URI, например https://api.example.test/orders. Из него клиент получает схему, имя, порт и путь. DNS отвечает, какой адрес связан с именем. TCP отвечает, удалось ли открыть соединение с адресом и портом. Для HTTPS поверх TCP запускается TLS. Только после успешного TLS клиент отправляет HTTP-сообщение и получает статус, поля и тело.

Порядок важен не как учебная условность, а как ограничение доказательств. Если имя не разрешилось, вы ещё не проверяли порт. Если TCP не установлен, у приложения нет основания считать запрос полученным. Если TLS остановился на проверке identity, HTTP-заголовки и статус не объясняют отказ. Если статус 404 уже прочитан, TLS-обмен прошёл для этого соединения, но это ещё не доказательство, что ответ сформировал нужный origin.

Что доказывает наблюдение на каждой границе
ГраницаНаблюдениеДопустимый выводЧего пока нет
DNSПолучен A или AAAA-ответИмя разрешилось в один из адресовДоступность порта и соответствие сертификата
TCPСоединение установленоАдрес принял соединение на выбранном портуУспешная проверка TLS и HTTP-маршрут
TLSКлиент принял защищённый каналПроверки identity и доверия прошли для этого клиентаНужный handler получил запрос
HTTPПолучены статус и поляHTTP-обмен состоялсяТело создал origin, а не cache или proxy
ПриложениеДоверенный лог связал request id с handlerИзвестен компонент, обработавший входТак же ли настроены другие адреса и регионы
\"Матрица
Симптом указывает на границу проверки, но не всегда на конкретного виновника: источник ответа подтверждается только доверенным логом.

Доверенный лог здесь важнее знакомого заголовка. Клиент может получить Server: familiar-product или X-Request-Id: local-001, но оба поля могли добавить или изменить по пути. Доказательство источника появляется, когда компонент под вашим контролем создал идентификатор и записал его вместе со временем, маршрутом и результатом.

Как читать ошибку без догадки о виновнике

Ошибка до HTTP-статуса ограничивает поиск транспортом. Сообщение о несовпадении имени сертификата требует проверить hostname и сертификат; оно не доказывает, что порт недоступен. Отказ TCP требует проверить маршрут, firewall, listener или балансировщик; он не говорит, какой HTTP-путь должен существовать. Таймаут без раздельных фаз оставляет место остановки неизвестным — не подставляйте туда удобную версию.

HTTP-ошибка уже принадлежит сообщению, которое кто-то сформировал. 404 Not Found означает, что получен HTTP-ответ с таким статусом, но не называет внутренний маршрут и не доказывает origin. 503 Service Unavailable сообщает о недоступности для данного отвечающего компонента; это может быть proxy, gateway или приложение. Ищите request id и запись того слоя, который действительно отправил ответ клиенту.

Симптом и первый тест
СимптомГипотеза с ограниченной уверенностьюПервый тестНе делать сразу
Нет статуса, ошибка сертификатаНе прошла проверка имени, цепочки или срокаСверить hostname, SAN, trust store и срок действияНе добавлять Host и не включать --insecure постоянно
Имя разрешилось, connect отклонёнПорт, маршрут или listener не принимает соединениеСохранить адрес, порт и тип TCP-ошибкиНе проверять URL и бизнес-логику как первопричину
TLS успешен, получен 404Путь, метод, authority или версия маршрута не совпалиСверить запрос с логом входного proxy и известным endpointНе перевыпускать сертификат без нового факта
Получен 503Отвечающий компонент не обслуживает запрос или не дождался upstreamСопоставить слой, время ответа, Via и upstream-логНе увеличивать timeout и не повторять запись вслепую
Повтор даёт другой ответКэш, балансировка, редирект или меняющееся состояниеСравнить адрес, Age, ETag, Cache-Control и времяНе объединять ответы разных попыток в одну причину

TLS: что именно проверяет клиент

Для HTTPS недостаточно сказать «сертификат действующий». Клиент строит reference identity из имени или IP в URI и сопоставляет её с именами в сертификате. Затем он проверяет цепочку к доверенному anchor и период действия. Эти проверки отвечают на разные вопросы: сертификат может быть выдан правильным центром, но не для этого имени; имя может совпасть, но цепочка не доверена локальному клиенту; оба условия могут пройти, а срок — закончиться.

Подключение к IP вместо имени часто меняет проверяемую identity. Если сертификат выпущен для DNS-имени, сам факт, что IP указывает на тот же сервер, не делает IP подходящим именем. HTTP-поле Host не исправляет ошибку задним числом: оно появляется на уровне HTTP, после установления TLS. В HTTP/2 и HTTP/3 роль имени и порта в запросе обычно представляет :authority; при диагностике учитывайте фактическую версию протокола.

Флаг --insecure или его аналог может быть полезен в коротком разрешённом эксперименте: он показывает, что удалённая сторона способна отправить данные без обычной проверки identity. Но такой ответ не доказывает безопасность канала и не является исправлением. Результат эксперимента нужно явно пометить как полученный с отключённой проверкой и повторить обычным клиентом после исправления доверия.

HTTP, proxy и кэш: ответ не всегда пришёл от origin

HTTP-маршрут выбирается с учётом цели запроса. В HTTP/1.1 это видно через Host, а в HTTP/2 и HTTP/3 — через :authority. Один адрес может обслуживать несколько имён, поэтому несоответствие authority, пути или метода способно привести к 404 при полностью рабочем TLS. Уточняйте, какой компонент принимал соединение и какое значение он использовал для маршрутизации.

Посредник может завершить запрос сам, передать его дальше или вернуть результат из кэша. Поле Via по стандарту помогает увидеть промежуточные протоколы и получателей, но оно не является криптографической подписью тела. Age и валидаторы вроде ETag помогают заметить кэширование, а Cache-Control описывает правила хранения и повторного использования. Это признаки цепочки, а не самостоятельное доказательство конкретного origin.

Для расследования сохраняйте метод, нормализованный путь, authority, порт, момент, длительность, статус, размер тела и безопасный request id. Секреты и персональные данные в такой записи не нужны: удалите Authorization, cookie и чувствительные query-параметры. Если доступен только внешний ответ, напишите «источник не установлен». Такая формулировка полезнее уверенного, но неподтверждённого назначения владельца.

Воспроизводимый пример: сначала HTTP, затем отрицательный путь

Пример запускается локально и намеренно не моделирует TLS. Он показывает, что статус относится к HTTP-маршруту, а вручную заданный request id не подтверждает доверенное происхождение. Сохраните код в check-http.mjs и запустите на Node.js с поддержкой встроенного fetch.

import { createServer } from 'node:http';\n\nconst server = createServer((request, response) => {\n  const knownRoute = request.method === 'GET' && request.url === '/health';\n  response.writeHead(knownRoute ? 200 : 404, {\n    'content-type': 'application/json; charset=utf-8',\n    'x-request-id': 'local-001',\n  });\n  response.end(JSON.stringify({\n    method: request.method,\n    path: request.url,\n    route: knownRoute ? 'health' : 'missing',\n  }));\n});\n\nserver.listen(0, '127.0.0.1', async () => {\n  const { port } = server.address();\n  for (const path of ['/health', '/missing']) {\n    const response = await fetch('http://127.0.0.1:' + port + path);\n    console.log(response.status, await response.json());\n  }\n  server.close();\n});

Ожидаемый результат — 200 для GET /health и 404 для GET /missing. В обоих ответах будет local-001, потому что пример присваивает его сервером. Это воспроизводит локальный HTTP-контракт, но не доказывает, что аналогичное поле в production создано доверенным входным компонентом.

Отрицательный тест: замените путь на /health?token=demo. Сервер вернёт 404, потому что демонстрационный контракт сравнивает полный URL. Это не универсальное правило маршрутизации, а специально видимое условие примера. После теста не переносите его буквально в приложение: реальный роутер может отдельно разбирать path и query. Ценность эксперимента в том, что изменение одного условия меняет наблюдаемый результат.

Порядок диагностики

  1. Запишите схему, hostname, порт, метод, безопасный путь, время и режим proxy. Замаскируйте токены, cookie и персональные параметры.
  2. Проверьте DNS и сохраните выбранный адрес, тип записи и момент. Если адресов несколько, повторите наблюдение для каждого значимого маршрута.
  3. Проверьте TCP connect к нужному порту. При отказе сначала разбирайте сеть, listener, firewall или балансировщик.
  4. Для HTTPS обычным клиентом проверьте hostname или IP identity, SAN, цепочку доверия и срок действия. Запишите конкретное свойство, которое не прошло.
  5. Только после успешного TLS снимите метод, path, authority, статус и ограниченный набор заголовков. Ошибке до TLS не приписывайте HTTP-статус.
  6. Установите отвечающий слой: свяжите request id с доверенным логом proxy, gateway или handler. Если связи нет, оставьте источник ответа неизвестным.
  7. Проверьте Via, Age, ETag, Cache-Control и длительность, если подозреваете intermediary или cache. Сравните холодный и повторный запрос.
  8. Сформулируйте одну подтверждённую причину и один следующий тест. Для POST сначала проверьте идемпотентность или ключ дедупликации; ответ 503 сам по себе не делает повтор безопасным.

Ограничения применимости

Модель описывает порядок доказательств, а не конкретную реализацию сети. Она не заменяет packet capture, распределённую трассировку, настройки CDN, корпоративного proxy, DNS-кэш, региональную балансировку или наблюдение внутри закрытого сегмента. У клиента могут быть скрыты отдельные фазы, поэтому итоговый timeout нельзя разложить на DNS, TCP и TLS без дополнительного измерения.

Локальный сервер не подтверждает работу реального origin, сертификата, балансировщика или кэша. Поле Via может быть скрыто политикой, а request id от внешней стороны может отсутствовать или быть перезаписан. Сопоставляйте только те записи, которым доверяете в своей цепочке. Нельзя переносить учебный порт, hostname, заголовок и ожидаемый статус в production без отдельного контракта.

Для операции, меняющей заказ, деньги, подписку или иной ресурс, сетевой сбой оставляет результат неопределённым: сервер мог применить действие и потерять ответ. RFC различает идемпотентные методы и остальные, но бизнес-операция может иметь дополнительные побочные эффекты. Без ключа операции, проверки состояния или явного правила сервиса безопаснее остановить автоматический повтор и передать решение владельцу контракта.

Критерий готовности

Диагностика готова, если для одного endpoint есть две очищенные записи: успешная и ошибочная. В каждой указаны время, hostname, порт, метод, безопасный путь, этап, статус или текст ошибки, длительность и ожидаемый следующий тест. Для HTTPS отдельно зафиксированы identity, цепочка и срок; для HTTP — authority, маршрут, источник ответа и признаки кэша, если они доступны.

Исправление считается подтверждённым после изменения одного условия и повторного запуска с обычной проверкой сертификата. Ожидаемый результат должен быть сформулирован заранее: например, известный маршрут возвращает 200, отсутствующий — 404, а ошибка до TLS не получает выдуманный HTTP-статус. Если логи не связывают ответ с доверенным компонентом, честный результат проверки — «источник не установлен», а не имя предполагаемого сервиса.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/024.json b/editorial/agent-rewrites/024.json index d06c411..fdc12a6 100644 --- a/editorial/agent-rewrites/024.json +++ b/editorial/agent-rewrites/024.json @@ -1,7 +1,7 @@ { "index": 24, "slug": "editorial-2027-05-practice-http-tls-guide", - "title": "HTTP и TLS: как найти границу ошибки до того, как менять код", - "excerpt": "404, 503 и ошибка сертификата возникают на разных этапах запроса. Разбираем их по наблюдаемым признакам, проверяем локальным примером и не превращаем retry или --insecure в случайное исправление.", - "contentHtml": "

Браузер показывает ошибку, а команда сразу меняет timeout, маршрут или сертификат. Через час выясняется, что запрос вообще не дошёл до приложения. В другом случае приложение вернуло 404, но инженер ищет проблему в TLS. Цена такой ошибки — лишний rollout, потерянное время и риск сломать рабочий путь, пытаясь исправить не тот слой.

\n

Тезис: сначала нужно определить первый подтверждённый этап отказа. До HTTP находятся DNS, TCP и TLS. После успешного TLS появляются метод, URI, заголовки и статус HTTP. Если перепутать границу, проверка не отвечает на вопрос и создаёт ложное ощущение прогресса.

\n

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

\n

У HTTPS-запроса есть последовательность. Клиент разрешает имя, открывает TCP-соединение, проводит TLS-рукопожатие, отправляет HTTP-сообщение и читает ответ. Посредник может завершить запрос на любом шаге. Поэтому текст ошибки важнее цвета страницы: ERR_TLS_CERT_ALTNAME_INVALID ещё не является HTTP-ответом, а 404 означает, что HTTP-обмен уже состоялся.

\n

Уровень ошибки задаёт набор допустимых проверок. Заголовок Host не исправит сертификат, если TLS-клиент не доверяет имени. Увеличение timeout не создаст отсутствующий маршрут. Повтор POST не становится безопасным только потому, что сервер вернул 503.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
ERR_TLS_CERT_ALTNAME_INVALIDИмя в URL не совпадает с SANСверить hostname, SAN и адресИсправить имя, сертификат или vhost
404 Not FoundURI или метод не попал в маршрутПроверить известный endpoint и лог маршрутаИсправить путь, метод или route config
503 Service UnavailableОбработчик или зависимость недоступныПроверить Retry-After и upstream-логиУстранить недоступность; retry ограничить контрактом
Timeout без статусаНеизвестен этап задержкиРазделить connect и read timeoutНайти этап, затем менять лимит
\n
\"Последовательность
Диагностика идёт слева направо. Первый наблюдаемый сбой ограничивает область поиска.
\n

Что означает HTTP-статус

\n

404 — это статус HTTP-ответа. Он не доказывает, что ответ сформировало origin-приложение: его мог вернуть reverse proxy или другой посредник. Сохраняйте метод, нормализованный путь, статус, request ID и безопасный набор заголовков. Полные Cookie, Authorization и чувствительные query-параметры в запись не нужны.

\n

503 сообщает о временной невозможности обработать запрос. Заголовок Retry-After может дать ориентир, но не гарантирует безопасность повтора. Для чтения задайте ограниченный retry с общим deadline. Для записи сначала проверьте идемпотентность и правило дедупликации. Иначе потерянный ответ после успешной записи превратится в дубль.

\n

Отрицательный путь обязателен. Если метод меняет деньги, заказ, подписку или другой ресурс, а сервер не принимает ключ операции и не описывает повтор, клиент должен остановиться. Автоматический retry в таком месте скрывает неопределённый результат.

\n

Почему ошибка сертификата возникает раньше HTTP

\n

TLS защищает канал и связывает его с именем узла. Клиент сравнивает имя назначения с именами в Subject Alternative Name сертификата. Если URL содержит старый alias, IP-адрес или имя другого виртуального хоста, проверка может завершиться до отправки HTTP-запроса. Тогда у приложения нет статуса и тела для анализа.

\n

Проверяйте три свойства: имя, цепочку доверия и срок действия. Общий текст certificate error скрывает различия между ними. Не подменяйте проверку флагом --insecure. Он может показать, что endpoint отвечает без валидации сертификата, но не исправляет доверие и не доказывает безопасность соединения.

\n

Учебная проверка на локальном сервере

\n

Пример ниже учебный. Он запускает только локальный HTTP-сервер, не обращается к внешней сети и не моделирует TLS. Его задача — показать разницу между известным маршрутом и отсутствующим URI. В production этот код не заменяет proxy, сертификат, health-check или журнал приложения.

\n
import { createServer } from 'node:http'; const server = createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok'); return; } res.writeHead(404, { 'content-type': 'text/plain' }); res.end('missing'); }); server.listen({ port: 0, host: '127.0.0.1' }, async () => { const { port } = server.address(); const response = await fetch('http://127.0.0.1:' + port + '/missing'); console.log(response.status, await response.text()); server.close(); });
\n

Запуск node check-http.mjs в этом учебном случае печатает 404 missing. Если заменить путь на /health, получится 200 ok. Мы проверяем и статус, и тело. Один только текст страницы не показывает, какой контракт нарушен.

\n

Отправьте POST /health и получите 404: маршрут принимает только GET. Это не проблема TLS и не причина увеличивать timeout. Сначала решите, должен ли такой метод существовать в контракте.

\n

Порядок диагностики

\n
  1. Запишите URL, метод, время и request ID. Удалите Authorization, Cookie и персональные параметры.
  2. Проверьте DNS и адрес назначения отдельно от приложения. Несколько адресов могут вести к разным конфигурациям.
  3. Для HTTPS проверьте hostname, SAN, срок действия и цепочку сертификата обычным клиентом. Не отключайте проверку в рабочем запросе.
  4. После успешного TLS проверьте статус, Allow, Location, Retry-After, тип тела и идентификатор ответа.
  5. Сопоставьте метод с эффектом. Для изменения состояния определите идемпотентность или ключ операции до включения повторов.
  6. Сравните ошибочный запрос с безопасным endpoint на том же hostname и зафиксируйте ожидаемый результат.
\n

Ограничения

\n

HTTP-статус не раскрывает автоматически путь внутри прокси или состояние upstream. Заголовок Server не является доказательством источника ответа. DNS-ответ не доказывает наличие нужного маршрута. Для вывода о реальной инфраструктуре нужны согласованные логи, сетевые данные и разрешённый доступ.

\n

Локальный пример не проверяет CDN, балансировщик, корпоративный proxy, реальную цепочку сертификатов, рестарт процесса или запись в базе. Он показывает только границу между URI, методом и ответом локального HTTP-сервера. Не переносите его упрощённое поведение в production без явного контракта и тестов.

\n

Если TLS не проходит, не ищите заголовки приложения. Если TLS проходит, но статус равен 404, ищите маршрут и метод. Если пришёл 503, определяйте доступность обработчика и безопасность повтора. Если нет статуса, сначала найдите этап timeout.

\n

Проверяемый критерий готовности

\n

Диагностика готова, если для одного hostname можно воспроизвести успешный HTTPS-запрос, ошибку проверки имени сертификата, известный 404 и временный 503. Для каждого случая запись содержит этап, метод, путь, статус или TLS-ошибку, безопасный request ID и одно действие. Для записи с неопределённым результатом retry остановлен или защищён идемпотентным контрактом.

\n

Проверяемый результат — не «ошибка исчезла», а совпадение наблюдения с уровнем проверки. Повторите запрос после изменения одного условия. Если причина и новый результат не различаются по логам или команде, ремонт ещё не доказан.

\n

Проверяемые источники

\n" + "title": "HTTP и TLS: как за один запрос найти границу отказа", + "excerpt": "Практический маршрут от DNS и TLS к статусу HTTP: как отличить отсутствие маршрута от ошибки сертификата, проверить гипотезу и не сделать опасный retry.", + "contentHtml": "

Браузер показывает «сайт недоступен», а инженер сразу меняет timeout, маршрут или сертификат. Через час выясняется, что запрос остановился на другом слое: DNS отдал не тот адрес, TLS отверг имя, reverse proxy вернул 404 или upstream не успел ответить. Исправление симптома в таком месте добавляет rollout и не приближает к причине.

\n

Разберём один вопрос: как по наблюдаемому запросу найти первый подтверждённый этап отказа. До HTTP находятся разрешение имени, TCP и TLS. После успешного TLS клиент отправляет метод, целевой URI и поля HTTP. Статус 404 уже доказывает, что некоторый участник обмена сформировал HTTP-ответ; ошибка проверки сертификата — нет.

\n

Результат диагностики — не формула «для 503 всегда повторяем». Нужна запись, в которой видны вход, первый ответивший слой, проверка и действие. Такой формат помогает отделить исправление маршрута от изменения сетевых лимитов и отдельно решить вопрос безопасности повтора.

\n

Сначала фиксируем наблюдаемый симптом

\n

Начните с одного конкретного запроса: hostname, порт, метод, путь, время, код клиента и безопасный request ID. Секреты из записи удалите. Значение имеет не фраза из браузера, а то, что реально можно сопоставить с логом или повторить командой.

\n
Симптом связывает проверку с уровнем, но не доказывает конкретный компонент
НаблюдениеЧто уже подтвержденоСледующая проверкаЧего не следует заключать
Не разрешается имяДо TCP-соединения дело не дошлоDNS-запись, resolver, адрес и TTLЧто приложение не работает
TCP открылся, TLS не завершилсяПорт достижим, HTTP ещё не отправленhostname, SAN, цепочка и срок сертификатаЧто URI или метод неверны
404 Not FoundПолучен HTTP-ответ с кодом 404метод, URI, Host, proxy- и application-логиЧто ответил именно origin
503 Service UnavailableHTTP-участник сообщил о недоступностиupstream, Retry-After, deadline и request IDЧто повтор безопасен для записи
Нет статуса до timeoutПолучатель не отдал HTTP-ответ вовремяconnect/read timeout и трасса по проксиЧто причина обязательно в приложении
\n

Таблица задаёт область поиска, а не готовый диагноз. 404 может создать edge, балансировщик или приложение. Заголовок Server и внешний вид страницы не дают надёжного ответа о владельце. Поэтому к статусу добавляйте путь прохождения запроса: hostname, порт, request ID и доступные логи.

\n

Где заканчивается TLS и начинается HTTP

\n

Упрощённая последовательность выглядит так: клиент разрешает имя, устанавливает TCP, проводит TLS-рукопожатие и только затем передаёт HTTP-сообщение. TLS 1.3 описывает защищённое рукопожатие и параметры канала, а HTTP определяет сообщение, метод, URI, поля и статус. Это разные контракты с разными наблюдениями.

\n
\"Схема
Статус HTTP появляется после успешного прохождения TLS. Фиксируйте первый слой, для которого есть наблюдение.
\n

Проверка сертификата сопоставляет имя назначения с идентичностью сервера. Если URL содержит старый alias, IP вместо DNS-имени или имя другого виртуального хоста, клиент может остановиться до отправки запроса. В этом случае у приложения нет достоверного статуса, тела и заголовков, которые можно было бы исправлять.

\n

Флаг --insecure годится только для изолированного эксперимента, когда нужно увидеть, отвечает ли endpoint при отключённой проверке. Он не исправляет цепочку доверия, hostname или конфигурацию сервера. Результат такого эксперимента нельзя принимать за доказательство безопасного рабочего соединения.

\n

Как читать 404, 405, 503 и 504

\n

404 означает, что отвечающий сервер не нашёл текущего представления целевого ресурса. Причиной может быть опечатка в пути, неправильный Host, отсутствие маршрута на proxy или удалённый endpoint. Сравните ошибочный запрос с известным GET /health, а затем проверьте метод и нормализацию URI.

\n

405 Method Not Allowed — другой сигнал: ресурс распознан, но данный метод для него не разрешён. Поле Allow показывает поддерживаемые методы, если сервер его сформировал. Подмена POST на GET не является исправлением: она меняет семантику операции и может скрыть ошибку клиента.

\n

503 сообщает о временной неспособности обработать запрос. Это может быть перегруженный или недоступный upstream, окно обслуживания либо решение посредника. Retry-After задаёт время или дату, после которых клиенту предлагается повторить запрос, но сам по себе не обещает, что операция безопасна или завершилась без побочного эффекта.

\n

504 означает, что gateway или proxy не получил своевременный ответ от upstream. Увеличение timeout на браузере не доказывает, что upstream стал быстрее. Сопоставьте deadline клиента, proxy и обработчика, а также отметьте, дошёл ли запрос до приложения и мог ли обработчик завершить запись до обрыва ответа.

\n

Воспроизводимый локальный пример

\n

Ниже — учебный HTTP-сервер на localhost. Он намеренно не моделирует DNS, TLS, proxy и базу данных. Его задача — отделить корректный маршрут от неизвестного URI и показать, что метод является частью контракта. Сохраните код в check-http.mjs и запустите в Node.js с поддержкой глобального fetch.

\n
import { createServer } from 'node:http';\n\nconst server = createServer((request, response) => {\n  const route = request.method + ' ' + request.url;\n\n  if (route === 'GET /health') {\n    response.writeHead(200, { 'content-type': 'text/plain' });\n    response.end('ok');\n    return;\n  }\n\n  if (route === 'POST /orders') {\n    response.writeHead(503, {\n      'content-type': 'application/json',\n      'retry-after': '10',\n    });\n    response.end(JSON.stringify({ error: 'upstream_busy' }));\n    return;\n  }\n\n  response.writeHead(404, { 'content-type': 'text/plain' });\n  response.end('missing');\n});\n\nserver.listen({ port: 0, host: '127.0.0.1' }, async () => {\n  const { port } = server.address();\n\n  for (const path of ['/health', '/missing']) {\n    const result = await fetch('http://127.0.0.1:' + port + path);\n    console.log(path, result.status, await result.text());\n  }\n\n  const unavailable = await fetch('http://127.0.0.1:' + port + '/orders', {\n    method: 'POST',\n  });\n  console.log('/orders', unavailable.status, unavailable.headers.get('retry-after'));\n  server.close();\n});
\n

Ожидаемый вывод содержит /health 200 ok, /missing 404 missing и /orders 503 10. В первом случае совпали метод и путь. Во втором сервер сформировал HTTP-ответ, поэтому TLS здесь вообще не участвует. В третьем клиент получил указание Retry-After: 10, но код не запускает автоматический повтор.

\n

Измените в первом цикле GET на отдельный запрос POST /health. Ответ будет 404, потому что простая модель сервера различает метод и URI. Это маленькая, но полезная проверка: одинаковый путь не означает одинаковый контракт.

\n

Разделяем проверки командами

\n

Для HTTPS удобнее идти от дешёвого наблюдения к дорогому. Команды ниже — шаблоны: подставьте разрешённый hostname и безопасный endpoint, не копируйте токены и cookies в историю shell.

\n
# Адреса, которые вернул локальный DNS-resolver\ndig +short api.example.test\n\n# Полезные детали соединения и заголовки ответа\ncurl --verbose --connect-timeout 3 --max-time 10   --dump-header - --output /dev/null   https://api.example.test/health\n\n# Наблюдение TLS с явным SNI; сертификат проверяйте штатным клиентом\nopenssl s_client -connect api.example.test:443   -servername api.example.test -brief </dev/null
\n

dig показывает ответ выбранного resolver-а, но не маршрут внутри сети. curl --verbose помогает увидеть этап соединения и HTTP-заголовки; его вывод всё равно нужно сопоставить с логами. openssl s_client показывает детали рукопожатия, но набор флагов и текст результата зависят от версии OpenSSL. Ни одна команда не доказывает состояние бизнес-операции.

\n

Если имя не разрешается, остановитесь на DNS и проверьте resolver. Если TCP соединён, но TLS не завершён, сравните hostname в URL с SAN и цепочкой доверия. Если TLS завершён и есть статус, переходите к HTTP-маршруту. Если статус 503 или 504, добавьте в расследование upstream и границы времени, а не только клиентский timeout.

\n

Почему retry требует отдельного решения

\n

Повтор после сетевого обрыва оставляет неопределённый результат: сервер мог принять запрос, а клиент не успел получить ответ. Для чтения такой повтор часто допустим по смыслу метода, но лимит, deadline и нагрузка всё равно остаются проектными решениями. Для записи одного статуса 503 недостаточно.

\n

HTTP определяет идемпотентность метода как свойство повторного применения к серверу с тем же эффектом, что и однократное применение, если исходный запрос уже был выполнен. Это не означает, что каждый конкретный endpoint безопасен автоматически. POST по умолчанию не получает такой гарантии от протокола. Сервис может добавить ключ идемпотентности и дедупликацию, но это уже его прикладной контракт.

\n
Решение о повторе принимается по эффекту операции
СитуацияРискЗащита
GET вернул 503 с коротким deadlineЛишняя нагрузка и каскад повторовограничить число попыток, общий deadline и backoff
POST оборвался без ответаЗаказ мог быть созданключ операции, дедупликация и проверка результата
Ответ содержит Retry-AfterЗадержка интерпретирована неверноразобрать секунды или дату и не превышать общий deadline
504 от gatewayupstream мог завершить работу после обрывасопоставить логи gateway и upstream до повтора
\n

Без контракта идемпотентности безопасное действие после неопределённого POST — не повторять вслепую. Сначала запросите состояние операции по отдельному идентификатору или передайте результат владельцу сервиса. Клиентская библиотека не может восстановить неизвестный побочный эффект по одному коду ответа.

\n

Порядок расследования

\n
  1. Сохраните точные входы: hostname, порт, метод, нормализованный путь, время, код клиента и безопасный request ID.
  2. Проверьте DNS отдельно: адрес, resolver, ожидаемый TTL и совпадение окружения. Не переходите к маршруту приложения, пока имя ведёт не туда.
  3. Проверьте TCP и TLS: доступность порта, hostname, SAN, цепочку, срок действия и SNI. Не отключайте проверку сертификата в рабочем запросе.
  4. Если появился HTTP-статус, сравните метод, URI, Host, Allow, Retry-After, тип тела и логи proxy с логами origin.
  5. Для 503 и 504 зафиксируйте upstream, таймаут каждого слоя и факт выполнения операции. Один клиентский замер не разделяет эти причины.
  6. Перед retry классифицируйте эффект: чтение, идемпотентная запись или неопределённая операция. Для последней сначала найдите статус по ключу операции.
  7. После изменения одного условия повторите тот же запрос и сравните новое наблюдение с исходным. Если изменились одновременно маршрут, timeout и код клиента, причинность не доказана.
\n

Ограничения применимости

\n

Эта схема описывает обычный HTTPS-путь с доступным наблюдением клиента. Она не заменяет диагностику mTLS, QUIC/HTTP/3, service mesh, корпоративного proxy, CDN, нестандартного DNS, балансировки по региону или логики авторизации. В таких системах добавьте соответствующие границы и владельцев, сохранив порядок «первый подтверждённый слой → следующая проверка».

\n

Статус, полученный от proxy, не доказывает, что origin получил запрос. Запись в access log не доказывает завершение транзакции в базе. DNS-ответ не доказывает наличие маршрута. Локальный сервер не моделирует сертификаты, распределённые часы, реальную очередь или повторную доставку. Эти выводы требуют собственных трасс, логов и тестового стенда.

\n

Команды могут раскрыть имена хостов и заголовки, поэтому запускайте их только в разрешённом окружении. Не используйте чужие адреса, не отправляйте production-записи в учебный endpoint и не добавляйте Authorization, Cookie или персональные параметры в публичный отчёт.

\n

Критерий завершения проверки

\n

Расследование можно закрыть, когда для одного запроса записаны входы, первый подтверждённый слой, доказательство и действие. Для ошибки сертификата это детали имени и цепочки; для 404 — метод, URI и владелец ответа; для 503/504 — upstream, временные границы и решение по retry. После изменения воспроизведите только этот сценарий и проверьте, что наблюдение изменилось ожидаемым образом.

\n

Если остаётся только фраза «ошибка исчезла», проверка не закончена. Нужен повторяемый запрос, сопоставленный с логами и контрактом операции. Тогда следующий инженер сможет отличить исправленный маршрут от временно здорового upstream и не вернётся к случайному изменению timeout.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/025.json b/editorial/agent-rewrites/025.json index aed29bf..ec90e87 100644 --- a/editorial/agent-rewrites/025.json +++ b/editorial/agent-rewrites/025.json @@ -1 +1,7 @@ -{"index":25,"slug":"editorial-2027-04-field-build-evolution","title":"Рост frontend bundle: как найти конкретный input и не чинить симптом","excerpt":"JavaScript-артефакт стал больше, но размер файла не говорит о причине. Разбираем metafile, связываем delta с input и проверяем, что изменилось в output и доставке.","contentHtml":"

После изменения сборки JavaScript-файл стал больше. В отчёте видна общая delta, но не видно, какой импорт её создал. Команда удаляет самую крупную библиотеку по названию. Так легко сломать функцию и не убрать причину: размер мог вырасти из-за нового entry point, дубликата зависимости, отключённого tree-shaking или source map, попавшей в артефакт. Цена ошибки — регресс поведения, лишний сетевой трафик и несколько итераций вслепую.

Тезис статьи простой: рост bundle нужно свести к изменению между двумя наборами входов. Сначала сравнивают metafile или другой отчёт состава сборки. Потом находят input с ненулевой delta, проверяют его связь с chunk и только затем меняют импорт, конфигурацию или delivery. Общий размер файла остаётся симптомом, а не диагнозом.

Как проходит сигнал от input к браузеру

Сборщик читает entry point и рекурсивно разрешает импорты. Он преобразует модули, удаляет недостижимый код, объединяет часть графа и записывает output. Metafile описывает этот путь в структурированном виде. У esbuild в нём есть разделы inputs и outputs; у конкретного инструмента формат может отличаться, но граница анализа остаётся той же.

Input — файл или модуль, который участвовал в сборке. Его bytes показывают вклад исходного входа в анализ. Output — созданный артефакт. Его размер зависит от преобразования, минификации, разделения chunks и повторного использования общего кода. Передача по сети зависит ещё от gzip или Brotli, заголовков и кэша браузера. Поэтому число в metafile нельзя называть размером загрузки без отдельной проверки.

Source map решает другую задачу. Она связывает преобразованный код с исходными файлами для отладки. Карта может быть большой. Её наличие в каталоге сборки не означает, что её нужно отдавать каждому пользователю. Проверяйте output, HTTP-заголовок и политику публикации отдельно.

\"Цикл
Общий симптом проходит несколько границ: input, chunk, output и HTTP-ответ. Исправление выбирают после перехода к конкретному слою.

Учебный diff двух отчётов

Ниже — учебная функция для минимального esbuild-подобного JSON. Она объединяет имена входов из двух отчётов, подставляет ноль для отсутствующего input, считает разницу и сортирует рост сверху. Пример показывает способ поиска. Он не запускает сборку и не доказывает результат в конкретном проекте.

import { summarizeBundleDiff } from './upgrade-2027-04.mjs';\n\nconst before = {\n  inputs: {\n    'src/app.ts': { bytes: 4200 },\n    'src/search.ts': { bytes: 1800 },\n    'node_modules/date-fns/index.js': { bytes: 900 }\n  }\n};\n\nconst after = {\n  inputs: {\n    'src/app.ts': { bytes: 4200 },\n    'src/search.ts': { bytes: 1800 },\n    'node_modules/date-fns/index.js': { bytes: 900 },\n    'node_modules/chart-lib/index.js': { bytes: 7600 }\n  }\n};\n\nconsole.log(summarizeBundleDiff(before, after));\n// [{ name: 'node_modules/chart-lib/index.js', before: 0,\n//    after: 7600, delta: 7600 }]

В реальном отчёте сохраняйте рядом commit, lockfile, команду сборки, режим, entry points, версию runtime и имена output. Иначе два JSON могут выглядеть сравнимыми, хотя один собран с другой конфигурацией. Перед diff проверьте, что пути нормализованы: абсолютный путь рабочей машины и относительный путь CI создадут две разные строки для одного файла.

Следующий вопрос — не «какой input самый большой?», а «как этот input попал в конкретный output?». Ищите связи в разделе outputs или в анализаторе вашего bundler. Если модуль вошёл только в ленивый chunk, изменение не равно росту initial загрузки. Если он попал в общий chunk, его стоимость может распространяться на несколько страниц. Если output не изменился, ищите причину в сжатии, заголовках, кэше или измерении браузера.

Симптом → причина → проверка → действие

Матрица диагностики роста bundle
СимптомПричинаПроверкаДействие
Появился новый большой inputНовый импорт или entry pointНайти первый импорт и output, в который он попалРазделить загрузку, удалить импорт или оставить стоимость с объяснением
Старый input выросИзменился export, plugin или transformСравнить delta input и настройки tree-shakingПроверить side effects, export и конфигурацию плагина
Одна зависимость видна несколькими путямиДубликаты версий или разные resolver conditionsСопоставить реальные пути и lockfileСвести версии, alias или условия разрешения
Metafile почти тот же, но ответ тяжелееИзменились minify, compression или headersСравнить raw, gzip/Brotli и HTTP responseИсправить delivery и повторить browser check
Выросла source mapDebug artifact публикуется рядом с production outputПроверить каталог, header SourceMap и сетевой запросОграничить публикацию карты нужной среде
В metafile нет объясненияСравниваются разные входы или другой формат отчётаСверить commit, command, target и схему JSONПересобрать baseline и candidate в одинаковых условиях

Пример с динамическим импортом

Представим страницу поиска. В baseline она импортирует форму и таблицу при первом открытии. В candidate в общий модуль добавили визуализацию: import Chart from 'chart-lib'. В metafile появился новый input. Но решение зависит от маршрута импорта.

// Динамическая граница загрузки. Учебный пример.\nconst openChart = async () => {\n  const { renderChart } = await import('./chart/render-chart.js');\n  return renderChart();\n};\n\nbutton.addEventListener('click', openChart);

Если сборщик поддерживает code splitting и конфигурация сохраняет эту границу, библиотека может уйти в отдельный chunk. Тогда initial bundle не обязан вырасти на весь вклад библиотеки. Цена появляется при открытии графика: пользователь ждёт дополнительный запрос и выполнение кода. Нужно измерять оба пути.

Статический импорт даёт другой результат: import { renderChart } from './chart/render-chart.js'. Если модуль достижим из entry point и не исключён настройками, он может попасть в initial output. Это не ошибка само по себе. Для критического пути важнее время до функции, чем минимальный размер каждого файла. Сначала сформулируйте границу загрузки, затем проверьте, сохранил ли её bundler.

Динамический импорт также не гарантирует маленький chunk. Внутри него могут оказаться общие зависимости, полифиллы или набор файлов, созданный шаблонным путём. Для runtime-пути проверяйте сетевой waterfall, размер после сжатия, cache headers и время выполнения. Нельзя делать вывод только по строке delta в отчёте.

Не перепутать состав с поведением

Tree-shaking удаляет код, который инструмент считает недостижимым. Побочные эффекты, формат модуля и настройки package могут изменить этот вывод. Если большой input присутствует в отчёте, это ещё не доказывает, что весь исходный файл попал в переданный bundle. Смотрите связь input с output и фактические bytes output.

Дубликат зависимости часто выглядит как два похожих пути: одна копия разрешилась из корня, другая — из вложенного package. Сначала проверьте lockfile и resolver. Alias может уменьшить размер, но сломать пакет, который рассчитывает на другую версию или экспорт. Правило «свести всё к одной версии» применяйте только после проверки совместимости.

Source map нельзя считать частью пользовательского JavaScript без проверки HTTP. Если карта доступна по ссылке из production-ответа, браузер и инструменты разработчика смогут запросить её. Это удобно для отладки, но карта может раскрывать исходники и увеличивать доступный объём артефактов. Решение зависит от политики проекта и среды.

Действия по порядку

  1. Зафиксировать baseline и candidate: commit, lockfile, runtime, команда, режим, entry points, flags и output directory.
  2. Собрать оба отчёта состава на одинаковом окружении. Записать exit code и не смешивать cold cache с warm cache без пометки.
  3. Нормализовать пути и схему JSON. Запустить diff по inputs, затем отсортировать изменения по абсолютной delta.
  4. Для каждого заметного input найти output и chunk. Отделить initial, lazy и shared части.
  5. Проверить причину: импорт, версия зависимости, resolver, plugin, tree-shaking, minify или source map.
  6. Сделать одно изменение. Пересобрать candidate и повторить diff, чтобы увидеть, исчезла ли именно заявленная delta.
  7. Проверить браузерный путь: network transfer, compression, cache hit, время загрузки lazy chunk и ошибки runtime.
  8. Зафиксировать отрицательный путь: если metafile стабилен, не менять импорт, а перейти к delivery или browser measurement.

Ограничения и критерий готовности

Metafile показывает модель сборщика, а не полную стоимость для пользователя. Разные bundler описывают input и output по-разному. Сжатие, HTTP-кэш, CDN, service worker и скорость CPU находятся за пределами одного JSON. Source map может быть создана, но не отдана клиенту. Поэтому сравнение состава нельзя выдавать за измерение производительности страницы.

Нельзя считать исправлением постоянное отключение source map, удаление зависимости по имени или включение агрессивного split без проверки поведения. Нельзя сравнивать отчёты после разных изменений в lockfile и конфигурации. Если входы различаются, сначала восстановите сопоставимые условия; иначе отрицательный результат анализа честнее случайного вывода.

Готовность подтверждается четырьмя артефактами: отчёты baseline и candidate с условиями запуска, diff с конкретным input и output, проверка изменённого пользовательского пути и повторная сборка после действия. Другой инженер должен увидеть, что изменилось, воспроизвести проверку и понять, почему выбранное действие относится к причине. Если причина не найдена, готовым результатом считается зафиксированная граница: состав bundle стабилен, следующий тест идёт на уровне compression, HTTP или браузера.

Проверяемые источники

"} +{ + "index": 25, + "slug": "editorial-2027-04-field-build-evolution", + "title": "Рост frontend bundle: как найти конкретный input и не чинить симптом", + "excerpt": "Общий размер JavaScript-артефакта показывает симптом, но не причину. Разбираем сопоставимый diff metafile, связь input с output и проверку реальной доставки в браузер.", +"contentHtml": "

После изменения frontend-сборки команда видит в отчёте новый большой файл и сразу предлагает удалить самую заметную библиотеку. Это симптом, а не диагноз: такой ход часто промахивается мимо причины. Рост мог появиться из-за нового entry point (точки входа), дубликата зависимости, другой ветки разрешения пакета, изменившегося tree-shaking или публикации source map. В результате можно сломать рабочий экран, а initial-загрузка останется прежней.

Разберём воспроизводимый сценарий: baseline и candidate собраны из разных состояний проекта, а candidate стал тяжелее. Цель диагностики — не найти «виновный пакет», а показать цепочку input → import → output/chunk → HTTP-ответ. После этого решение уже предметное: изменить импорт, выровнять зависимость, вернуть настройку сборщика или проверить доставку. Если цепочка не сходится, честный результат — не менять код наугад.

Что именно измеряет bundle-анализ

Сборщик читает entry point и проходит граф импортов. Затем он преобразует модули, удаляет часть недостижимого кода, объединяет совместимые модули и записывает один или несколько output-файлов. В esbuild JSON-метаданные сборки называются metafile. В них есть inputs и outputs: первые описывают входные файлы и их исходный размер, вторые — созданные артефакты, их размер и вклад входов.

inputs[path].bytes — не размер загруженного браузером файла. Это размер входного файла, участвовавшего в анализе. outputs[path].bytes — размер созданного output до сетевого сжатия. Для вывода о пользовательской загрузке дополнительно понадобятся фактический HTTP-ответ, gzip или Brotli, cache headers, service worker и порядок запросов. Один показатель нельзя подменять другим.

Ещё одна граница проходит между текстовой сводкой и JSON. Формат вывода команды анализа удобен для человека, но его не стоит использовать как API для автоматического diff. Для инструмента сравнения берите JSON-метаданные и явно фиксируйте версию сборщика и схему полей.

\"Цепочка
Сначала связываем delta с конкретным input и output, затем проверяем, что пользователь действительно получил в ответе. Общий размер файла — только начало расследования.

Сначала сделайте два отчёта сопоставимыми

Diff имеет смысл только для сборок, где изменён один понятный фактор. Перед запуском сохраните commit, lockfile, версию Node.js и bundler, команду, режим production/development, entry points, флаги и каталог output. Зафиксируйте также, были ли включены minify, source map, code splitting и анализ зависимостей.

Минимальная команда из документации esbuild выглядит так:

esbuild app.js --bundle --metafile=meta.json --outfile=out.js

Для candidate используйте ту же команду и меняйте только заявленное условие. Если baseline собирался с --outfile, а candidate с --outdir, сравнение файлов уже смешивает изменение состава и изменение режима записи. То же происходит при замене lockfile, resolver conditions или платформы без отметки в протоколе.

Пути в metafile по умолчанию относительные. Это помогает получать воспроизводимые отчёты на разных машинах. Если один отчёт содержит src/app.ts, а другой — абсолютный путь рабочей станции, нормализуйте пути до сравнения или пересоберите baseline. Иначе один input будет ошибочно принят за два.

Воспроизводимый diff по inputs

Ниже — самостоятельный пример без зависимости от конкретного проекта. Функция берёт два объекта с полем inputs, добавляет нули для новых и исчезнувших путей и сортирует изменения по модулю delta. Она не утверждает, что вход целиком оказался в output: следующий шаг обязан проверить раздел outputs.

function diffInputs(before, after) {\\n  const names = new Set([\\n    ...Object.keys(before.inputs || {}),\\n    ...Object.keys(after.inputs || {}),\\n  ]);\\n\\n  return [...names]\\n    .map((name) => {\\n      const beforeBytes = before.inputs?.[name]?.bytes ?? 0;\\n      const afterBytes = after.inputs?.[name]?.bytes ?? 0;\\n      return {\\n        name,\\n        before: beforeBytes,\\n        after: afterBytes,\\n        delta: afterBytes - beforeBytes,\\n      };\\n    })\\n    .filter((item) => item.delta !== 0)\\n    .sort((left, right) => Math.abs(right.delta) - Math.abs(left.delta));\\n}\\n\\nconst baseline = {\\n  inputs: {\\n    'src/app.ts': { bytes: 4200 },\\n    'src/search.ts': { bytes: 1800 },\\n    'node_modules/date-fns/index.js': { bytes: 900 },\\n  },\\n};\\nconst candidate = {\\n  inputs: {\\n    ...baseline.inputs,\\n    'node_modules/chart-lib/index.js': { bytes: 7600 },\\n  },\\n};\\n\\nconsole.log(diffInputs(baseline, candidate));\\n// [{ name: 'node_modules/chart-lib/index.js', before: 0,\\n//    after: 7600, delta: 7600 }]

Список с большой delta — это список кандидатов для проверки, а не готовый вердикт. Сверьте путь с lockfile и найдите первый импорт, который приводит к нему. Наличие файла в inputs говорит, что сборщик его читал; оно не говорит, что все его исходные байты попали в конкретный output после tree-shaking.

Свяжите input с output и chunk

В каждом output esbuild хранит собственное поле inputs. Вложенное bytesInOutput показывает вклад входного файла в этот output. Там же могут быть imports, exports и entryPoint. Эта связь отвечает на главный вопрос: новый input увеличил initial-файл, lazy chunk, общий chunk или вообще не тот артефакт, который измеряет команда.

Проверяйте путь по такой последовательности:

  1. В inputs найдите новые и выросшие пути, затем отсортируйте их по delta.
  2. В каждом output найдите тот же input и запишите bytesInOutput, имя output и его entryPoint, если поле есть.
  3. По outputs[*].imports восстановите связи между output и отделите initial-файл от импортируемого chunk.
  4. Сопоставьте путь с исходным импортом, lockfile и настройками resolver. Проверьте, не появилось ли две версии одной библиотеки.
  5. Соберите candidate после одного изменения и повторите diff. Исправление доказано только тогда, когда исчезла заявленная delta и пользовательский путь продолжил работать.

Полезно хранить рядом с diff короткую запись: «input chart-lib/index.js добавлен в search.js, output — search-ABC.js, initial не изменился, lazy-запрос вырос после нажатия кнопки». Такая запись воспроизводимее, чем фраза «bundle стал меньше».

Матрица симптомов и проверок

Как перейти от наблюдаемого симптома к проверяемому действию
СимптомРабочая гипотезаЧто проверитьОграниченное действие
Новый большой inputДобавился импорт или entry pointПервый импорт, output и entry pointРазделить загрузку или удалить импорт, если функция не нужна
Старый input выросИзменились export, plugin или transformНастройки сборки, формат модуля и diff lockfileВернуть совместимую настройку и пересобрать
Одна библиотека имеет два путиДве версии или разные условия resolverLockfile, реальные пути и package exportsСвести версии только после проверки совместимости
Metafile прежний, HTTP-ответ тяжелееИзменились minify, compression или headersRaw, gzip/Brotli, response headers и cacheИсправлять delivery, не импорт
Выросла картаSource map создаётся или публикуется иначеКаталог, SourceMap и сетевой запросРазделить политику debug-артефактов и production
Diff нестабилен между машинамиРазные пути, runtime или lockfileFingerprint окружения и относительность путейВернуть одинаковые условия до сравнения

Динамический import меняет место стоимости

Рассмотрим экран поиска, где график нужен только после нажатия кнопки. Если сборщик сохраняет границу code splitting, динамический import() может вынести модуль в отдельный файл. Initial-загрузка тогда не обязана увеличиться на весь график, но пользователь заплатит дополнительным запросом и выполнением кода при открытии графика.

const openChart = async () => {\\n  const { renderChart } = await import('./chart/render-chart.js');\\n  return renderChart();\\n};\\n\\ndocument.querySelector('#open-chart')\\n  .addEventListener('click', openChart);

В esbuild code splitting требует output directory и формат ESM. Без включённого splitting асинхронная семантика import() сохраняется, но импортированный код может оказаться в том же bundle. Поэтому одного наличия динамического импорта недостаточно: проверьте флаг, формат и фактический список output.

Изменение размера lazy chunk — не автоматически регресс. Если график редко открывают, меньший initial путь может быть предпочтительнее. Если его открывают сразу после первого экрана, дополнительный запрос может увеличить время до функции. Сравните оба пользовательских сценария: холодную загрузку страницы и переход по кнопке. В network panel запишите transfer size, статус cache, длительность запроса и ошибки выполнения.

Почему нельзя удалять код по имени

Tree-shaking удаляет недостижимые объявления, но его результат зависит от статических ES-модулей и побочных эффектов. В документации esbuild отдельно указано, что tree-shaking использует import/export, а CommonJS не даёт такой же статической информации. Поэтому «большой пакет» может быть не причиной, а следствием выбранного формата модуля или настройки package fields.

То же относится к дубликатам. Alias или принудительное сведение версий иногда уменьшает output, но может изменить API, side effects или поведение плагина. Сначала установите, какие два пути разрешаются и какие exports реально используются. Потом проверьте тестовый сценарий и только затем меняйте resolver.

Source map — отдельный артефакт от JavaScript. Заголовок HTTP SourceMap указывает браузерным инструментам, где искать карту для оптимизированного ресурса. В production нужно проверить не только наличие файла в каталоге, но и ссылку из ответа, права доступа и принятую в проекте политику раскрытия исходников. Рост карты не доказывает рост пользовательского JavaScript, а её публикация может расширить доступный набор исходных материалов.

Критерий готовности и границы метода

Диагностика готова, когда другой инженер получает два отчёта с одинаковыми условиями, diff с конкретным input, связь этого input с output/chunk, результат браузерной проверки и одно изменение, которое можно повторить. Для каждой цифры должно быть ясно, это входные bytes, raw output или сетевой transfer. Для каждого решения должна быть названа цена: дополнительный lazy-запрос, риск несовместимости версий, потеря отладки или изменение initial-критического пути.

Metafile не измеряет время выполнения JavaScript, стоимость парсинга и компиляции, порядок запросов, cache hit или работу service worker. Формат поля зависит от bundler и его версии; код, который жёстко ожидает только текущие поля, следует защищать проверкой схемы. Динамический импорт не гарантирует отдельный chunk без подходящей конфигурации. Сжатие может изменить порядок «самых больших» файлов после raw diff.

Остановите расследование и вернитесь к сборке, если baseline и candidate отличаются lockfile, entry points или режимом без документированной причины. Не называйте результатом «оптимизацию» удаление зависимости по названию. Если состав output не изменился, переходите к compression, HTTP, кэшу и измерению браузерного пути. Именно эта граница не даёт исправить не тот слой.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/026.json b/editorial/agent-rewrites/026.json index 4210671..85e725c 100644 --- a/editorial/agent-rewrites/026.json +++ b/editorial/agent-rewrites/026.json @@ -1 +1 @@ -{"index":26,"slug":"editorial-2027-04-mechanism-build-evolution","title":"Кэш frontend-сборки: как ключ сохраняет или скрывает устаревший результат","excerpt":"Разбираем, какие входы должны формировать ключ кэша сборки, почему cache hit не доказывает свежесть артефакта и как проверить отрицательный путь.","contentHtml":"

Сборка внезапно стала медленной, хотя в CI почти каждый запуск сообщает cache hit. В другом случае job проходит за секунды, но после изменения lockfile приложение получает старый bundle. Эти симптомы похожи на проблему производительности, но цена ошибки выше: команда либо платит временем за постоянные промахи, либо публикует артефакт, который не соответствует исходникам.

Тезис простой: кэш повторяет результат функции от конкретного набора входов. Ключ должен меняться, когда меняется любой вход, влияющий на dependency graph, transform или output. Cache hit подтверждает только совпадение ключа. Он не подтверждает полноту ключа, корректность публикации и соответствие source map.

Механизм: кэш повторяет вычисление, а не «проект»

Bundler читает исходники, lockfile, конфигурацию, плагины и окружение. Затем он строит граф модулей и сохраняет промежуточные или итоговые данные. При следующем запуске он вычисляет ключ и решает, можно ли использовать сохранённый результат. Если ключ содержит мало данных, система не видит устаревание. Если ключ содержит случайные данные, система не видит повторение.

У ключа есть три свойства. Он должен быть детерминированным: одинаковые нормализованные входы дают одинаковое значение. Он должен быть чувствительным: изменение значимого входа меняет значение. Он должен быть ограниченным: в него не попадают timestamp, случайный UUID и абсолютный путь, если они не влияют на output. Иначе кэш либо выдаёт ложный hit, либо превращает каждый запуск в miss.

Минимальный набор зависит от инструмента. Для dependency pre-bundling важны lockfile, patches, релевантная конфигурация и среда выполнения. Для файлового кэша webpack дополнительно важны режим, каталог и сериализация. Для linked dependency нужно проверить, как bundler разрешает symlink и когда повторяет оптимизацию. Нельзя перенести список входов из одного toolchain в другой без проверки его семантики.

Матрица ключа кэша frontend-сборки: lockfile, конфигурация, runtime, исходный digest и каталог кэша ведут к проверке результата.
Ключ связывает входы с результатом, но не заменяет проверку output. Изменение значимого входа должно вести к invalidation.

Симптом → причина → проверка → действие

Диагностика кэша сборки
СимптомПричинаПроверкаДействие
Каждый запуск — missКлюч включает время или нестабильный путьСравнить ключ двух запусков без изменения исходниковНормализовать входы и убрать шумные поля
Hit после изменения lockfileLockfile не входит в ключИзменить только lockfile и записать key до/послеДобавить digest lockfile и проверить invalidation
Разные job видят чужой outputОбщий каталог без namespaceСопоставить key, runner, права и locationРазделить namespace или ограничить общий кэш
Bundle новый, source map стараяКэшируются связанные артефакты с разными условиямиСверить bundle, map и commit в одном jobПубликовать согласованную пару или остановить выпуск
Linked package не меняетсяDev-server использует сохранённый pre-bundleИзменить linked dependency и проверить повторную оптимизациюПрименить документированный force/re-bundle и уточнить watch-контракт

Учебный пример ключа

Ниже функция показывает прозрачный способ собрать ключ из четырёх строк. Пример учебный: он не знает формат конфигурации вашего bundler, не запускает сборку и не доказывает результат в production. Его проверяемое свойство — изменение lockfile меняет ключ, а повтор тех же входов сохраняет его.

import { makeDependencyCacheKey } from './upgrade-2027-04.mjs';\n\nconst base = {\n  lockfile: 'lock-v1',\n  config: 'target=es2022;minify=true',\n  runtime: 'node-24',\n  sourceDigest: 'src-001',\n};\n\nconst first = makeDependencyCacheKey(base);\nconst repeat = makeDependencyCacheKey({ ...base });\nconst afterLockfileChange = makeDependencyCacheKey({\n  ...base,\n  lockfile: 'lock-v2',\n});\n\nconsole.log(first === repeat); // true\nconsole.log(first === afterLockfileChange); // false

Функция использует фиксированный порядок полей и разделитель строк перед вычислением SHA-256. Реальный проект должен определить полный набор входов отдельно. Если plugin меняет transform, его версия или нормализованная конфигурация должны участвовать в digest. Если runtime меняет ABI или формат сериализации, одной версии Node может быть мало.

Обратный путь важнее положительного. Если ключ совпал, но output не соответствует commit, нельзя лечить симптом постоянным force. Сначала нужно установить, какой вход пропущен, где лежит чужой результат и какой job его записал. Если причина неизвестна, безопасное действие — остановить публикацию или очистить ограниченный namespace, а затем добавить диагностический вывод. Принудительная инвалидизация скрывает дефект ключа и вернёт его после следующего изменения.

Cache hit требует второй проверки

После hit проверьте не только exit code. Сверьте digest bundle, source map, список chunks и commit, из которого построен артефакт. Если сборка публикует manifest, сравните его с фактическими файлами. Наличие файла в каталоге кэша не означает, что job использовал его целиком: bundler мог восстановить часть данных и пересобрать остальное.

Разделяйте cold и warm режимы. Cold run показывает стоимость работы без сохранённого результата. Warm run показывает выигрыш при совпадении условий. Эти числа отвечают на разные вопросы. Не смешивайте время установки зависимостей, bundling, minify и upload, если измеряете только сборку. Не переносите локальный hit на CI: другой Node, runner, каталог или права меняют результат.

Для Vite linked dependency является отдельной границей. Локальный пакет может разрешаться не так, как опубликованная зависимость. Изменение файла не обязано автоматически менять pre-bundle. Проверяйте документированное поведение dependency optimizer и режим повторной оптимизации. Для webpack memory cache живёт в процессе, а filesystem cache переживает запуски. У них разная стоимость, область действия и диагностика.

Порядок проверки

  1. Выпишите входы, которые меняют граф зависимостей, transform, target, формат output или правила публикации.
  2. Нормализуйте конфигурацию и значения окружения. Зафиксируйте lockfile, runtime, bundler, платформу, каталог и namespace.
  3. Соберите deterministic key. Уберите timestamp, случайные значения и абсолютные пути, если они не меняют результат.
  4. Проверьте положительный путь: одинаковые входы дают hit и одинаковые digest bundle, map и manifest.
  5. Проверьте отрицательный путь по одному изменению: lockfile, конфигурация, runtime, исходный модуль и linked package должны дать miss или документированную invalidation.
  6. Запишите key, hit/miss, location, cold/warm режим и причину invalidation. Не выводите секреты и приватные исходники.
  7. Запретите публикацию, если key совпал, а согласованность артефактов не доказана. Исправьте входы или границу кэша и повторите проверку.

Ограничения

Hash строки не понимает смысл конфигурации. Два разных текста могут описывать одинаковое поведение и дать разные ключи. Обратная ситуация опаснее: один digest может не учитывать plugin, symlink, системную библиотеку или скрытый флаг. Поэтому формула ключа должна следовать реальным входам bundler, а не удобству реализации.

Общий файловый кэш зависит от прав, конкуренции job, срока хранения и способа очистки. Namespace защищает от смешения результатов, но не исправляет неполный ключ. Source map может не публиковаться в production, однако при диагностике её нужно сверять с тем же bundle и commit. Пользовательская скорость также не следует из cache hit: её проверяют отдельными браузерными и сетевыми измерениями.

Проверяемый критерий готовности

Механизм готов, если другой инженер может повторить два запуска на одинаковом входе и получить одинаковый key, затем изменить один значимый вход и увидеть ожидаемый miss или явную invalidation. После hit bundle, source map и manifest проходят проверку согласованности. В отчёте видны входы, runtime, location, состояние кэша и причина решения.

Если хотя бы один изменённый вход сохраняет старый output без объяснённого контракта, кэш нельзя считать корректным. Если система не показывает причину hit или miss, сначала добавьте наблюдаемость. Только после этого сравнивайте секунды и решайте, оправдывает ли ускорение сложность хранения.

Проверяемые источники

"} +{"index":26,"slug":"editorial-2027-04-mechanism-build-evolution","title":"Кэш frontend-сборки: как ключ сохраняет или скрывает устаревший результат","excerpt":"Разбираем границы кэша dependency optimizer и сборщика, составляем ключ из проверяемых входов и доказываем свежесть артефакта через положительный и отрицательный тест.","readingMinutes":9,"contentHtml":"

Сборка внезапно стала медленной, хотя CI сообщает cache hit. В другом запуске job заканчивается за секунды, но после изменения lockfile приложение получает старый bundle. Оба симптома выглядят как проблема производительности. На деле команда рискует либо платить временем за постоянные промахи, либо опубликовать артефакт, который не соответствует исходникам.

Кэш не хранит абстрактный «проект». Он повторяет результат вычисления для конкретных входов. Поэтому совпадение ключа доказывает только, что система выбрала сохранённую запись. Оно не доказывает полноту ключа, согласованность bundle и source map или свежесть уже опубликованного файла. В этой статье разберём, какие границы нужно разделить и как проверить их без доверия к одному статусу hit.

Что именно кэшируется

Слово «кэш сборки» скрывает несколько механизмов. Vite в режиме разработки предварительно собирает зависимости, чтобы браузер не обходил сотни внутренних модулей. webpack может сохранять разобранные модули и chunks в памяти или на файловой системе. Браузер затем кэширует уже отданные HTTP-ресурсы. Эти слои могут использовать похожие слова, но у них разные владельцы состояния и разные условия инвалидирования.

Сначала назовите слой, который дал наблюдаемый сигнал. node_modules/.vite относится к кэшу dependency optimizer. node_modules/.cache/webpack относится к filesystem cache webpack по умолчанию. HTTP-заголовок Cache-Control относится к кэшу браузера или CDN. Статус одного слоя нельзя трактовать как доказательство состояния другого: быстрый webpack job не сообщает, какой JS-файл уже есть у пользователя.

У любого кэша есть функция от входов к результату. Входами могут быть исходный граф, lockfile, конфигурация, плагины, runtime, платформа, путь хранения и namespace. Если значимый вход не участвует в решении, старый результат может выглядеть валидным. Если в ключ попадает timestamp или случайное значение, повторная работа превращается в miss. Состав ключа — часть контракта, а не косметическая оптимизация.

Матрица кэша frontend-сборки связывает commit и lockfile, конфигурацию, состояние кэша и runner с проверкой итогового bundle.
Честное сравнение начинается с одинаковых входов и состояния кэша. После hit всё равно нужно сверить артефакт с исходным commit.

Ключ должен отвечать на четыре вопроса

Хороший ключ не обязан быть длинным, но обязан быть объяснимым. Для каждого поля можно ответить, какую часть результата оно меняет, как его нормализовать и какой тест покажет пропуск. Полезно заранее отделить вход вычисления от места хранения: directory и namespace могут влиять на безопасность обмена записями, но не всегда меняют сам bundle.

Минимальная карта входов и проверок
СлойЧто фиксироватьКак проверитьРиск пропуска
Dependency optimizerlockfile, patches, relevant config, NODE_ENVИзменить один вход и повторить запускСтарая pre-bundle после обновления зависимости
Сборщикcommit, entry graph, mode, loaders/plugins, targetСравнить key и digest outputНовый исходник не отражается в артефакте
Runtimeверсия Node, OS, CPU и native toolchain, если они влияют на outputСделать два запуска на зафиксированном runnerНесовместимый или недетерминированный результат
Хранилищеcache directory, name, branch namespace, праваПроверить, кто записал и кто восстановил записьОдин job получает чужой результат
Публикацияbundle, source map, manifest, content hashСверить файлы и ссылки в manifestКэш сборки свежий, а браузер получает старый URL

Такая карта не обещает, что перечислены все поля конкретного инструмента. Она задаёт рабочий вопрос: если поле изменилось, что именно должно измениться — ключ, промежуточный результат, имя файла или только deploy manifest? Например, версия Node может не менять текст bundle в одном проекте, но менять native-расширение, порядок сериализации или minifier output в другом. Решение нужно подтвердить экспериментом.

Что действительно обещает Vite

В документации Vite dependency pre-bundling относится только к development mode. Файловый кэш по умолчанию лежит в node_modules/.vite. Vite учитывает содержимое lockfile, время изменения каталога patches, релевантные поля vite.config.js и значение NODE_ENV. Это хороший пример явного контракта: изменение каждого перечисленного входа должно привести к повторной оптимизации.

Есть отдельная ловушка монорепозиториев. Linked dependency, который не разрешается из node_modules, Vite обычно рассматривает как исходный код и не пытается предварительно бандлить. Для ESM-пакета это может быть правильным поведением. Если пакет нужно оптимизировать, его добавляют в optimizeDeps.include. После изменения linked dependency документация рекомендует перезапустить dev server с --force. Это не исправляет неизвестный ключ: команда лишь явно просит повторить оптимизацию.

У Vite есть и второй кэш — браузерный. Разрешённые dependency-запросы получают длительное кэширование, а изменение установленной версии отражается в version query. Поэтому отладка локального пакета требует согласованного действия: выключить browser cache в DevTools, перезапустить сервер с принудительной оптимизацией и только затем смотреть новый запрос. Удаление каталога само по себе не показывает, почему исходное решение было неверным.

Практический вывод ограничен этим слоем. Vite-документ подтверждает условия invalidation dependency optimizer, но не описывает ваш CI-кэш, содержимое production bundle и правила деплоя. Если симптом возник только после публикации, проверяйте следующий слой, а не добавляйте --force в каждый локальный запуск.

Что проверять в webpack

У webpack параметр cache: true является сокращением для memory cache. В development mode memory cache используется по умолчанию, а для production mode значение по умолчанию — без кэширования. Filesystem cache включают явно. Он позволяет пережить процесс и использовать данные между запусками, но добавляет требования к каталогу, namespace, правам и составу build dependencies.

Для filesystem cache webpack хэширует дополнительные build dependencies и инвалидирует запись при их изменении. В официальном примере конфигурация добавляется через cache.buildDependencies.config: [__filename]. Это важнее, чем просто положить каталог в общий CI-кэш: если конфигурация и loader, влияющий на transform, не попали в зависимость, старый результат может пережить правку.

У filesystem cache есть имя. Разные значения cache.name создают независимые записи, поэтому им можно разделить несколько конфигураций в одном репозитории. Но имя не заменяет hash входов. В документации также указано, что файлы кэша webpack хранят абсолютные пути и для обмена между CI-запусками нужен одинаковый абсолютный путь. Если runner меняет рабочий каталог, результат следует считать неподтверждённым, пока конкретная конфигурация не доказала обратное.

Не смешивайте compile cache и browser cache. Настройка output.filename: '[name].[contenthash].js' меняет имя файла на основе содержимого asset и помогает браузеру получить новый URL. Она не доказывает, что compiler использовал свежий модуль. Обратное тоже верно: свежий compile output с прежним именем может остаться у браузера или CDN. Для выпуска нужно проверять и кэш сборки, и ссылку от HTML или manifest к опубликованным файлам.

Учебный ключ и два отрицательных теста

Ниже — маленькая функция для проверки идеи. Она хэширует только четыре явно названных значения. В реальном проекте порядок полей, нормализацию и список входов нужно согласовать с конкретным bundler. Код не подключается к Vite или webpack, не читает lockfile и не доказывает, что выбранный набор полон.

import { createHash } from 'node:crypto';\n\nconst keyFields = ['lockfile', 'config', 'runtime', 'sourceDigest'];\n\nexport function makeCacheKey(input) {\n  const payload = keyFields\n    .map((name) => name + '=' + String(input[name] ?? ''))\n    .join('|');\n\n  return createHash('sha256').update(payload).digest('hex');\n}\n\nconst base = {\n  lockfile: 'lock-v1',\n  config: 'target=es2022;minify=true',\n  runtime: 'node-24-linux-x64',\n  sourceDigest: 'src-001',\n};\n\nconst sameInputs = { ...base };\nconst changedLockfile = { ...base, lockfile: 'lock-v2' };\nconst changedSource = { ...base, sourceDigest: 'src-002' };\n\nconsole.log(makeCacheKey(base) === makeCacheKey(sameInputs)); // true\nconsole.log(makeCacheKey(base) === makeCacheKey(changedLockfile)); // false\nconsole.log(makeCacheKey(base) === makeCacheKey(changedSource)); // false

Первый тест проверяет положительный путь: одинаковые нормализованные значения дают одинаковый key. Два следующих теста проверяют отрицательный путь: изменение lockfile и исходного digest не должны сохранить тот же key. Если тест падает, кэш не готов к ускорению — сначала исправьте контракт.

Но изменения key недостаточно. Нужно проверить, что miss действительно запускает новое вычисление, а не только создаёт другой ярлык для старого каталога. Сохраните commit, key, состояние cold или warm, путь записи и digest каждого публикуемого файла. В отчёте не должны появиться секреты, токены и исходники, которые не нужны для диагностики.

Как расследовать ложный hit

Начните с повторения симптома на зафиксированном runner. Один запуск с очищенным ограниченным каталогом показывает стоимость cold path. Следующий запуск без изменений показывает warm path. Третий запуск меняет ровно один вход. Если одновременно обновить lockfile, конфигурацию и Node, вы увидите miss, но не узнаете, какой вход его вызвал.

Сравнивайте не только время. Запишите key и решение кэша, commit и lockfile digest, версии Node и bundler, список плагинов, рабочий каталог, cache name, branch namespace и список output. Для bundle и source map посчитайте digest. Для manifest проверьте, что каждая ссылка указывает на файл из этого же запуска. Для HTML проверьте, что он публикуется атомарно вместе с manifest.

Если key совпал, а output отличается, ищите скрытый вход или недетерминированность: timestamp в banner, порядок обхода файлов, системный шрифт, native binary, случайный идентификатор или переменную окружения. Если output совпал, но пользователь видит старый код, переходите к HTTP-кэшу, CDN, service worker и маршруту публикации. Ошибка может находиться после сборщика.

Если разные job используют один filesystem cache, проверьте гонку записи. Ветка и commit должны иметь понятный namespace. Запись от неподходящего target или режима нельзя считать fallback только потому, что её key похож. При сомнении безопаснее отклонить восстановление, чем опубликовать непроверенный артефакт.

Порядок проверки перед ускорением

  1. Назовите слой кэша и его результат: dependency pre-bundle, модульный cache, итоговый bundle или HTTP-ресурс.
  2. Выпишите все входы, которые могут изменить граф, transform, target, формат output или правила публикации.
  3. Разделите входы на обязательные, условные и неизвестные. Для неизвестных добавьте отдельный отрицательный эксперимент.
  4. Нормализуйте значения и соберите объяснимый key. Не добавляйте timestamp, UUID и абсолютный путь, если они не влияют на вычисление.
  5. Проведите cold, warm и один-change запуск на одном runner. Зафиксируйте hit или miss и причину решения.
  6. Сверьте bundle, source map, manifest и HTML с одним commit. Имя файла с contenthash — полезная проверка, но не замена digest-сверке.
  7. Проверьте конкурентный сценарий: две job не должны читать незавершённую запись или смешивать namespace разных конфигураций.
  8. Проверьте отрицательный путь: изменённые lockfile, config, source, plugin и runtime дают ожидаемый miss либо явно документированную invalidation.
  9. Только после этого измерьте экономию времени и решите, оправдывает ли она стоимость хранения, очистки и наблюдаемости.

Ограничения применимости

Эта модель не выбирает лучший bundler и не обещает детерминированность одного только SHA-256. Хэшируется представление входов, а не их смысл. Два эквивалентных конфига могут дать разные ключи, а одинаковый текст конфига может зависеть от loader, native library или скрытой переменной. Полный список входов должен следовать фактическому pipeline.

Учебная функция не является готовой настройкой CI. Она не читает дерево файлов, не определяет, какие поля Vite или webpack считают значимыми, не проверяет права на общий cache и не обнаруживает гонку записи. Использовать её как authorization или как единственное доказательство свежести нельзя. Для production нужны тесты конкретной конфигурации и наблюдаемость каждого решения.

Проверка source map применима только там, где map создаётся и доступна job. В production её может не быть по требованиям безопасности. Тогда сверяйте bundle, manifest, commit и другие доступные артефакты; отсутствие map не следует маскировать как успешную проверку.

Vite-примеры относятся к dependency optimizer в development mode. Параметры Vite и webpack меняются между версиями. Настройки CI, CDN, service worker и права хранилища в официальных руководствах bundler не описываются. Перед переносом рецепта закрепите версию инструмента и повторите эксперимент в своём runner.

Проверяемый критерий готовности

Кэш можно считать пригодным для ограниченного применения, когда другой инженер повторяет два запуска с одинаковыми входами и получает одинаковое решение, затем меняет один значимый вход и видит ожидаемый miss или явно объяснённую invalidation. После hit digest bundle и связанных файлов соответствует одному commit, а manifest и HTML ссылаются на тот же выпуск.

Дополнительный критерий — понятный отказ. Если запись нельзя связать с key, входами, runner или владельцем namespace, она не должна попасть в публикацию. Если на одном слое всё корректно, а пользователь видит старый ресурс, расследование продолжается на следующем слое. Такая граница экономит время: команда меняет конкретный контракт, а не добавляет бесконечные повторные сборки.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/027.json b/editorial/agent-rewrites/027.json index c3065ea..540f33e 100644 --- a/editorial/agent-rewrites/027.json +++ b/editorial/agent-rewrites/027.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-04-practice-build-evolution", "title": "Сборка быстрее на 20%? Сначала докажите, что вход одинаковый", "excerpt": "Как сравнивать frontend-сборки по одному входу, не принимать cache hit за ускорение и находить причину роста bundle по данным артефакта.", - "contentHtml": "

Новая frontend-сборка закончилась за 38 секунд вместо 47. Через день CI снова показывает 47 секунд. В другом запуске candidate оказался быстрее, но собирал только production entry, а baseline — два entry и source map. Цена ошибки — неверный выбор инструмента, потерянное время на миграцию и артефакт, который нельзя сравнить с опубликованным.

\n

Тезис: время и размер имеют смысл только для одинаковой работы. Сборщик получает исходный граф, lockfile, конфигурацию, runtime, entry points и состояние кэша. Если хотя бы один существенный вход отличается, результат нужно пометить как несопоставимый, а не объявлять победителя.

\n

Что именно сравнивает инженер

\n

Сборка не является одной операцией. Сначала резолвер строит граф модулей. Затем плагины и loaders преобразуют входы. Bundler раскладывает граф по chunks, минифицирует код и пишет output. Кэш может вернуть промежуточный результат до части этих шагов. Поэтому число из секундомера описывает не «скорость инструмента», а конкретный маршрут с конкретным состоянием.

\n

Размер тоже имеет несколько значений. Размер исходного input показывает вклад модуля в сборку. Размер output показывает файл на диске. Transfer size показывает объём после compression и HTTP-обмена. Эти величины нельзя подменять друг другом. Большой input может попасть в отложенный chunk, а небольшой модуль — блокировать первый экран.

\n
\"Последовательность
Секундомер запускается после фиксации условий. Если меняется вход или конфигурация, сравнение начинается заново.
\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Candidate быстрее в одном запускеУ него тёплый cacheСравнить hit/miss, directory и серию cold/warmРазвести режимы и повторить замер
Output меньше, но entry другойСобирается другая работаСверить entry, mode, flags и список chunksИсключить запуск или выровнять вход
Bundle вырос на 60 КБНовый input или duplicate dependencyСравнить metafile inputs и lockfileПроверить import, версии и split
Fingerprint совпал, результат старыйВ ключ не вошёл plugin или linked packageИзменить один вход и проверить invalidationРасширить ключ и проверить output после hit
\n

Кэш повторяет не проект, а функцию от входов

\n

Кэш хранит результат, полученный при определённых условиях. Его ключ должен различать изменения, которые влияют на граф, transform или output. Для типового frontend-проекта это lockfile, нормализованная конфигурация, версия Node и bundler, исходный digest, entry и параметры режима. Состав полей зависит от инструмента. Нельзя скопировать ключ webpack в Vite и считать его полным.

\n

Неполный ключ даёт опасный cache hit. Например, команда меняет alias или plugin, но имя cache namespace остаётся прежним. Bundler видит старый промежуточный результат и выпускает артефакт без нового правила. Постоянный флаг принудительной пересборки скрывает проблему, но не объясняет, что именно должно инвалидировать кэш.

\n

Слишком широкий ключ создаёт обратную проблему. Если в него попадает абсолютный путь временной директории или случайный идентификатор job, каждый запуск выглядит новым. CI теряет повторяемость. Поэтому ключ должен быть детерминированным: одинаковые значимые входы дают одинаковое значение, а изменение значимого входа меняет его.

\n

Учебная проверка сопоставимости

\n

Ниже учебный код. Он не запускает bundler и не измеряет реальный проект. Функция получает два уже записанных запуска, отбрасывает разные inputFingerprint и только затем считает разницу. Числа нужны для показа контракта, а не для заявления о production-эффекте.

\n
function compareBuildRuns({ baseline, candidate }) {\n  if (!baseline || !candidate) {\n    return { comparable: false, reason: 'нет двух запусков' };\n  }\n\n  if (baseline.inputFingerprint !== candidate.inputFingerprint) {\n    return { comparable: false, reason: 'входы сборки различаются' };\n  }\n\n  return {\n    comparable: true,\n    deltaMs: candidate.durationMs - baseline.durationMs,\n    deltaBytes: candidate.outputBytes - baseline.outputBytes,\n  };\n}\n\nconst baseline = {\n  inputFingerprint: 'src-42', durationMs: 420, outputBytes: 180000,\n};\nconst candidate = {\n  inputFingerprint: 'src-42', durationMs: 380, outputBytes: 176000,\n};\nconst changedInput = {\n  inputFingerprint: 'src-43', durationMs: 350, outputBytes: 174000,\n};\n\nconsole.log(compareBuildRuns({ baseline, candidate }));\n// { comparable: true, deltaMs: -40, deltaBytes: -4000 }\nconsole.log(compareBuildRuns({ baseline, candidate: changedInput }));\n// { comparable: false, reason: 'входы сборки различаются' }
\n

Первый вызов разрешает вычисление: учебный candidate завершился на 40 мс раньше и дал на 4000 байт меньше. Второй вызов останавливается до сравнения цифр. Более быстрое число не компенсирует другой исходный граф. В рабочем отчёте fingerprint должен быть связан с commit, lockfile, entry и версией окружения, а не с короткой строкой, которую никто не умеет восстановить.

\n

Почему одного запуска недостаточно

\n

Время зависит от cache state, нагрузки CPU, диска и фоновых процессов. Поэтому записывайте cold и warm отдельно. Для каждой серии сохраняйте несколько запусков и выбирайте заранее заданное представление: медиану для типичного времени или p95 для хвоста. Не смешивайте установку зависимостей с bundling, если вопрос касается только сборки.

\n

Сравнивайте не только duration. Запишите exit code, peak memory, число и имена chunks, output bytes, cache hit/miss и команду запуска. Если candidate быстрее, но потерял source map или собрал меньше entry, это не оптимизация. Это изменение результата.

\n

В webpack contenthash помогает увидеть, какой файл изменился после изменения содержимого. Deterministic module ids уменьшают случайные изменения имён. Эти настройки улучшают диагностику и кэширование, но не делают разные конфигурации одинаковыми. Их эффект нужно проверять на конкретном output.

\n

От общего роста bundle к конкретному input

\n

Размер bundle — только симптом. Сравните два metafile или эквивалентных отчёта сборщика. В JSON-метафайле esbuild можно найти inputs и их вклад в outputs. Отсортируйте delta по каждому input. Новый крупный модуль, выросший старый модуль и две версии одной зависимости ведут к разным действиям.

\n

Если delta появилась в библиотеке, найдите import path и проверьте tree-shaking. Если появились два пути к разным версиям пакета, проверьте lockfile и resolver. Если input не изменился, а output вырос, ищите plugin transform, target, minify и split. После исправления повторите сборку на том же fingerprint.

\n

Metafile не измеряет браузерную скорость. Для пользовательского эффекта отдельно смотрите transfer size, compression, cache и timing критического ресурса. Source map помогает связать bundle с исходным модулем, но карта может быть большой и не должна случайно попасть в production delivery.

\n

Порядок проверки

\n
  1. Запишите вопрос сравнения: время bundling, размер output или скорость критического пути. Не смешивайте эти метрики.
  2. Зафиксируйте commit, lockfile, entry points, mode, flags, target, версии Node и bundler, runner и расположение кэша.
  3. Соберите baseline и candidate с одинаковой командой. Отдельно пометьте cold и warm state, сохраните raw output и exit code.
  4. Проверьте fingerprint и состав результата. Разный fingerprint, entry, chunk или режим означает «несопоставимо», даже если число лучше.
  5. Сравните серию запусков, chunks и input delta. Для роста найдите import path, dependency version или transform до изменения кода.
  6. После изменения повторите измерение на том же входе. Затем отдельно проверьте transfer и критический пользовательский маршрут.
\n

Ограничения

\n

Учебная функция не знает, какие поля использует ваш bundler. Fingerprint не доказывает корректность, если его строит неполный скрипт. Одинаковый runtime не устраняет различия диска, CPU и виртуализации. Число запусков не исправляет несопоставимый entry.

\n

Рост output не равен росту времени выполнения в браузере. Source map и metafile описывают артефакт, но не гарантируют cache hit у пользователя. Compression, CDN, service worker и код до первого экрана требуют отдельных наблюдений. Не называйте локальную разницу production-результатом без измерения соответствующего пути.

\n

Отрицательный путь должен быть явным. Если входы различаются, функция возвращает «несопоставимо». Если output изменился, а причина не найдена, не откатывайте код по одной цифре. Если cache hit дал старый артефакт, исправьте ключ или invalidation. Не оставляйте постоянный force.

\n

Проверяемый критерий готовности

\n

Сравнение готово, если другой инженер может восстановить два запуска по commit, lockfile, команде и окружению, увидеть одинаковый fingerprint и получить те же поля отчёта. В отчёте видны cold/warm state, серия времени, chunks, input delta и ограничения метрики.

\n

Для кэша готовность дополнительно подтверждает отрицательный тест: изменение lockfile, конфигурации, runtime и одного исходного модуля меняет ключ или приводит к зафиксированному invalidation. После cache hit output соответствует тому же входу. Только тогда разницу времени можно обсуждать как свойство проверенного маршрута.

\n

Проверяемые источники

\n" + "contentHtml": "

Новая frontend-сборка закончилась за 38 секунд вместо 47. Через день CI снова показывает 47 секунд. В другом запуске candidate оказался быстрее, но собирал только production entry, а baseline — два entry и source map. Цена ошибки — неверный выбор инструмента, потерянное время на миграцию и артефакт, который нельзя сравнить с опубликованным.

\n

Тезис: время и размер имеют смысл только для одинаковой работы. Сборщик получает исходный граф, lockfile, конфигурацию, runtime, entry points и состояние кэша. Если хотя бы один существенный вход отличается, результат нужно пометить как несопоставимый, а не объявлять победителя.

\n

Сначала определить объект сравнения

\n

Слово «быстрее» описывает разные вопросы. Можно сравнивать время полного production bundling, длительность инкрементальной пересборки после одного изменения, размер файлов на диске или объём первой загрузки в браузере. У этих вопросов разные входы и разные владельцы. Один секундомер не отвечает на все четыре.

\n

Для полного bundling граница замера должна быть явной: например, от запуска команды до успешного завершения записи output. Установка зависимостей, сбор логов и загрузка артефакта в хранилище не входят в этот интервал, если команда хочет оценить именно сборщик. Для пользовательского эффекта понадобятся отдельные измерения: размер ответа после compression, сетевые задержки, порядок загрузки chunks и время выполнения в браузере.

\n
Какой вопрос задаём — такую метрику и фиксируем
ВопросМетрикаЧто сохранитьОшибка сравнения
Как долго создаётся production output?duration от старта команды до exit code 0команду, exit code, commit и состояние build cacheвключить install или upload только в один запуск
Как быстро обновляется код после правки?время incremental rebuildодин изменённый файл, число пересборок и режим watcherсравнить warm rebuild с cold full build
Что отправляется клиенту?raw bytes и transfer sizeentry, chunks, compression и заголовкиназвать размер на диске объёмом ответа
Почему вырос bundle?delta по output и inputsmetafile или эквивалентный отчёт с import pathискать причину только в общем числе байт
\n
Последовательность сравнения сборок: одинаковый вход, конфигурация, cache state, замер и проверка output
Секундомер запускается после фиксации условий. Если меняется вход или конфигурация, сравнение начинается заново.
\n

Одинаковый вход — это контракт

\n

Вход сборки — не только папка src. Для честной пары запусков зафиксируйте commit или digest исходных файлов, lockfile, entry points, режим, target, feature flags, версии Node.js и bundler, операционную среду, команду и рабочую директорию. Если plugin читает переменные окружения, шаблоны или файлы за пределами src, они тоже входят в контракт.

\n

Не следует обещать, что этот список универсален. Конкретный инструмент может учитывать дополнительные поля: конфигурацию resolver, патчи зависимостей, параметры минификатора, локальные плагины или содержимое системных каталогов. Практическое правило такое: меняем один предполагаемый вход, наблюдаем invalidation и записываем результат. Если изменение не отражается в ключе или output, значит, проверяемая модель кэша неполна.

\n

Для воспроизводимости полезен не короткий fingerprint вроде src-42, а запись, которую другой инженер может восстановить: ссылка на commit, digest lockfile, нормализованный конфиг, список entry и описание окружения. Сам fingerprint помогает связать записи, но не доказывает, что в него попали все значимые входы.

\n

Кэш и content hash решают разные задачи

\n

Кэш сборки отвечает на вопрос «можно ли повторно использовать промежуточный результат». Content hash в имени output отвечает на другой вопрос: «изменилось ли содержимое этого файла для клиента». Нельзя считать одинаковым cache hit и одинаковое имя файла. Первый относится к внутреннему маршруту сборщика, второе — к доставке артефакта.

\n

В webpack подстановка [contenthash] меняет имя output при изменении содержимого соответствующего asset. Это помогает браузеру оставить неизменившийся файл в кэше. В документации webpack отдельно показано, что runtime и module identifiers могут влиять на hashes нескольких chunks; поэтому изменение одного модуля не обязано менять только один файл. Вывод надо делать по фактическому output, а не по ожиданию.

\n

У Vite есть более узкий пример для dependency pre-bundling: файловый кэш хранится в node_modules/.vite, а повторный pre-bundling зависит, среди прочего, от lockfile, времени изменения patches, релевантных полей конфигурации и NODE_ENV. Это описание конкретного механизма Vite, а не готовая формула для webpack, esbuild или самописного кэша. Флаг --force полезен для диагностики, но постоянный force скрывает неверный ключ и убирает пользу повторного запуска.

\n

Воспроизводимый мини-эксперимент

\n

Сначала подготовьте два запуска, а не подставляйте числа в отчёт вручную. Baseline и candidate должны пройти одну и ту же команду на зафиксированной паре входов. Для каждого запуска сохраните время в миллисекундах, размер raw output, список entry и chunks, cache state, exit code и ссылку на артефакт. Код ниже только проверяет сопоставимость уже собранных записей; он не запускает bundler и не доказывает эффект в production.

\n
function compareBuildRuns({ baseline, candidate }) {\n  if (!baseline || !candidate) {\n    return { comparable: false, reason: 'нет двух запусков' };\n  }\n\n  if (baseline.inputFingerprint !== candidate.inputFingerprint) {\n    return { comparable: false, reason: 'входы сборки различаются' };\n  }\n\n  if (baseline.entryFingerprint !== candidate.entryFingerprint) {\n    return { comparable: false, reason: 'entry points различаются' };\n  }\n\n  return {\n    comparable: true,\n    deltaMs: candidate.durationMs - baseline.durationMs,\n    deltaBytes: candidate.outputBytes - baseline.outputBytes,\n  };\n}\n\nconst baseline = {\n  inputFingerprint: 'src-42', entryFingerprint: 'web-entries-2',\n  durationMs: 420, outputBytes: 180000,\n};\nconst candidate = {\n  inputFingerprint: 'src-42', entryFingerprint: 'web-entries-2',\n  durationMs: 380, outputBytes: 176000,\n};\nconst changedEntry = {\n  inputFingerprint: 'src-42', entryFingerprint: 'web-entries-1',\n  durationMs: 350, outputBytes: 174000,\n};\n\nconsole.log(compareBuildRuns({ baseline, candidate }));\n// { comparable: true, deltaMs: -40, deltaBytes: -4000 }\nconsole.log(compareBuildRuns({ baseline, candidate: changedEntry }));\n// { comparable: false, reason: 'entry points различаются' }
\n

Первый вызов разрешает вычисление: учебный candidate завершился на 40 мс раньше и дал на 4000 байт меньше. Второй вызов останавливается до сравнения цифр. Более быстрое число не компенсирует другой набор entry. В рабочем отчёте тот же принцип стоит расширить на mode, target, flags, lockfile и cache state. Поля, которые команда считает значимыми, должны присутствовать в данных, а не только в устной договорённости.

\n

Добавьте отрицательные тесты. Изменение одного исходного модуля должно менять input fingerprint или приводить к зафиксированному invalidation. Замена lockfile, plugin или target должна либо изменить ключ, либо завершить проверку явным cache miss. Если тест не способен отличить старый output от нового, он проверяет только форму отчёта, а не корректность кэша.

\n

Серия запусков вместо удачного числа

\n

Время зависит от состояния build cache, файлового кэша операционной системы, нагрузки CPU, диска, виртуализации и фоновых процессов. Поэтому разделяйте cold build cache и warm build cache. «Cold» здесь означает отсутствие повторно используемого кэша сборщика, а не стерильное состояние всей машины: страницу ОС, частоту процессора и фоновые процессы одним удалением каталога не выровнять.

\n

Снимите несколько запусков в каждом режиме и заранее выберите правило агрегации. Медиана показывает типичный результат, p95 — хвост задержек; обе метрики требуют одинакового количества наблюдений и одинаковой процедуры. Не удаляйте неудачные запуски без причины: exit code, timeout и выброс должны остаться в raw log с объяснением, иначе среднее станет красивее, но расследование потеряет контекст.

\n

Сравнивайте не только duration. Запишите peak memory, число и имена chunks, output bytes, наличие source map, cache hit/miss и фактическую команду. Candidate, который быстрее потому, что потерял source map или один entry, не оптимизировал тот же маршрут. Сначала подтвердите равенство результата, затем обсуждайте выигрыш.

\n

От общего роста bundle к конкретному input

\n

Размер bundle — симптом, а не причина. В esbuild включите metafile: JSON содержит inputs и outputs, связи импортов, размер output и вклад input в этот output. Сохраните этот файл рядом с артефактом. Текстовая визуализация удобна человеку, но автоматическую проверку лучше строить по JSON-данным, чтобы формат отчёта не стал скрытым контрактом.

\n

Сопоставьте baseline и candidate по каждому output. Новый крупный input указывает на добавленную зависимость или import. Два пути к разным версиям одной библиотеки требуют проверки lockfile и resolver. Выросший input без изменения исходника направляет расследование к transform, target, minify или plugin. После каждого изменения повторяйте сравнение на том же наборе entry и проверяйте, что функциональный output остался эквивалентным.

\n

Для webpack похожую роль выполняют stats и анализ chunks. contenthash подсказывает, какие файлы изменились, но не объясняет, какой import занял байты и почему. Для любого инструмента сначала выберите машинно читаемый отчёт, затем сделайте маленькую таблицу delta: output, input, bytes before, bytes after, import path и предполагаемое действие.

\n

Размер файла не равен пользовательской скорости

\n

Raw output — размер файла до передачи. Transfer size зависит от gzip или Brotli, заголовков, CDN и того, был ли ресурс в кэше. Время выполнения зависит от JavaScript, CPU устройства и момента, когда браузер встречает критический код. Поэтому уменьшение bundle на 4 КБ может не изменить первый экран, а дополнительный chunk может ухудшить маршрут из-за новой сетевой границы.

\n

Проверьте отдельно тот путь, ради которого меняется сборка. В браузере сохраните URL и response headers, повторите маршрут с очищенным и заполненным HTTP-кэшем, зафиксируйте compression и timing. Не переносите локальную цифру bundling в формулировку «страница стала быстрее», пока не измерен браузерный сценарий на сопоставимых условиях.

\n

Source map и metafile служат диагностике и могут не входить в production delivery. Если baseline публикует карту, а candidate нет, raw output сравнивается не с тем же результатом. Сначала выровняйте policy артефакта, а затем отдельно решите, какие файлы доступны клиенту, а какие остаются в хранилище CI.

\n

Порядок проверки в CI

\n
  1. Сформулируйте один вопрос: время bundling, incremental rebuild, размер output или пользовательский critical path. Назначьте ему одну основную метрику.
  2. Зафиксируйте commit, lockfile, entry points, mode, flags, target, версии Node.js и bundler, runner, рабочую директорию и build cache.
  3. Соберите baseline и candidate одной процедурой. Отдельно пометьте cold и warm cache state, сохраните raw log, exit code и артефакт.
  4. Проверьте input fingerprint, entry fingerprint и набор output. Разный вход, entry, режим, chunk или policy source map означает «несопоставимо».
  5. Снимите серию запусков и выберите медиану или p95 до просмотра чисел. Не скрывайте timeout и не смешивайте failed run с успешными.
  6. Для роста output сравните metafile, stats или эквивалент: найдите input, import path, версию зависимости и transform, которые объясняют delta.
  7. После изменения повторите серию на том же входе. Затем проверьте transfer size и браузерный critical path отдельным измерением.
  8. Оставьте в CI отрицательную проверку: изменение каждого значимого входа должно менять ключ или вызывать наблюдаемый cache miss, а output должен соответствовать новому входу.
\n

Ограничения применимости

\n

Эта схема не выдаёт универсальный рейтинг bundlers. Она помогает сравнить два конкретных маршрута при заданном входе и окружении. Результат нельзя переносить на другой проект, если там другие entry points, plugins, target, версии зависимостей, CPU или policy артефактов.

\n

Fingerprint не доказывает полноту сам по себе. Если его строит неполный скрипт, одинаковая строка может скрыть другой конфиг или старый linked package. Одинаковый runtime не устраняет различия диска, памяти и виртуализации. Cold build cache не означает cold browser cache.

\n

Рост output не равен росту времени выполнения в браузере, а уменьшение raw bytes не гарантирует ускорения первого экрана. Source map и metafile описывают артефакт, но не подтверждают его корректную доставку. Для вывода о production-поведении нужны отдельные данные соответствующего маршрута.

\n

Отрицательный путь должен быть явным. Если входы или entry различаются, возвращайте «несопоставимо». Если cache hit дал старый артефакт, исправьте ключ или invalidation и повторите проверку. Если причина роста не найдена, не объявляйте регрессию по одной цифре и не включайте постоянный force как замену расследованию.

\n

Проверяемый критерий готовности

\n

Сравнение готово, если другой инженер может восстановить оба запуска по commit, lockfile, команде и окружению, увидеть одинаковые input и entry fingerprints и получить тот же состав output. В отчёте видны cache state, серия времени, chunks, input delta, exit code и ограничения выбранной метрики.

\n

Для кэша готовность дополнительно подтверждает отрицательный тест: изменение lockfile, конфигурации, runtime, plugin и одного исходного модуля меняет ключ или приводит к зафиксированному invalidation. После cache hit output соответствует тому же входу. Только тогда разницу времени можно обсуждать как свойство проверенного маршрута, а не как обещание нового инструмента.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/028.json b/editorial/agent-rewrites/028.json index dab3532..5893865 100644 --- a/editorial/agent-rewrites/028.json +++ b/editorial/agent-rewrites/028.json @@ -1,7 +1,7 @@ { "index": 28, "slug": "editorial-2027-03-field-d-lessons", - "title": "D и C ABI: как остановить ошибку на границе пакета", - "excerpt": "Если D и C по-разному понимают размер, layout или код возврата, ошибка проявляется далеко от FFI-вызова. Разбираем физический контракт пакета и проверяем его до передачи в C.", - "contentHtml": "

Сбой на границе D и C часто выглядит случайным. На одной архитектуре функция возвращает неверный идентификатор. На другой процесс падает при чтении поля. В тесте с коротким пакетом всё проходит, а реальный пакет ломает декодер. Цена ошибки высока: данные уже могли попасть в доменную логику, а причина остаётся в нескольких байтах, которые две стороны интерпретируют по-разному.

Тезис простой: FFI-вызов нельзя считать началом проверки. Сначала нужно подтвердить физический контракт пакета. Он включает размер, offsets, alignment, порядок байтов, набор обязательных полей, calling convention, ownership и код возврата. Только после этого пакет можно передавать в C. Имя структуры и успешная компиляция этого не доказывают.

Что именно ломается

ABI описывает представление типов и вызовов на машинной границе. В D и C совпадение названий полей не гарантирует совпадение layout. Между двумя полями может появиться padding. Указатель занимает разный размер на разных target-платформах. Директива packing меняет offsets. Сборка с другим compiler flag создаёт другой контракт, даже если исходный header не изменился.

Порядок байтов нужно проверять отдельно. Структура из памяти не является wire-форматом. Число 0x01020304 в little-endian и big-endian занимает те же четыре байта, но читается с разным значением. Если код копирует входной буфер в структуру без явного декодирования, ошибка будет похожа на неверный размер или повреждённый id.

Есть и семантическая часть. Поле payload может быть указателем, длиной или смещением внутри буфера. Ноль может означать пустой пакет, null или успешный результат. C-функция может частично заполнить output и вернуть ошибку. Поэтому проверка размера без проверки ownership и кода возврата создаёт ложное чувство безопасности.

\"Схема
Проверка отделяет физический контракт от вызова. Несовпадение возвращает пакет на границу и останавливает опасную операцию.
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Поле имеет неверное значение только на одной платформеРазный padding, alignment или размер указателяСравнить sizeof, offsets и alignment D/C на каждом targetЗафиксировать layout, выровнять типы или сериализовать поля явно
Число меняется после чтения буфераПерепутан порядок байтовПрогнать golden bytes с 0x01020304Декодировать wire-формат явно, не копировать структуру целиком
Редкий crash после успешного вызоваНеверная длина или истёкшее владение памятьюПроверить pointer, length, lifetime и правило освобожденияСузить wrapper, скопировать данные или вернуть ошибку до C
Ошибочный пакет выглядит успешнымOutput читается до проверки кода возвратаПроверить порядок обработки return code и outputСначала переводить ошибку, потом интерпретировать output
Тесты проходят, production-пакет не читаетсяТест использует другой header, target или версию протоколаСохранить hex-пакет, compiler flags и версию ABIДобавить контрактный тест для реального target matrix

Минимальный безопасный порядок

Начните с байтов, а не с вызова. Сохраните один пакет, который воспроизводит проблему, и его ожидаемую расшифровку. Укажите длину, архитектуру, endianness и версию контракта. Без этих данных «неверное поле» остаётся описанием симптома.

Затем составьте layout table. Для каждого поля запишите тип C, тип D, offset, размер, alignment, смысл, допустимый диапазон и владельца памяти. Если поле является указателем, рядом должна стоять длина и правило освобождения. Если их нельзя указать, wrapper не готов.

Только после этого сравните C header и D-объявление. Сверьте calling convention и compiler flags. Отдельно проверьте, не добавляет ли C-код packing или условную компиляцию. Сборка одного target не подтверждает остальные.

Учебный фрагмент wrapper

Ниже приведён учебный фрагмент. Он показывает порядок проверки пустого буфера и передачи длины. Имена функции, код ошибки и типы условны. Фрагмент не доказывает корректность конкретной библиотеки и не является production-результатом.

extern(C) int decode_packet(\\n    const(ubyte)* data,\\n    size_t length,\\n    uint* version,\\n);\\n\\n// Учебный пример: контракт библиотеки нужно подтвердить по её header.\\nint decode(scope const(ubyte)[] data, out uint version) @trusted\\n{\\n    if (data.length == 0)\\n        return -1;\\n\\n    return decode_packet(data.ptr, data.length, &version);\\n}

В примере wrapper не передаёт null вместе с ненулевой длиной. Но это только одна проверка. Реальный контракт может требовать null для пустого буфера, завершающий ноль, выравнивание адреса или отдельный allocator. Поэтому правило нельзя переносить на библиотеку без чтения её header и документации.

Атрибут @trusted не означает, что C-вызов проверен компилятором. Он означает, что автор wrapper берёт на себя доказательство инвариантов. Держите такую функцию короткой. Не смешивайте в ней разбор формата, бизнес-правила и освобождение памяти. Чем шире trusted-зона, тем труднее проверить её границу.

Проверка кода возврата

Обрабатывайте результат внешней функции в фиксированном порядке: сначала код возврата, затем размер и версию output, затем семантику полей. Не используйте частично заполненную структуру после ошибки. Если C API допускает частичный output, это должно быть явно записано в контракте и покрыто отдельным тестом.

Для числовых полей нужны golden bytes. Возьмите известное значение, закодируйте его в требуемом wire-формате и сравните результат на D-стороне. Такой тест показывает, где ошибка: в байтах, offsets или выборе типа. Для указателей добавьте нулевую длину, длину ровно до границы и длину на один байт больше.

Действия по порядку

  1. Зафиксировать C header, D-объявление, compiler flags, target architecture, packing directives и calling convention.
  2. Составить таблицу layout: размер, offset, alignment, тип, значение, длина и владелец каждого поля.
  3. Сохранить golden bytes для корректного пакета, неверного endianness, обрезанной длины и неизвестной версии.
  4. Вынести FFI в маленький wrapper и поставить проверки pointer, length, lifetime и кода возврата до передачи данных доменному коду.
  5. Проверить валидный и отрицательный пути на каждой поддерживаемой архитектуре, включая границы 0, capacity и capacity+1.
  6. Сохранить в отчёте hex-пакет, размер, target, версию ABI и точную ошибку. Это связывает симптом с физическим контрактом.

Когда проверка должна остановить вызов

Остановите вызов, если размер не совпал, обязательное поле отсутствует, версия неизвестна, pointer не согласован с length или правило ownership не имеет ответа. Не пытайтесь «продолжить с тем, что удалось прочитать». На FFI-границе частичный успех часто превращается в повреждённое состояние выше по стеку.

Если layout зависит от платформы, есть два пути. Можно описать отдельные контракты и тестировать каждый target. Можно отказаться от передачи структуры и использовать явную сериализацию полей в буфер. Второй путь иногда медленнее, но уменьшает зависимость от padding и размера указателя. Выбирайте его, когда переносимость важнее нулевой копии.

Ограничения

Учебный wrapper не моделирует все правила D, C и конкретной библиотеки. Он не проверяет compiler lowering, alignment адреса, aliasing, null termination, thread safety, освобождение памяти и совместимость версий. Таблица layout не заменяет сборку маленького C helper и тест на целевом ABI. Документация языка объясняет общие правила, но не подтверждает vendor header.

Не называйте проверку успешной только потому, что код компилируется и один тест возвращает ожидаемое поле. Готовность требует повторяемого отрицательного пути. Ошибочный размер должен остановить вызов. Ошибочный порядок байтов должен быть виден в golden test. Ошибка C не должна превращаться в валидный доменный объект.

Критерий готовности

Граница готова, если для каждого target есть зафиксированные размер и layout, есть golden bytes и тесты на отрицательные случаи, wrapper проверяет pointer, length, lifetime и код возврата, а неизвестная версия или несовпадение контракта останавливает вызов. Проверка должна оставлять диагностический пакет: hex, target, версию ABI и причину отказа. Тогда следующая ошибка возвращается к конкретному байту, а не к предположению о языке.

Проверяемые источники

" + "title": "D и C ABI: как поймать ошибку до FFI-вызова", + "excerpt": "Разбираем сбой на границе D и C: layout структуры, порядок байтов, ownership и код возврата. В статье — воспроизводимый протокол проверки, пример wrapper и условия, при которых вызов нужно остановить.", + "contentHtml": "

Сбой на границе D и C редко выглядит как ошибка FFI-вызова. Сервис получает неверную длину, декодер читает поле за концом буфера, а процесс падает уже в доменной логике. На одной архитектуре тест проходит, на другой меняется значение поля. Причина часто находится не в алгоритме, а в физическом контракте: размере структуры, отступе, порядке байтов, calling convention или владении памятью.

Разберём один технический вопрос: как доказать, что пакет можно передать из D в C и безопасно обработать результат. Для этого нужны не только одинаковые объявления. Нужны снимок C-header, измерения sizeof и offsetof, проверка входных байтов, правило lifetime и отрицательные тесты. Если хотя бы одно условие неизвестно, wrapper должен вернуть ошибку до передачи данных следующему слою.

Граница состоит из двух контрактов

У FFI есть контракт вызова и контракт данных. Первый описывает имя символа, calling convention, типы аргументов и способ возврата результата. В D объявление без linkage по умолчанию использует D-соглашение, поэтому для C-функции нужна явная форма extern(C). Официальная спецификация D связывает это соглашение с C ABI компилятора на целевой платформе, но сама запись extern(C) не проверяет правильность прототипа.

Второй контракт описывает байты. Для структуры нужно знать порядок полей, padding, alignment и итоговый размер. Для указателя нужны адрес, длина и lifetime. Для числа в буфере нужен byte order. Для результата нужны код возврата и правило, какие поля output разрешено читать при ошибке. Имя PacketHeader не заменяет ни одного из этих фактов.

\"Проверка
Безопасная граница сначала принимает решение о допустимости пакета, затем вызывает C и только после успешного кода возврата создаёт доменный результат.

Сначала сравните layout, а не ощущения

Обычный C-header может выглядеть так:

#include <stddef.h>\n#include <stdint.h>\n\nstruct packet_header {\n    uint16_t version;\n    uint16_t flags;\n    uint32_t payload_len;\n};\n\n_Static_assert(sizeof(struct packet_header) == 8, \"packet_header size\");\n_Static_assert(offsetof(struct packet_header, payload_len) == 4,\n               \"packet_header payload offset\");

Для этого конкретного объявления мы фиксируем два свойства: размер структуры равен 8 байтам, а payload_len начинается с offset 4. Это не универсальная цифра для любой структуры. Добавление указателя, изменение packing, другого поля или compiler option меняет доказательство. Поэтому значения надо получать из того header и тех flags, с которыми собирается библиотека.

На стороне D сопоставление должно быть явным:

extern(C) struct PacketHeader\n{\n    ushort version;\n    ushort flags;\n    uint payload_len;\n}\n\nstatic assert(PacketHeader.sizeof == 8);\nstatic assert(PacketHeader.payload_len.offsetof == 4);\n\nextern(C) int decode_packet(\n    const(ubyte)* data,\n    size_t length,\n    PacketHeader* header,\n);

Структура с extern(C) использует C layout, но это не освобождает от проверки. Если C собирается с #pragma pack или с опцией изменения alignment, соответствующее правило нужно отразить в D и подтвердить измерением. Если C использует bit field, его нельзя механически переписать как обычное поле D: спецификация интерфейса требует отдельной модели со сдвигами и масками.

Контракт FFI: наблюдаемый факт, проверка и безопасное решение
Часть контрактаЧто может разойтисьКак проверитьЧто делать при расхождении
Layout структурыРазмер, padding, offset, alignmentСобрать C helper с sizeof, offsetof, _Alignof и сравнить со static assert DИсправить типы и align или передавать поля явно
Wire-байтыEndianness, длина, версия, reserved-поляПрогнать golden bytes и проверить границы до чтения каждого поляДекодировать буфер явно; не копировать его в структуру вслепую
ВызовLinkage, прототип, callback и calling conventionСверить header, D-декларацию, символ и ABI targetОставить FFI в маленьком wrapper и не экспортировать D-типы случайно
ПамятьКто выделяет, кто освобождает и сколько живут данныеЗаписать allocator, owner, length и момент освобожденияСкопировать данные на границе или вызвать парный deallocator библиотеки
РезультатЧастично заполненный output и неоднозначный код ошибкиПроверить return code до чтения output, включая отрицательные тестыВернуть typed error и запретить создание доменного объекта

Структура в памяти не равна wire-формату

Самая опасная подмена — считать, что массив байтов можно всегда привести к указателю на D-структуру. Даже при совпадении C и D layout это доказывает только представление в памяти на конкретном target. Wire-формат может задать little-endian, фиксированные размеры, checksum и padding, который нельзя читать как значение. На big-endian target те же четыре байта будут интерпретированы иначе.

Разделяйте два случая. Если C API принимает native struct, проверяйте ABI и передавайте указатель на объект с согласованным alignment. Если API принимает wire-пакет, сначала проверьте длину и версию, затем извлеките поля с явным byte order и диапазонами. Нельзя использовать успешный тест на x86 как доказательство переносимости сетевого или файлового формата.

Для golden bytes возьмите пакет с известной расшифровкой, например версией 1, flags 2 и длиной payload 16. Зафиксируйте массив байтов, ожидаемые значения, endian и версию протокола. Добавьте обрезанный пакет и пакет с длиной, превышающей остаток входа. Тест должен показывать не только результат, но и точку отказа: header, поле длины или checksum.

Wrapper должен владеть порядком проверки

Wrapper — это узкая граница, в которой собраны инварианты вызова. Он не должен превращаться в место для бизнес-логики. До C проверяются минимум ненулевой размер, допустимый адрес, верхняя граница длины и согласованность версии. После C сначала читается код возврата. Только при успехе проверяются output и семантические диапазоны.

int decode(scope const(ubyte)[] bytes, out PacketHeader header) @trusted\n{\n    header = PacketHeader.init;\n\n    if (bytes.length < PacketHeader.sizeof)\n        return -1;\n\n    auto rc = decode_packet(bytes.ptr, bytes.length, &header);\n    if (rc != 0)\n        return rc;\n\n    if (header.version != 1)\n        return -2;\n\n    if (header.payload_len > bytes.length - PacketHeader.sizeof)\n        return -3;\n\n    return 0;\n}

Фрагмент показывает порядок, а не готовую библиотеку. Он предполагает, что C-функция принимает указатель на байты, длину и заполняет native PacketHeader. Коды -1, -2 и -3 условны. В реальном проекте их нужно заменить типизированными ошибками конкретного API и проверить, может ли C писать в output при ненулевом коде возврата.

Атрибут @trusted здесь обозначает место, где автор D ручается за небезопасную операцию. Он не исправляет неверный prototype и не проверяет lifetime автоматически. Держите trusted-код коротким, не возвращайте наружу чужой указатель без правила владения и не смешивайте вызов C с созданием долгоживущего объекта.

Ownership и lifetime нельзя угадывать

Буфер, созданный в D, не становится автоматически безопасным для C на любой срок. Если C сохраняет указатель после возврата из wrapper, временный массив или память, которую может переместить сборщик, нельзя считать достаточной гарантией. В контракте должна быть одна из явных моделей: C читает данные только во время вызова; wrapper делает копию в памяти, которую C ожидает; либо C возвращает данные со своим deallocator, который вызывается парной функцией.

Нельзя освобождать память функцией, принадлежащей другому allocator. Вызов free из C runtime не является универсальной заменой функции освобождения, предоставленной библиотекой. Аналогично, GC.free относится к памяти, полученной от D GC, а не к произвольному адресу из C. Если библиотека документирует callback для освобождения или отдельный destroy_packet, этот шаг должен быть частью того же контракта.

Особое ограничение появляется у потоков. Документация D предупреждает, что GC не знает о потоках, созданных напрямую через OS/C runtime, если они не подключены к D runtime. Нельзя хранить там ссылки на GC-память без отдельной стратегии регистрации или копирования. Для FFI это практическое правило: если C вызывает callback из собственного потока, заранее определите, какие данные callback может видеть и кто удерживает их lifetime.

Отрицательные тесты доказывают границу

Положительный тест показывает, что один пакет однажды прочитан. Он не показывает, что wrapper остановит опасный вход. Нужна матрица отрицательных случаев: пустой буфер, длина меньше header, длина ровно header, payload на границе, payload на один байт больше, неизвестная версия, повреждённый порядок байтов, null output и код ошибки от C.

Для каждого случая фиксируйте ожидаемый эффект. Обрезанный пакет не должен приводить к чтению за границей. Неизвестная версия не должна превращаться в объект версии 1. Код ошибки не должен оставлять старое содержимое output видимым вызывающему коду. Если C может вернуть частичный output, wrapper обнуляет или закрывает его до передачи результата дальше.

  1. Сохранить точный C-header, версию библиотеки, target architecture, compiler flags, packing directives и calling convention.
  2. Собрать маленький C helper, который печатает sizeof, offsetof и alignment каждого поля; сопоставить эти значения с D static assert.
  3. Разделить native struct и wire-формат. Для wire-формата зафиксировать golden bytes, endian, версию, длину и допустимые диапазоны.
  4. Проверить ownership: allocator, deallocator, момент освобождения и возможность C сохранить указатель после вызова.
  5. Оставить один короткий wrapper, который проверяет границы до FFI, код возврата до output и версию до доменной логики.
  6. Прогнать положительные и отрицательные случаи на каждом поддерживаемом target, а в отчёт записать байты, ABI-метаданные и точную причину отказа.

Когда вызов нужно остановить

Остановите вызов, если размер или offset не совпал с C helper, неизвестен packing, не определён byte order, длина не согласована с указателем, lifetime заканчивается раньше C-операции или deallocator не назван. Не пытайтесь продолжить с «похожим» layout. Несколько совпавших полей не доказывают совместимость всей структуры.

Иногда безопаснее отказаться от передачи структуры. Явная сериализация полей в буфер добавляет операции копирования, зато убирает зависимость от padding, указателей и target alignment. Передача native struct оправдана, когда API стабилен, ABI закреплён и для него есть контрактные тесты. Выбор зависит от стоимости копирования и стоимости несовместимого обновления, а не от желания сделать wrapper короче.

Ограничения применимости

Пример не является готовой привязкой к конкретной библиотеке. В нём не описаны C++ name mangling, variadic functions, callbacks с несколькими calling convention, bit fields, packed structures, thread safety, checksum и динамическая загрузка символов. Для этих случаев нужны отдельные правила исходного API и тесты на реальном target.

Спецификация D описывает общие правила ABI, но не знает compiler flags, vendor header, версию сторонней библиотеки и её ownership. Даже совпавшие измерения на одной машине не доказывают совместимость всех платформ. Если библиотека обновляет header, повторите helper и пересмотрите golden bytes. Не переносите пример в production, пока не определены конкретные коды ошибок и deallocator.

Критерий готовности

Граница готова, когда C и D собраны с согласованными настройками, размеры и offsets проверяются автоматически, wire-байты отделены от native layout, ownership записан в контракте, а wrapper имеет отрицательные тесты. При неизвестной версии, неверной длине, ошибке C или неясном lifetime он возвращает отказ, а не частичный объект. Такой процесс связывает падение с измеримым нарушением контракта и оставляет следующий шаг для диагностики.

Проверяемые источники

" }