Один из моих плейтестеров залогинился в игру вечером, поиграл минут десять - и его начало выкидывать. Без причины, без ошибки на экране. Ручной релогин не помогал: заходишь, играешь, снова вылет. Замкнутый круг

Человек решил, что взломали аккаунт. Испугался, сам сменил пароль. Взлома не было - но откуда ему знать? С той стороны экрана это выглядело именно так: кто-то выбивает меня из сессии

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

У соло нет второй линии

В команде между твоим кодом и пользователем стоят люди: QA, релиз-инженер, второй разработчик, который заметит. У соло-разработчика с агентами этой прослойки нет. Агент пишет код быстро, ты его ревьюишь (об этом была прошлая статья - агент не думает, думаешь ты), но последний рубеж перед живым человеком - всё равно ты один

И вот что я понял, набив шишек: прод-баги у соло неизбежны, и цель не в том, чтобы их не было. Они будут. Часть багов физически не ловится тестами - они вылезают из взаимодействия систем, таймингов, реального поведения игрока. Ревью ловит «агент написал ерунду». Прод ловит «всё написано правильно, а вместе не работает»

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

Анатомия того самого вылета

Вернёмся к вылетам. Разберу подробно, потому что это идеальный пример «правильного кода, который не работает вместе».

Сервер отличал игровые сессии друг от друга по полю iat из JWT-токена - времени выпуска токена. И вот первое честное признание: это плохая опора, и придраться к ней можно было прямо на ревью, без всякого прода. iat говорит, когда токен выпущен, а не какая это сессия. Я растянул логику авторизации не туда и не заметил, потому что держал в голове одну картину: один игрок, одно соединение, один токен

Картина была неверная. Игрок держит три соединения сразу: мир, планета, город. В идеале они должны делить один токен, но у меня каждое обновляло его своим циклом. Когда одно соединение освежало токен чуть раньше других, рождался новый iat. Реестр сессий на сервере видел новый iat, считал его новой сессией - а активной он держал только одну, поэтому прежние выбивал. В том числе живых «братьев» того же самого игрока

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

Причину нашли не сразу - и здесь пора сказать про агента, потому что разведку по логам вёл он.

Ложных следов было три, и все три выглядели убедительно

Первый - NullReferenceException. В логах рядом лежали два настоящих NRE: в обработчике городских ресурсов и в аудиоконтроллере шаттла. Соблазн назначить их причиной большой - вот же красное в логе. Агент проверил и написал в выводе прямо: это отдельные баги, к вылетам отношения не имеют. Они уехали в общую пачку своим ходом

Второй - код разрыва 4000. Это код дисконнекта «вошли повторно» - буквально то, что мы искали. Агент разделил события: часть из них - штатный переход игрока из города на планету, а вовсе не выбивание. Совпадение кода не превратилось в диагноз

Третий - «у клиента отстают часы». Гипотеза естественная, раз речь про токены и время. Агент отбил её четырьмя независимыми точками в коде: обновление токена реактивное, по ответу 401, а не по локальным часам; iat и exp - серверные метки; окно считается от серверного времени; допуск на расхождение при проверке токена нулевой. И, что важнее, сверил это с реальным продовым логом, а не просто рассудил

Корень он нашёл исключением: не «вероятнее всего вот это», а вычеркнув всё, что не подтвердилось.

И тут важная разница с прошлой статьёй. Там я писал, что уверенный процент от агента в диагностике («на 50% дело в аудиомикшере») - это имитация расследования: паттерн-матчинг, поданный как вывод. Здесь получилось наоборот, и отличие ровно одно: агент не ранжировал гипотезы, а проверял их - каждую по конкретным местам в коде и по живым логам. Ранжирование - это угадывание с уверенным лицом. Исключение с доказательством - работа. Что из двух вы получите и решает постановка задачи

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

И вот честная часть, без причёсывания: этот баг ещё не закрыт полностью. Правильное решение - завязать личность сессии на стабильный идентификатор, а не на производный от токена. Это ещё не в проде. Пока стоит временная заплатка: пятиминутное окно, в котором своих (по стабильному внешнему идентификатору игрока) не выбиваем. Заплатка держит, полное решение расписано, жду подтверждения от живого игрока после выката. Вот так «учиться на проде» выглядит в будни - не аккуратный постмортем задним числом, а живая заплатка и план

Правило важнее фикса

Починить баг - это ноль. Точнее, это только половина, и не главная.

Я на этом обжёгся буквально. Был баг: обработчик события делал ранний выход (if (!client) return) до того, как записать состояние в базу. Если игрок в момент события находился не в той комнате - скажем, на планете, а не в городе, - сюжетный блок терялся навсегда. Поймали, починили точечно в одном месте. И не записали никуда

Через несколько циклов другой сдвиг в таймингах (поменяли логику автозакрытия комнат) вскрыл ровно тот же класс бага в другом месте. Рецидив. Та же ошибка, второй раз, потому что в первый раз я починил симптом и пошёл дальше

После этого появилось правило - и файл, где такие правила живут

DECISIONS.md: где решения перестают забываться

У меня в репозитории с документацией лежит DECISIONS.md, рядом с описанием архитектуры и логом прогресса. Каждое решение пронумеровано, у него статус (зафиксировано / замысел / отложено), обязательно указан баг-источник и, если есть, список прецедентов

Одна деталь, которую я считаю важной: правило канонизируется не на первом случае, а на втором независимом случае того же класса. Первый баг - может, совпадение, местная особенность. Второй такой же в другом месте - уже система, и вот тогда это идёт в файл как инвариант. Иначе DECISIONS.md распухнет от паникёрских «никогда больше так не делать» после каждой мелочи.

