Скриншот demo.machines.mellonis.ru с запущенной машиной Тьюринга
Скриншот demo.machines.mellonis.ru с запущенной машиной Тьюринга

За последние две недели я выкатил подряд четыре ломающих мажорных релиза @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.

Это работало. Но у этого были три побочки:

  1. Подстановка протекала. Потребители хотели доступ к неподставленному, «настоящему» состоянию итерации (тому, которое видит хук step), — см. turing-machine-js#107. Часть пользователей читала MachineState.debugBreak напрямую из runStepByStep и удивлялась, что m.state относится к итерации, на которой after сработал, а не к той, что его породила.

  2. Halt требовал отдельной ветки. Как уже сказано: собственный after останавливающейся итерации срабатывает после выхода из цикла. В v5 для этого появился пост-цикловой добор. И у этого добора своя ветка кода, со своей подстановкой, отдельной от диспатча внутри цикла.

  3. Возвращаемый тип генератора расширился. Поскольку пост-цикловому добору надо было вернуть финальный yield-нутый объект наружу из итератора, тип возврата runStepByStep стал Generator<MachineState, MachineState | null> — yield-тип и return-тип. Канонический потребитель на for..of return-значения не видит, но всякий, кто читал сигнатуру типа, теперь должен был разбираться, почему слотов два.

Всё это выросло из одного проектного решения: диспатчить 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 приостановки/брейкпоинтов

  1. Называйте хуки глаголом потребителя, а не событием движка. onPause, а не onDebugBreak. onProgress, а не onChunkReceived. Имя расползается по ментальной модели каждого потребителя — выбирайте ту рамку, которую хотите им передать.

  2. Кидайте исключение при невозможных конфигурациях — и пораньше. «Снисходительность к входным данным» подходит для форм, которые могут быть осмысленными; это ловушка для форм, которые осмысленными быть не могут. У haltState.debug.after = true не было возможной семантики. Молчаливое проглатывание стоило одному пользователю полдня — пока я не заметил.

  3. Фазы жизненного цикла принадлежат той итерации, которую описывают. Если хочется диспатчить after K на yield-е K+1 — спросите, какой пейлоад приходится подставлять, чтобы это выглядело правильно. Подстановка — это и есть сигнал.

  4. Общий выключатель стоит дёшево и оправдывает себя. Булева на входной точке, которая выключает всю фичу целиком (без переписывания флажков на уровне графа), делает фичу безопасной для прода и удобной для тестов. run({ debug: false }) был одной из самых мелких добавок v5 — и одной из самых востребованных среди зависимых проектов.

  5. Ломающие изменения стоят мажора. Три мажора за две недели звучат дорого. На самом деле — нет, если синхронный 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.