
За последние две недели я выкатил подряд четыре ломающих мажорных релиза @turing-machine-js/machine — v3, v4, v5, v6 — и самое интересное здесь было не в какой-то одной фиче. Интересно было смотреть, как один и тот же участок API (хук приостановки/брейкпоинта в управляющем цикле) переделывался дважды за три версии, и каждый раз потому, что в прошлый раз там выпирало то, что выпирать не должно.
Этот пост — разбор полётов. Если вы проектируете API приостановки/шага/брейкпоинта для интерпретатора на цикле-генераторе, для планировщика или рантайма DSL, ошибки, которые я допустил, нетрудно повторить — и полезнее сначала встретить их в чужом коде.
Постановка задачи
Движок исполняет машину Тьюринга. Внутри — генератор: каждый yield — один шаг (одно срабатывание перехода на одном символе ленты). Управляющий цикл выглядит примерно так:
async function run({ initialState, onStep }) {
for (const m of runStepByStep({ initialState })) {
await onStep?.(m);
if (m.state.isHalt) return;
}
}
MachineState (m) — снимок состояния машины на каждой итерации: какое состояние сейчас будет исполнено, текущий и следующий символы, движения, которые сделают головки. Потребитель — логгер, UI, тест — может подцепиться к onStep и наблюдать.
В v4 я хотел добавить способ приостановить выполнение в выбранных точках — как брейкпоинт в отладчике. У любого State должна быть возможность задать конфигурацию debug:
myState.debug = { before: [symA], after: [symA] };
...а управляющий цикл должен давать потребителю возможность заглянуть внутрь машины и решить, когда продолжить.
v4: выкатываем
Дизайн v4 был простым. Я сделал run() асинхронным, добавил хук onDebugBreak и спроектировал его так:
await machine.run({
initialState,
onStep: (m) => { /* каждый шаг */ },
onDebugBreak: async (m) => {
if (m.debugBreak?.before) console.log('before:', m.state.name);
if (m.debugBreak?.after) console.log('after:', m.state.name);
// зависаем здесь до резолва промиса
},
});
Поле debug у состояния — мутабельное. state.debug = { before: true } ставит брейк перед шагом; before: [symA] фильтрует по тому, что читает головка. Хук вызывается через await, поэтому любой потребитель может реализовать сценарий «приостановить машину, пока человек не нажмёт Продолжить» — просто не резолвить промис.
Также хотелось ставить брейкпоинты на halt:
haltState.debug = { before: true }; // приостановка перед выходом
Это работало. Фильтры по списку символов на haltState молча игнорировались — у haltState нет символа под головкой, по которому можно было бы фильтровать. Ну и ладно, подумал я, — будем снисходительны к входным данным, главное, что универсальный true срабатывает.
В этом дизайне было два просчёта. Я не замечал ни одного, пока не сел писать документацию, а следом — UI.
Ошибка № 1: хук описывает движок, а не потребителя
onDebugBreak на бумаге читается как вполне разумное имя. Движок выстреливает «debug break»; вы цепляете на него хук.
Но что потребитель в этом хуке делает? Приостанавливает. Заглядывает внутрь. Ждёт ввод. Возобновляет. Хук на самом деле не уведомляет о том, что произошло событие, — он предлагает точку сотрудничества.
Имя onDebugBreak несёт одну конкретную рамку — «отладка». Но тот же хук — это ровно то, что нужно для пошаговой визуализации, замедленного воспроизведения, плавной анимации, UI с «нажмите пробел, чтобы двинуться дальше». Ничто из этого не отладка. Это всё приостановка.
Кажется мелочью. Это не мелочь — потому что, увидев имя, потребители начинают проектировать вокруг него: их обработчик называется handleDebugBreak, в их стейт-машине заводится булева debugging, кнопка в UI подписана «Остановить отладку». Имя расползается по словарю каждого потребителя.
Я написал RFC (turing-machine-js#109) и переименовал хук в v5. Жёсткое переименование, без алиаса с депрекацией:
await machine.run({
initialState,
- onDebugBreak: (m) => { ... },
+ onPause: (m) => { ... },
});
Поле пейлоада m.debugBreak осталось — это метаданные о том, почему сработал этот yield ({ before: true } или { after: true }), и «break» внутри пейлоада нормально читается. Но имя хука — это контракт с потребителем: onPause описывает, что он делает, а не что делает движок.
Правило на следующий раз: называя хук, называйте глагол потребителя, а не событие движка. Хуки — это точки сотрудничества, а не event listeners.
Ограничение: haltState.debug.after — бессмыслица
Вторая ошибка v4 пряталась под инстинктом быть снисходительным к входным данным. haltState.debug.after молча принимал запись. Просто не срабатывал.
Почему? Потому что семантика after в этом движке означает «сработать на итерации после той, что соответствовала фильтру». Для обычных состояний это логично: вы выходите из состояния K, исполняется следующая итерация (K+1), и на её yield-е потребителю сообщают: «кстати, фильтр after на K сработал на прошлом шаге». Но halt — терминальное. После halt никакого K+1 нет. Событию after не за что зацепиться.
В v4 я молча проглатывал haltState.debug.after = true. Хуже того, я пропускал также { before: true, after: true } — половина присваивания была осмысленной, половина была no-op-ом, и потребитель об этом не узнавал.
В v5 я сделал так, чтобы обе формы бросали исключение при записи (turing-machine-js#108, часть 2):
- haltState.debug = { before: true, after: true }; // v5: бросает — 'after' на halt не за что зацепиться
+ haltState.debug = { before: true };
Правило на следующий раз: если у конфигурации нет семантики — кидайте исключение пораньше. Молчаливый no-op на входе выглядит юзерфрендли — ровно до момента, когда потребитель полдня выясняет, почему у него UI не срабатывает на halt. «Будь снисходителен» — ложная экономия, когда снисходительность глушит реальную ошибку.
Заодно я починил связанный баг: фильтр after самой останавливающейся итерации тоже не срабатывал (turing-machine-js#108, часть 1). Управляющий цикл выходил, как только state.isHalt становилось true, — и after-событие от предыдущей итерации, которое в норме сработало бы на этом финальном yield-е, просто проваливалось. В v5 я добавил пост-цикловой добор, чтобы оно сработало.
К этому я ещё вернусь.
И один бонус v5: общий выключатель на run() для всей системы приостановки.
await machine.run({ initialState, debug: false, onPause: ... });
При false все срабатывания приостановки подавляются вне зависимости от того, что прописано в state.debug по всему графу (turing-machine-js#106). Можно A/B-тестировать «режим отладки», не переписывая граф и не вычищая каждое поле state.debug. Флаг управляет диспатчем onPause; само поле полезной нагрузки m.debugBreak по-прежнему заполняется на yield-ах генератора (это свойство итерации, а не того, как run() решил его выставить наружу).
Ошибка №2: танец подстановки
Эту я поймал с особой гордостью — код работал, тесты проходили, документация была корректной. Просто дизайн был неправильным.
В v4 и v5 after K (срабатывание after для итерации K) на самом деле происходило на yield-е итерации K+1. Управляющий цикл проносил между yield-ами флажок pendingAfterFromPrev, и на yield-е следующей итерации сначала диспатчил хук after, потом хук before, потом onStep. Хук для after-срабатывания K должен был видеть состояние K, а не K+1. Поэтому я подставлял снимок предыдущего yield-а в поле m.state итерации K+1 — только на время диспатча after.
Это работало. Но у этого были три побочки:
-
Подстановка протекала. Потребители хотели доступ к неподставленному, «настоящему» состоянию итерации (тому, которое видит хук
step), — см. turing-machine-js#107. Часть пользователей читалаMachineState.debugBreakнапрямую изrunStepByStepи удивлялась, чтоm.stateотносится к итерации, на которойafterсработал, а не к той, что его породила. -
Halt требовал отдельной ветки. Как уже сказано: собственный
afterостанавливающейся итерации срабатывает после выхода из цикла. В v5 для этого появился пост-цикловой добор. И у этого добора своя ветка кода, со своей подстановкой, отдельной от диспатча внутри цикла. -
Возвращаемый тип генератора расширился. Поскольку пост-цикловому добору надо было вернуть финальный yield-нутый объект наружу из итератора, тип возврата
runStepByStepсталGenerator<MachineState, MachineState | null>— yield-тип и return-тип. Канонический потребитель наfor..ofreturn-значения не видит, но всякий, кто читал сигнатуру типа, теперь должен был разбираться, почему слотов два.
Всё это выросло из одного проектного решения: диспатчить after K на yield-е итерации K+1, а не на собственном yield-е итерации K.
В v6 я это схлопнул (turing-machine-js#119). Жизненный цикл одной итерации теперь честно before → step → after. Срабатывание after приходится на тот же yield, что и итерация, которая его породила. Нет подстановки. Нет pendingAfterFromPrev. Нет пост-циклового добора. Тип возврата генератора сужается обратно до Generator<MachineState>.
// v6
await machine.run({
initialState,
onStep: (m) => { /* без изменений */ },
onPause: (m) => {
if (m.debugBreak?.before) console.log('before:', m.state.name);
if (m.debugBreak?.after) console.log('after:', m.state.name);
},
});
Порядок диспатча между хуками поменялся (любой тест, утверждавший pause(after K-1) → pause(before K) → step(K), пришлось перевернуть в pause(before K) → step(K) → pause(after K)), но множество вызванных диспатчей и семантика на каждой итерации не изменились. Потребители, относящиеся к хукам как к независимым наблюдателям, не видят никаких различий (turing-machine-js#107 — «отдать наружу неподставленное состояние» — исчез как проблема: подстановки больше нет, обходить нечего).
Правило на следующий раз: если пейлоаду хука нужно подставлять состояние из другой итерации, а не из той, что сейчас yield-ит, — последовательность диспатча неверна. Фазы жизненного цикла (before, step, after) принадлежат той итерации, которую описывают. Подстановка была не хитрым приёмом, а протечкой — она говорила, что события стоят не на том такте.
Что я заберу в следующий API приостановки/брейкпоинтов
-
Называйте хуки глаголом потребителя, а не событием движка.
onPause, а неonDebugBreak.onProgress, а неonChunkReceived. Имя расползается по ментальной модели каждого потребителя — выбирайте ту рамку, которую хотите им передать. -
Кидайте исключение при невозможных конфигурациях — и пораньше. «Снисходительность к входным данным» подходит для форм, которые могут быть осмысленными; это ловушка для форм, которые осмысленными быть не могут. У
haltState.debug.after = trueне было возможной семантики. Молчаливое проглатывание стоило одному пользователю полдня — пока я не заметил. -
Фазы жизненного цикла принадлежат той итерации, которую описывают. Если хочется диспатчить
after Kна yield-е K+1 — спросите, какой пейлоад приходится подставлять, чтобы это выглядело правильно. Подстановка — это и есть сигнал. -
Общий выключатель стоит дёшево и оправдывает себя. Булева на входной точке, которая выключает всю фичу целиком (без переписывания флажков на уровне графа), делает фичу безопасной для прода и удобной для тестов.
run({ debug: false })был одной из самых мелких добавок v5 — и одной из самых востребованных среди зависимых проектов. -
Ломающие изменения стоят мажора. Три мажора за две недели звучат дорого. На самом деле — нет, если синхронный downstream — один репозиторий (в моём случае
@post-machine-js/machine, который синхронно с движком обновил peer-зависимость^4.0.0→^6.0.0и переименовал свой внутренний__onDebugBreak→__onPause). Эргономика API копится у всех зависимых проектов на весь срок жизни библиотеки — заплатите цену миграции, пока API ещё молодой.
Дизайн v6 — это то, что мне следовало выпустить ещё в v4. Тогда у меня не было нужного словаря — я думал «выставить наружу события движка», а не «спроектировать контракт потребителя». Именно работа над документацией вскрыла обе ошибки. Тут есть мета-урок: документация — самое дешёвое дизайн-ревью, которое можно у себя устроить, но это уже тема для другого поста.
Код: turing-machine-js (движок) и post-machine-js (машина Поста поверх него, версии синхронизированы). Интерактивное демо на demo.machines.mellonis.ru использует v6-хук onPause для кнопок Step / Run / Stop.