Владелец этого файла - отдельный агент-архитектор. Я как тимлид эскалирую туда архитектурные развилки вариантами A/B/C, он канонизирует выбранное. Разделение ролей: тимлид тушит и решает здесь и сейчас, архитектор держит долгую память проекта

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

Запись - источник правды, доставка - лучшая попытка поверх неё.

Иначе я бы просто поменял «записали, но не доставили» на такое же молчаливое «записали, но не доставили», только с другой стороны

Кнопка, которая врёт

Второе правило родилось не из вылета, а из кнопки «Продать». И сразу оговорюсь: агент здесь в рассказе не появится. Не потому что его не было, а потому что я не записал, как он вёл себя в разборе - остался зафиксирован баг, фикс и правило, а процесс нет. Что само по себе иллюстрирует тему этой статьи: не записал - считай, не было.

Плейтестер жмёт «Продать». Кнопка выглядит серой, неактивной - но клик всё равно проходит (визуально погасили, а обработчик выключить забыли), команда уходит на сервер... и там серверный лимитер частоты молча её дропает, без ответа клиенту. Ордер не создан, реакции ноль. Человек лечил перезаходом. Тут вранья сразу два слоя: клиент показывает «нельзя», хотя на деле пускает клик, а сервер отказывает, но молчит об этом

И это всплыло не в одиночку. Ещё пара похожих: кнопка «улучшить» на максимальном уровне, шаттл, отказывающий в апгрейде с неправильной причиной в подсказке. Второй независимый случай - и в файл пошли сразу два правила

Первое: любая команда, меняющая состояние, обязана получить ответ - подтверждение или отказ с причиной. Молчаливый дроп недопустим: без ответа клиент врёт по умолчанию, потому что ему нечем себя поправить. Второе: визуальное состояние и текст причины отказа считаются из того же условия, что и настоящий гейт, а не из отдельной проверки. И триггер-вопрос на код-ревью, который я теперь задаю всегда: «Если игрок сделает ровно то, что просит кнопка или подсказка, - действие разблокируется?» Если нет - гейт врёт, и это баг, даже если всё «работает».

Инвентаризация перед выкатом

Ещё одно правило - уже не про конкретный баг, а про целый их класс. Родилось после того, как за один цикл я поймал четыре продовых блокера одного вида: поле объявлено в схеме, юнит-тест зелёный, а в реальном коде продакшена его никто не пишет. Экспортировано, но не вызвано. Об одном таком случае была прошлая статья - там это ловил вопрос на ревью «а где это вызывается в проде?». Теперь я довёл тот вопрос до регулярного ритуала перед выкатом.

Правило звучит так: любой пункт приёмки, который заявляет эффект на состояние - запись в базу, инкремент счётчика, доставка сообщения, - не закрывается, пока не прослежена вся цепочка: поле в схеме => место записи => реальное продовое событие, которое эту запись триггерит. Юнит-тест функции в изоляции доказательством не считается. Функция может быть идеальной и не вызываться ниоткуда.

На практике перед деплоем с новыми stateful-полями я делаю инвентаризацию: выписываю все такие поля и размечаю каждое:

  • 🟢 OK - пишется, из реального события;
  • 🔴 MISSING WRITE - кто-то это поле читает, а пишущего кода нет нигде (блокер деплоя);
  • 🔴 WRITE ONLY IN TEST - пишется только из теста, в проде мёртвое (блокер деплоя);
  • 🟡 WRITE BUT NOT FROM EVENT - пишется, но не из того события, из которого должно;
  • ORPHAN - поле-сирота: и не пишется, и не читается, кандидат на удаление.

Блокеры деплоя тут - MISSING WRITE и WRITE ONLY IN TEST: поле обещано, а в проде его никто не заполняет. Пока такие есть - не выкатываю.

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

  • Перед пушем волны коммитов - проверить, что каждый реально в рабочей ветке, а не завис на локальной. Это тот самый merge-audit из прошлой статьи, теперь как ритуал: один раз я чуть не выкатил клиент с новым форматом данных на сервер со старой схемой, потому что серверный коммит остался лежать локально
  • Рестарт контейнера dev-сервера перед плейтестом серверных правок. Иначе тестируешь протухший код и удивляешься, почему фикс «не работает»
  • Версию клиента не трогаю, если правка только серверная и не требует форс-апдейта, - иначе рискуешь застрявшими игроками на старом клиенте

И одно, что не делегируется: смоук-тест и проверку живьём перед продом делаю только я сам, руками, за клиентом. Агенты этого не делают. Агент подтвердит, что «всё готово», с той же уверенностью, с какой в прошлой статье выдавал 50% в диагностике - уверенность модели не равна проверке. Последний клик за живым человеком

Что из этого выносить

Прод-баги у соло-разработчика с агентами - не признак того, что ты плохо работаешь. Они будут, потому что часть багов живёт только в реальном взаимодействии, которого нет ни в тестах, ни в ревью. Разница между «тону в багах» и «расту» - не в их количестве, а в том, что с ними происходит после.

Каждый баг у меня проходит один путь: починить => дождаться второго такого же => канонизировать в правило => добавить триггер-вопрос на ревью или пункт в pre-deploy audit. Разовый фейл превращается в постоянный предохранитель. Файл решений - это не бюрократия, это моя внешняя память о собственных ошибках, которую не сотрёт следующий цикл.

А тот вылетающий игрок? Заплатка держит, настоящее решение расписано и ждёт очереди. Да, я пишу эту статью из позиции незакрытого до конца бага - и это честнее, чем писать из позиции «у меня всё идеально». Жизнь на проде - это не ноль ошибок, а система, которая из каждого шрама делает броню