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-параметры. Путь должен быть полезен для маршрутизации, но не обязан содержать персональные или платёжные данные.
Ниже — локальный 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 может показать, что удалённая сторона отвечает, но он отключает важную проверку и не исправляет конфигурацию.
Для HTTPS проверяйте не «SSL вообще», а три независимых условия. Первое — сертификат выдан для целевого hostname: имя должно совпасть с одним из значений Subject Alternative Name. Второе — цепочка ведёт к центру сертификации, которому доверяет клиент. Третье — текущая дата попадает в срок действия сертификата. Неправильный SAN, неизвестный issuer и истёкший срок требуют разных исправлений.
Подключение к IP вместо имени часто ломает проверку имени, даже если IP ведёт к нужному серверу. Заголовок Host не исправляет это задним числом: TLS завершается раньше, чем клиент отправляет HTTP-заголовки. Через proxy добавляется ещё одна граница. Имя proxy и имя origin нужно проверять отдельно, иначе ошибку промежуточного соединения можно принять за ошибку конечного сервиса.
Кэш также меняет смысл ответа. Age может показать возраст объекта, Cache-Control — правила хранения, ETag — валидатор представления, а Via — участие intermediary. Ни одно поле само по себе не доказывает, кто создал тело. Сопоставляйте заголовки с логом доверенного входа и, если возможно, с ответом origin.
Age, Cache-Control, ETag, Via и времени ответа. Сравните cold и повторный запрос.Эта модель не заменяет трассировку сети. Она не показывает потери пакетов, особенности 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 находится в логе доверенного входа. Если ответ может прийти из кэша, запись содержит признаки его участия или честно отмечает, что источник не установлен. Критерий выполнен только тогда, когда команда может повторить проверку и получить тот же вывод о границе отказа.
В браузере одна красная страница, в логе клиента другая строка, а инженер уже меняет таймаут или отключает проверку сертификата. Такой ремонт часто начинается раньше факта: запрос мог остановиться на DNS, TCP или TLS и не дойти до HTTP. В обратной ситуации сертификат проверен, но приложение или proxy вернули 404, и поиски проблемы в TLS только уводят в сторону.
У запроса есть наблюдаемая последовательность: имя превращается в адрес, TCP принимает соединение, TLS проверяет защищённый канал и identity сервера, затем HTTP передаёт метод, цель и поля. Диагностика становится воспроизводимой, если на каждой границе задать свой вопрос, записать факт и не переносить вывод на следующий уровень. Эта схема не обещает мгновенно назвать виновника; она сужает место поиска и подсказывает следующий безопасный тест.
Начните с 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 и время | Не объединять ответы разных попыток в одну причину |
Для HTTPS недостаточно сказать «сертификат действующий». Клиент строит reference identity из имени или IP в URI и сопоставляет её с именами в сертификате. Затем он проверяет цепочку к доверенному anchor и период действия. Эти проверки отвечают на разные вопросы: сертификат может быть выдан правильным центром, но не для этого имени; имя может совпасть, но цепочка не доверена локальному клиенту; оба условия могут пройти, а срок — закончиться.
Подключение к IP вместо имени часто меняет проверяемую identity. Если сертификат выпущен для DNS-имени, сам факт, что IP указывает на тот же сервер, не делает IP подходящим именем. HTTP-поле Host не исправляет ошибку задним числом: оно появляется на уровне HTTP, после установления TLS. В HTTP/2 и HTTP/3 роль имени и порта в запросе обычно представляет :authority; при диагностике учитывайте фактическую версию протокола.
Флаг --insecure или его аналог может быть полезен в коротком разрешённом эксперименте: он показывает, что удалённая сторона способна отправить данные без обычной проверки identity. Но такой ответ не доказывает безопасность канала и не является исправлением. Результат эксперимента нужно явно пометить как полученный с отключённой проверкой и повторить обычным клиентом после исправления доверия.
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-параметры. Если доступен только внешний ответ, напишите «источник не установлен». Такая формулировка полезнее уверенного, но неподтверждённого назначения владельца.
Пример запускается локально и намеренно не моделирует 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. Ценность эксперимента в том, что изменение одного условия меняет наблюдаемый результат.
Via, Age, ETag, Cache-Control и длительность, если подозреваете intermediary или cache. Сравните холодный и повторный запрос.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-статус. Если логи не связывают ответ с доверенным компонентом, честный результат проверки — «источник не установлен», а не имя предполагаемого сервиса.
https, проверка identity, Host и :authority, посредники, кэш, статусы и свойства методов.Браузер показывает ошибку, а команда сразу меняет timeout, маршрут или сертификат. Через час выясняется, что запрос вообще не дошёл до приложения. В другом случае приложение вернуло 404, но инженер ищет проблему в TLS. Цена такой ошибки — лишний rollout, потерянное время и риск сломать рабочий путь, пытаясь исправить не тот слой.
Тезис: сначала нужно определить первый подтверждённый этап отказа. До HTTP находятся DNS, TCP и TLS. После успешного TLS появляются метод, URI, заголовки и статус HTTP. Если перепутать границу, проверка не отвечает на вопрос и создаёт ложное ощущение прогресса.
\nУ HTTPS-запроса есть последовательность. Клиент разрешает имя, открывает TCP-соединение, проводит TLS-рукопожатие, отправляет HTTP-сообщение и читает ответ. Посредник может завершить запрос на любом шаге. Поэтому текст ошибки важнее цвета страницы: ERR_TLS_CERT_ALTNAME_INVALID ещё не является HTTP-ответом, а 404 означает, что HTTP-обмен уже состоялся.
Уровень ошибки задаёт набор допустимых проверок. Заголовок Host не исправит сертификат, если TLS-клиент не доверяет имени. Увеличение timeout не создаст отсутствующий маршрут. Повтор POST не становится безопасным только потому, что сервер вернул 503.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
ERR_TLS_CERT_ALTNAME_INVALID | Имя в URL не совпадает с SAN | Сверить hostname, SAN и адрес | Исправить имя, сертификат или vhost |
404 Not Found | URI или метод не попал в маршрут | Проверить известный endpoint и лог маршрута | Исправить путь, метод или route config |
503 Service Unavailable | Обработчик или зависимость недоступны | Проверить Retry-After и upstream-логи | Устранить недоступность; retry ограничить контрактом |
| Timeout без статуса | Неизвестен этап задержки | Разделить connect и read timeout | Найти этап, затем менять лимит |
404 — это статус HTTP-ответа. Он не доказывает, что ответ сформировало origin-приложение: его мог вернуть reverse proxy или другой посредник. Сохраняйте метод, нормализованный путь, статус, request ID и безопасный набор заголовков. Полные Cookie, Authorization и чувствительные query-параметры в запись не нужны.
503 сообщает о временной невозможности обработать запрос. Заголовок Retry-After может дать ориентир, но не гарантирует безопасность повтора. Для чтения задайте ограниченный retry с общим deadline. Для записи сначала проверьте идемпотентность и правило дедупликации. Иначе потерянный ответ после успешной записи превратится в дубль.
Отрицательный путь обязателен. Если метод меняет деньги, заказ, подписку или другой ресурс, а сервер не принимает ключ операции и не описывает повтор, клиент должен остановиться. Автоматический retry в таком месте скрывает неопределённый результат.
\nTLS защищает канал и связывает его с именем узла. Клиент сравнивает имя назначения с именами в Subject Alternative Name сертификата. Если URL содержит старый alias, IP-адрес или имя другого виртуального хоста, проверка может завершиться до отправки HTTP-запроса. Тогда у приложения нет статуса и тела для анализа.
\nПроверяйте три свойства: имя, цепочку доверия и срок действия. Общий текст certificate error скрывает различия между ними. Не подменяйте проверку флагом --insecure. Он может показать, что endpoint отвечает без валидации сертификата, но не исправляет доверие и не доказывает безопасность соединения.
Пример ниже учебный. Он запускает только локальный HTTP-сервер, не обращается к внешней сети и не моделирует TLS. Его задача — показать разницу между известным маршрутом и отсутствующим URI. В production этот код не заменяет proxy, сертификат, health-check или журнал приложения.
\nimport { 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. Мы проверяем и статус, и тело. Один только текст страницы не показывает, какой контракт нарушен.
Отправьте POST /health и получите 404: маршрут принимает только GET. Это не проблема TLS и не причина увеличивать timeout. Сначала решите, должен ли такой метод существовать в контракте.
Allow, Location, Retry-After, тип тела и идентификатор ответа.HTTP-статус не раскрывает автоматически путь внутри прокси или состояние upstream. Заголовок Server не является доказательством источника ответа. DNS-ответ не доказывает наличие нужного маршрута. Для вывода о реальной инфраструктуре нужны согласованные логи, сетевые данные и разрешённый доступ.
Локальный пример не проверяет CDN, балансировщик, корпоративный proxy, реальную цепочку сертификатов, рестарт процесса или запись в базе. Он показывает только границу между URI, методом и ответом локального HTTP-сервера. Не переносите его упрощённое поведение в production без явного контракта и тестов.
\nЕсли TLS не проходит, не ищите заголовки приложения. Если TLS проходит, но статус равен 404, ищите маршрут и метод. Если пришёл 503, определяйте доступность обработчика и безопасность повтора. Если нет статуса, сначала найдите этап timeout.
Диагностика готова, если для одного hostname можно воспроизвести успешный HTTPS-запрос, ошибку проверки имени сертификата, известный 404 и временный 503. Для каждого случая запись содержит этап, метод, путь, статус или TLS-ошибку, безопасный request ID и одно действие. Для записи с неопределённым результатом retry остановлен или защищён идемпотентным контрактом.
Проверяемый результат — не «ошибка исчезла», а совпадение наблюдения с уровнем проверки. Повторите запрос после изменения одного условия. Если причина и новый результат не различаются по логам или команде, ремонт ещё не доказан.
\nБраузер показывает «сайт недоступен», а инженер сразу меняет timeout, маршрут или сертификат. Через час выясняется, что запрос остановился на другом слое: DNS отдал не тот адрес, TLS отверг имя, reverse proxy вернул 404 или upstream не успел ответить. Исправление симптома в таком месте добавляет rollout и не приближает к причине.
Разберём один вопрос: как по наблюдаемому запросу найти первый подтверждённый этап отказа. До HTTP находятся разрешение имени, TCP и TLS. После успешного TLS клиент отправляет метод, целевой URI и поля HTTP. Статус 404 уже доказывает, что некоторый участник обмена сформировал HTTP-ответ; ошибка проверки сертификата — нет.
Результат диагностики — не формула «для 503 всегда повторяем». Нужна запись, в которой видны вход, первый ответивший слой, проверка и действие. Такой формат помогает отделить исправление маршрута от изменения сетевых лимитов и отдельно решить вопрос безопасности повтора.
Начните с одного конкретного запроса: 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 Unavailable | HTTP-участник сообщил о недоступности | upstream, Retry-After, deadline и request ID | Что повтор безопасен для записи |
| Нет статуса до timeout | Получатель не отдал HTTP-ответ вовремя | connect/read timeout и трасса по прокси | Что причина обязательно в приложении |
Таблица задаёт область поиска, а не готовый диагноз. 404 может создать edge, балансировщик или приложение. Заголовок Server и внешний вид страницы не дают надёжного ответа о владельце. Поэтому к статусу добавляйте путь прохождения запроса: hostname, порт, request ID и доступные логи.
Упрощённая последовательность выглядит так: клиент разрешает имя, устанавливает TCP, проводит TLS-рукопожатие и только затем передаёт HTTP-сообщение. TLS 1.3 описывает защищённое рукопожатие и параметры канала, а HTTP определяет сообщение, метод, URI, поля и статус. Это разные контракты с разными наблюдениями.
\nПроверка сертификата сопоставляет имя назначения с идентичностью сервера. Если URL содержит старый alias, IP вместо DNS-имени или имя другого виртуального хоста, клиент может остановиться до отправки запроса. В этом случае у приложения нет достоверного статуса, тела и заголовков, которые можно было бы исправлять.
\nФлаг --insecure годится только для изолированного эксперимента, когда нужно увидеть, отвечает ли endpoint при отключённой проверке. Он не исправляет цепочку доверия, hostname или конфигурацию сервера. Результат такого эксперимента нельзя принимать за доказательство безопасного рабочего соединения.
404 означает, что отвечающий сервер не нашёл текущего представления целевого ресурса. Причиной может быть опечатка в пути, неправильный Host, отсутствие маршрута на proxy или удалённый endpoint. Сравните ошибочный запрос с известным GET /health, а затем проверьте метод и нормализацию URI.
405 Method Not Allowed — другой сигнал: ресурс распознан, но данный метод для него не разрешён. Поле Allow показывает поддерживаемые методы, если сервер его сформировал. Подмена POST на GET не является исправлением: она меняет семантику операции и может скрыть ошибку клиента.
503 сообщает о временной неспособности обработать запрос. Это может быть перегруженный или недоступный upstream, окно обслуживания либо решение посредника. Retry-After задаёт время или дату, после которых клиенту предлагается повторить запрос, но сам по себе не обещает, что операция безопасна или завершилась без побочного эффекта.
504 означает, что gateway или proxy не получил своевременный ответ от upstream. Увеличение timeout на браузере не доказывает, что upstream стал быстрее. Сопоставьте deadline клиента, proxy и обработчика, а также отметьте, дошёл ли запрос до приложения и мог ли обработчик завершить запись до обрыва ответа.
Ниже — учебный HTTP-сервер на localhost. Он намеренно не моделирует DNS, TLS, proxy и базу данных. Его задача — отделить корректный маршрут от неизвестного URI и показать, что метод является частью контракта. Сохраните код в check-http.mjs и запустите в Node.js с поддержкой глобального fetch.
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, но код не запускает автоматический повтор.
Измените в первом цикле GET на отдельный запрос POST /health. Ответ будет 404, потому что простая модель сервера различает метод и URI. Это маленькая, но полезная проверка: одинаковый путь не означает одинаковый контракт.
Для 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\ndig показывает ответ выбранного resolver-а, но не маршрут внутри сети. curl --verbose помогает увидеть этап соединения и HTTP-заголовки; его вывод всё равно нужно сопоставить с логами. openssl s_client показывает детали рукопожатия, но набор флагов и текст результата зависят от версии OpenSSL. Ни одна команда не доказывает состояние бизнес-операции.
Если имя не разрешается, остановитесь на DNS и проверьте resolver. Если TCP соединён, но TLS не завершён, сравните hostname в URL с SAN и цепочкой доверия. Если TLS завершён и есть статус, переходите к HTTP-маршруту. Если статус 503 или 504, добавьте в расследование upstream и границы времени, а не только клиентский timeout.
Повтор после сетевого обрыва оставляет неопределённый результат: сервер мог принять запрос, а клиент не успел получить ответ. Для чтения такой повтор часто допустим по смыслу метода, но лимит, deadline и нагрузка всё равно остаются проектными решениями. Для записи одного статуса 503 недостаточно.
HTTP определяет идемпотентность метода как свойство повторного применения к серверу с тем же эффектом, что и однократное применение, если исходный запрос уже был выполнен. Это не означает, что каждый конкретный endpoint безопасен автоматически. POST по умолчанию не получает такой гарантии от протокола. Сервис может добавить ключ идемпотентности и дедупликацию, но это уже его прикладной контракт.
| Ситуация | Риск | Защита |
|---|---|---|
GET вернул 503 с коротким deadline | Лишняя нагрузка и каскад повторов | ограничить число попыток, общий deadline и backoff |
| POST оборвался без ответа | Заказ мог быть создан | ключ операции, дедупликация и проверка результата |
Ответ содержит Retry-After | Задержка интерпретирована неверно | разобрать секунды или дату и не превышать общий deadline |
| 504 от gateway | upstream мог завершить работу после обрыва | сопоставить логи gateway и upstream до повтора |
Без контракта идемпотентности безопасное действие после неопределённого POST — не повторять вслепую. Сначала запросите состояние операции по отдельному идентификатору или передайте результат владельцу сервиса. Клиентская библиотека не может восстановить неизвестный побочный эффект по одному коду ответа.
Allow, Retry-After, тип тела и логи proxy с логами origin.503 и 504 зафиксируйте upstream, таймаут каждого слоя и факт выполнения операции. Один клиентский замер не разделяет эти причины.Эта схема описывает обычный HTTPS-путь с доступным наблюдением клиента. Она не заменяет диагностику mTLS, QUIC/HTTP/3, service mesh, корпоративного proxy, CDN, нестандартного DNS, балансировки по региону или логики авторизации. В таких системах добавьте соответствующие границы и владельцев, сохранив порядок «первый подтверждённый слой → следующая проверка».
\nСтатус, полученный от proxy, не доказывает, что origin получил запрос. Запись в access log не доказывает завершение транзакции в базе. DNS-ответ не доказывает наличие маршрута. Локальный сервер не моделирует сертификаты, распределённые часы, реальную очередь или повторную доставку. Эти выводы требуют собственных трасс, логов и тестового стенда.
\nКоманды могут раскрыть имена хостов и заголовки, поэтому запускайте их только в разрешённом окружении. Не используйте чужие адреса, не отправляйте production-записи в учебный endpoint и не добавляйте Authorization, Cookie или персональные параметры в публичный отчёт.
Расследование можно закрыть, когда для одного запроса записаны входы, первый подтверждённый слой, доказательство и действие. Для ошибки сертификата это детали имени и цепочки; для 404 — метод, URI и владелец ответа; для 503/504 — upstream, временные границы и решение по retry. После изменения воспроизведите только этот сценарий и проверьте, что наблюдение изменилось ожидаемым образом.
Если остаётся только фраза «ошибка исчезла», проверка не закончена. Нужен повторяемый запрос, сопоставленный с логами и контрактом операции. Тогда следующий инженер сможет отличить исправленный маршрут от временно здорового upstream и не вернётся к случайному изменению timeout.
\nAllow и Retry-After, статусов 404, 405, 503 и 504, а также идемпотентности. Стандарт не определяет конфигурацию конкретного proxy или прикладного endpoint.После изменения сборки JavaScript-файл стал больше. В отчёте видна общая delta, но не видно, какой импорт её создал. Команда удаляет самую крупную библиотеку по названию. Так легко сломать функцию и не убрать причину: размер мог вырасти из-за нового entry point, дубликата зависимости, отключённого tree-shaking или source map, попавшей в артефакт. Цена ошибки — регресс поведения, лишний сетевой трафик и несколько итераций вслепую.
Тезис статьи простой: рост bundle нужно свести к изменению между двумя наборами входов. Сначала сравнивают metafile или другой отчёт состава сборки. Потом находят input с ненулевой delta, проверяют его связь с chunk и только затем меняют импорт, конфигурацию или delivery. Общий размер файла остаётся симптомом, а не диагнозом.
Сборщик читает entry point и рекурсивно разрешает импорты. Он преобразует модули, удаляет недостижимый код, объединяет часть графа и записывает output. Metafile описывает этот путь в структурированном виде. У esbuild в нём есть разделы inputs и outputs; у конкретного инструмента формат может отличаться, но граница анализа остаётся той же.
Input — файл или модуль, который участвовал в сборке. Его bytes показывают вклад исходного входа в анализ. Output — созданный артефакт. Его размер зависит от преобразования, минификации, разделения chunks и повторного использования общего кода. Передача по сети зависит ещё от gzip или Brotli, заголовков и кэша браузера. Поэтому число в metafile нельзя называть размером загрузки без отдельной проверки.
Source map решает другую задачу. Она связывает преобразованный код с исходными файлами для отладки. Карта может быть большой. Её наличие в каталоге сборки не означает, что её нужно отдавать каждому пользователю. Проверяйте output, HTTP-заголовок и политику публикации отдельно.
Ниже — учебная функция для минимального 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 не изменился, ищите причину в сжатии, заголовках, кэше или измерении браузера.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Появился новый большой 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 map | Debug 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-ответа, браузер и инструменты разработчика смогут запросить её. Это удобно для отладки, но карта может раскрывать исходники и увеличивать доступный объём артефактов. Решение зависит от политики проекта и среды.
inputs, затем отсортировать изменения по абсолютной delta.Metafile показывает модель сборщика, а не полную стоимость для пользователя. Разные bundler описывают input и output по-разному. Сжатие, HTTP-кэш, CDN, service worker и скорость CPU находятся за пределами одного JSON. Source map может быть создана, но не отдана клиенту. Поэтому сравнение состава нельзя выдавать за измерение производительности страницы.
Нельзя считать исправлением постоянное отключение source map, удаление зависимости по имени или включение агрессивного split без проверки поведения. Нельзя сравнивать отчёты после разных изменений в lockfile и конфигурации. Если входы различаются, сначала восстановите сопоставимые условия; иначе отрицательный результат анализа честнее случайного вывода.
Готовность подтверждается четырьмя артефактами: отчёты baseline и candidate с условиями запуска, diff с конкретным input и output, проверка изменённого пользовательского пути и повторная сборка после действия. Другой инженер должен увидеть, что изменилось, воспроизвести проверку и понять, почему выбранное действие относится к причине. Если причина не найдена, готовым результатом считается зафиксированная граница: состав bundle стабилен, следующий тест идёт на уровне compression, HTTP или браузера.
После изменения frontend-сборки команда видит в отчёте новый большой файл и сразу предлагает удалить самую заметную библиотеку. Это симптом, а не диагноз: такой ход часто промахивается мимо причины. Рост мог появиться из-за нового entry point (точки входа), дубликата зависимости, другой ветки разрешения пакета, изменившегося tree-shaking или публикации source map. В результате можно сломать рабочий экран, а initial-загрузка останется прежней.
Разберём воспроизводимый сценарий: baseline и candidate собраны из разных состояний проекта, а candidate стал тяжелее. Цель диагностики — не найти «виновный пакет», а показать цепочку input → import → output/chunk → HTTP-ответ. После этого решение уже предметное: изменить импорт, выровнять зависимость, вернуть настройку сборщика или проверить доставку. Если цепочка не сходится, честный результат — не менять код наугад.
Сборщик читает 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-метаданные и явно фиксируйте версию сборщика и схему полей.
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 будет ошибочно принят за два.
Ниже — самостоятельный пример без зависимости от конкретного проекта. Функция берёт два объекта с полем 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.
В каждом output esbuild хранит собственное поле inputs. Вложенное bytesInOutput показывает вклад входного файла в этот output. Там же могут быть imports, exports и entryPoint. Эта связь отвечает на главный вопрос: новый input увеличил initial-файл, lazy chunk, общий chunk или вообще не тот артефакт, который измеряет команда.
Проверяйте путь по такой последовательности:
inputs найдите новые и выросшие пути, затем отсортируйте их по delta.bytesInOutput, имя output и его entryPoint, если поле есть.outputs[*].imports восстановите связи между output и отделите initial-файл от импортируемого chunk.Полезно хранить рядом с 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 | Вернуть совместимую настройку и пересобрать |
| Одна библиотека имеет два пути | Две версии или разные условия resolver | Lockfile, реальные пути и package exports | Свести версии только после проверки совместимости |
| Metafile прежний, HTTP-ответ тяжелее | Изменились minify, compression или headers | Raw, gzip/Brotli, response headers и cache | Исправлять delivery, не импорт |
| Выросла карта | Source map создаётся или публикуется иначе | Каталог, SourceMap и сетевой запрос | Разделить политику debug-артефактов и production |
| Diff нестабилен между машинами | Разные пути, runtime или lockfile | Fingerprint окружения и относительность путей | Вернуть одинаковые условия до сравнения |
Рассмотрим экран поиска, где график нужен только после нажатия кнопки. Если сборщик сохраняет границу 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, кэшу и измерению браузерного пути. Именно эта граница не даёт исправить не тот слой.
inputs/outputs, поля bytes, bytesInOutput, связи импортов и оговорка о путях.import().Сборка внезапно стала медленной, хотя в 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 в другой без проверки его семантики.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Каждый запуск — miss | Ключ включает время или нестабильный путь | Сравнить ключ двух запусков без изменения исходников | Нормализовать входы и убрать шумные поля |
| Hit после изменения lockfile | Lockfile не входит в ключ | Изменить только 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, а затем добавить диагностический вывод. Принудительная инвалидизация скрывает дефект ключа и вернёт его после следующего изменения.
После 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 переживает запуски. У них разная стоимость, область действия и диагностика.
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, сначала добавьте наблюдаемость. Только после этого сравнивайте секунды и решайте, оправдывает ли ускорение сложность хранения.
Сборка внезапно стала медленной, хотя 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. Состав ключа — часть контракта, а не косметическая оптимизация.
Хороший ключ не обязан быть длинным, но обязан быть объяснимым. Для каждого поля можно ответить, какую часть результата оно меняет, как его нормализовать и какой тест покажет пропуск. Полезно заранее отделить вход вычисления от места хранения: directory и namespace могут влиять на безопасность обмена записями, но не всегда меняют сам bundle.
| Слой | Что фиксировать | Как проверить | Риск пропуска |
|---|---|---|---|
| Dependency optimizer | lockfile, 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 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 параметр 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 каждого публикуемого файла. В отчёте не должны появиться секреты, токены и исходники, которые не нужны для диагностики.
Начните с повторения симптома на зафиксированном 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 похож. При сомнении безопаснее отклонить восстановление, чем опубликовать непроверенный артефакт.
contenthash — полезная проверка, но не замена digest-сверке.Эта модель не выбирает лучший 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, она не должна попасть в публикацию. Если на одном слое всё корректно, а пользователь видит старый ресурс, расследование продолжается на следующем слое. Такая граница экономит время: команда меняет конкретный контракт, а не добавляет бесконечные повторные сборки.
node_modules/.vite, lockfile, patches, relevant config, NODE_ENV, linked dependencies и принудительную оптимизацию.contenthash в именах output-файлов и связь этого механизма с кэшированием браузером.Новая frontend-сборка закончилась за 38 секунд вместо 47. Через день CI снова показывает 47 секунд. В другом запуске candidate оказался быстрее, но собирал только production entry, а baseline — два entry и source map. Цена ошибки — неверный выбор инструмента, потерянное время на миграцию и артефакт, который нельзя сравнить с опубликованным.
\nТезис: время и размер имеют смысл только для одинаковой работы. Сборщик получает исходный граф, lockfile, конфигурацию, runtime, entry points и состояние кэша. Если хотя бы один существенный вход отличается, результат нужно пометить как несопоставимый, а не объявлять победителя.
\nСборка не является одной операцией. Сначала резолвер строит граф модулей. Затем плагины и loaders преобразуют входы. Bundler раскладывает граф по chunks, минифицирует код и пишет output. Кэш может вернуть промежуточный результат до части этих шагов. Поэтому число из секундомера описывает не «скорость инструмента», а конкретный маршрут с конкретным состоянием.
\nРазмер тоже имеет несколько значений. Размер исходного input показывает вклад модуля в сборку. Размер output показывает файл на диске. Transfer size показывает объём после compression и HTTP-обмена. Эти величины нельзя подменять друг другом. Большой input может попасть в отложенный chunk, а небольшой модуль — блокировать первый экран.
\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 |
Кэш хранит результат, полученный при определённых условиях. Его ключ должен различать изменения, которые влияют на граф, transform или output. Для типового frontend-проекта это lockfile, нормализованная конфигурация, версия Node и bundler, исходный digest, entry и параметры режима. Состав полей зависит от инструмента. Нельзя скопировать ключ webpack в Vite и считать его полным.
\nНеполный ключ даёт опасный cache hit. Например, команда меняет alias или plugin, но имя cache namespace остаётся прежним. Bundler видит старый промежуточный результат и выпускает артефакт без нового правила. Постоянный флаг принудительной пересборки скрывает проблему, но не объясняет, что именно должно инвалидировать кэш.
\nСлишком широкий ключ создаёт обратную проблему. Если в него попадает абсолютный путь временной директории или случайный идентификатор job, каждый запуск выглядит новым. CI теряет повторяемость. Поэтому ключ должен быть детерминированным: одинаковые значимые входы дают одинаковое значение, а изменение значимого входа меняет его.
\nНиже учебный код. Он не запускает bundler и не измеряет реальный проект. Функция получает два уже записанных запуска, отбрасывает разные inputFingerprint и только затем считает разницу. Числа нужны для показа контракта, а не для заявления о production-эффекте.
\nfunction 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Время зависит от 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 — только симптом. Сравните два metafile или эквивалентных отчёта сборщика. В JSON-метафайле esbuild можно найти inputs и их вклад в outputs. Отсортируйте delta по каждому input. Новый крупный модуль, выросший старый модуль и две версии одной зависимости ведут к разным действиям.
\nЕсли delta появилась в библиотеке, найдите import path и проверьте tree-shaking. Если появились два пути к разным версиям пакета, проверьте lockfile и resolver. Если input не изменился, а output вырос, ищите plugin transform, target, minify и split. После исправления повторите сборку на том же fingerprint.
\nMetafile не измеряет браузерную скорость. Для пользовательского эффекта отдельно смотрите transfer size, compression, cache и timing критического ресурса. Source map помогает связать bundle с исходным модулем, но карта может быть большой и не должна случайно попасть в production delivery.
\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Сравнение готово, если другой инженер может восстановить два запуска по commit, lockfile, команде и окружению, увидеть одинаковый fingerprint и получить те же поля отчёта. В отчёте видны cold/warm state, серия времени, chunks, input delta и ограничения метрики.
\nДля кэша готовность дополнительно подтверждает отрицательный тест: изменение lockfile, конфигурации, runtime и одного исходного модуля меняет ключ или приводит к зафиксированному invalidation. После cache hit output соответствует тому же входу. Только тогда разницу времени можно обсуждать как свойство проверенного маршрута.
\nНовая frontend-сборка закончилась за 38 секунд вместо 47. Через день CI снова показывает 47 секунд. В другом запуске candidate оказался быстрее, но собирал только production entry, а baseline — два entry и source map. Цена ошибки — неверный выбор инструмента, потерянное время на миграцию и артефакт, который нельзя сравнить с опубликованным.
\nТезис: время и размер имеют смысл только для одинаковой работы. Сборщик получает исходный граф, lockfile, конфигурацию, runtime, entry points и состояние кэша. Если хотя бы один существенный вход отличается, результат нужно пометить как несопоставимый, а не объявлять победителя.
\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 size | entry, chunks, compression и заголовки | назвать размер на диске объёмом ответа |
| Почему вырос bundle? | delta по output и inputs | metafile или эквивалентный отчёт с import path | искать причину только в общем числе байт |
Вход сборки — не только папка src. Для честной пары запусков зафиксируйте commit или digest исходных файлов, lockfile, entry points, режим, target, feature flags, версии Node.js и bundler, операционную среду, команду и рабочую директорию. Если plugin читает переменные окружения, шаблоны или файлы за пределами src, они тоже входят в контракт.
Не следует обещать, что этот список универсален. Конкретный инструмент может учитывать дополнительные поля: конфигурацию resolver, патчи зависимостей, параметры минификатора, локальные плагины или содержимое системных каталогов. Практическое правило такое: меняем один предполагаемый вход, наблюдаем invalidation и записываем результат. Если изменение не отражается в ключе или output, значит, проверяемая модель кэша неполна.
\nДля воспроизводимости полезен не короткий fingerprint вроде src-42, а запись, которую другой инженер может восстановить: ссылка на commit, digest lockfile, нормализованный конфиг, список entry и описание окружения. Сам fingerprint помогает связать записи, но не доказывает, что в него попали все значимые входы.
Кэш сборки отвечает на вопрос «можно ли повторно использовать промежуточный результат». Content hash в имени output отвечает на другой вопрос: «изменилось ли содержимое этого файла для клиента». Нельзя считать одинаковым cache hit и одинаковое имя файла. Первый относится к внутреннему маршруту сборщика, второе — к доставке артефакта.
\nВ webpack подстановка [contenthash] меняет имя output при изменении содержимого соответствующего asset. Это помогает браузеру оставить неизменившийся файл в кэше. В документации webpack отдельно показано, что runtime и module identifiers могут влиять на hashes нескольких chunks; поэтому изменение одного модуля не обязано менять только один файл. Вывод надо делать по фактическому output, а не по ожиданию.
У Vite есть более узкий пример для dependency pre-bundling: файловый кэш хранится в node_modules/.vite, а повторный pre-bundling зависит, среди прочего, от lockfile, времени изменения patches, релевантных полей конфигурации и NODE_ENV. Это описание конкретного механизма Vite, а не готовая формула для webpack, esbuild или самописного кэша. Флаг --force полезен для диагностики, но постоянный force скрывает неверный ключ и убирает пользу повторного запуска.
Сначала подготовьте два запуска, а не подставляйте числа в отчёт вручную. Baseline и candidate должны пройти одну и ту же команду на зафиксированной паре входов. Для каждого запуска сохраните время в миллисекундах, размер raw output, список entry и chunks, cache state, exit code и ссылку на артефакт. Код ниже только проверяет сопоставимость уже собранных записей; он не запускает bundler и не доказывает эффект в production.
\nfunction 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Время зависит от состояния 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 — симптом, а не причина. В esbuild включите metafile: JSON содержит inputs и outputs, связи импортов, размер output и вклад input в этот output. Сохраните этот файл рядом с артефактом. Текстовая визуализация удобна человеку, но автоматическую проверку лучше строить по JSON-данным, чтобы формат отчёта не стал скрытым контрактом.
Сопоставьте 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 и предполагаемое действие.
Raw output — размер файла до передачи. Transfer size зависит от gzip или Brotli, заголовков, CDN и того, был ли ресурс в кэше. Время выполнения зависит от JavaScript, CPU устройства и момента, когда браузер встречает критический код. Поэтому уменьшение bundle на 4 КБ может не изменить первый экран, а дополнительный chunk может ухудшить маршрут из-за новой сетевой границы.
\nПроверьте отдельно тот путь, ради которого меняется сборка. В браузере сохраните URL и response headers, повторите маршрут с очищенным и заполненным HTTP-кэшем, зафиксируйте compression и timing. Не переносите локальную цифру bundling в формулировку «страница стала быстрее», пока не измерен браузерный сценарий на сопоставимых условиях.
\nSource map и metafile служат диагностике и могут не входить в production delivery. Если baseline публикует карту, а candidate нет, raw output сравнивается не с тем же результатом. Сначала выровняйте policy артефакта, а затем отдельно решите, какие файлы доступны клиенту, а какие остаются в хранилище CI.
\nЭта схема не выдаёт универсальный рейтинг bundlers. Она помогает сравнить два конкретных маршрута при заданном входе и окружении. Результат нельзя переносить на другой проект, если там другие entry points, plugins, target, версии зависимостей, CPU или policy артефактов.
\nFingerprint не доказывает полноту сам по себе. Если его строит неполный скрипт, одинаковая строка может скрыть другой конфиг или старый linked package. Одинаковый runtime не устраняет различия диска, памяти и виртуализации. Cold build cache не означает cold browser cache.
\nРост output не равен росту времени выполнения в браузере, а уменьшение raw bytes не гарантирует ускорения первого экрана. Source map и metafile описывают артефакт, но не подтверждают его корректную доставку. Для вывода о production-поведении нужны отдельные данные соответствующего маршрута.
\nОтрицательный путь должен быть явным. Если входы или entry различаются, возвращайте «несопоставимо». Если cache hit дал старый артефакт, исправьте ключ или invalidation и повторите проверку. Если причина роста не найдена, не объявляйте регрессию по одной цифре и не включайте постоянный force как замену расследованию.
\nСравнение готово, если другой инженер может восстановить оба запуска по commit, lockfile, команде и окружению, увидеть одинаковые input и entry fingerprints и получить тот же состав output. В отчёте видны cache state, серия времени, chunks, input delta, exit code и ограничения выбранной метрики.
\nДля кэша готовность дополнительно подтверждает отрицательный тест: изменение lockfile, конфигурации, runtime, plugin и одного исходного модуля меняет ключ или приводит к зафиксированному invalidation. После cache hit output соответствует тому же входу. Только тогда разницу времени можно обсуждать как свойство проверенного маршрута, а не как обещание нового инструмента.
\n[contenthash], runtime chunk и deterministic module identifiers; эти настройки относятся к output webpack и не являются универсальным ключом build cache.--force. Эти правила относятся к Vite dependency optimizer.Сбой на границе 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 не подтверждает остальные.
Ниже приведён учебный фрагмент. Он показывает порядок проверки пустого буфера и передачи длины. Имена функции, код ошибки и типы условны. Фрагмент не доказывает корректность конкретной библиотеки и не является 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 или выборе типа. Для указателей добавьте нулевую длину, длину ровно до границы и длину на один байт больше.
Остановите вызов, если размер не совпал, обязательное поле отсутствует, версия неизвестна, 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 и причину отказа. Тогда следующая ошибка возвращается к конкретному байту, а не к предположению о языке.
Сбой на границе 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-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: спецификация интерфейса требует отдельной модели со сдвигами и масками.
| Часть контракта | Что может разойтись | Как проверить | Что делать при расхождении |
|---|---|---|---|
| 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 и запретить создание доменного объекта |
Самая опасная подмена — считать, что массив байтов можно всегда привести к указателю на 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 — это узкая граница, в которой собраны инварианты вызова. Он не должен превращаться в место для бизнес-логики. До 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 с созданием долгоживущего объекта.
Буфер, созданный в 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 обнуляет или закрывает его до передачи результата дальше.
sizeof, offsetof и alignment каждого поля; сопоставить эти значения с D static assert.Остановите вызов, если размер или 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 он возвращает отказ, а не частичный объект. Такой процесс связывает падение с измеримым нарушением контракта и оставляет следующий шаг для диагностики.