<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Статьи — Руслан Гильмуллин</title>
    <link>https://mellonis.ru/ru/articles/</link>
    <description>Статьи Руслана Гильмуллина.</description>
    <language>ru</language>
    <atom:link href="https://mellonis.ru/ru/articles/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Два мажора, один README, одно демо: два почти бесплатных дизайн-ревью</title>
      <link>https://mellonis.ru/ru/articles/dva-mazhora-odin-readme-odno-demo/</link>
      <guid isPermaLink="false">https://mellonis.ru/ru/articles/dva-mazhora-odin-readme-odno-demo/</guid>
      <pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate>
      <description>Из трёх мажоров, описанных в предыдущей статье, два не всплыли в тестах. Они всплыли в двух дизайн-ревью, которые тесты провести не могут.</description>
      <content:encoded><![CDATA[<blockquote>
<p>Именно работа над документацией вскрыла обе ошибки. Тут есть мета-урок: документация — самое дешёвое дизайн-ревью, которое можно у себя устроить, но это уже тема для другого поста.</p>
</blockquote>
<p>Это и есть тот самый «пост», другая статья — и первое, что ей нужно сделать, это поправить тизер. Тизер привирает. Документация поймала только одну из двух ошибок. Вторую поймал первый настоящий потребитель API, которого я собирал параллельно. Эти два ревью сработали в паре: документация смотрит на форму API, потребитель — на использование. Вместе они ловят то, чего не видят тесты.</p>
<p>Если вы выпускаете что угодно за интерфейсом — библиотеку, CLI, любую сущность за контрактом, — это два ревью, которые вы, скорее всего, не оставляете без внимания.</p>
<figure><img src="https://mellonis.ru/articles/dva-mazhora-odin-readme-odno-demo/bf-tiser.webp?v=78281bd0" alt="Скриншот demo.machines.mellonis.ru с запущенным Brainfuck UTM: многоленточное представление слева, исходник движка справа" width="3680" height="2392"><figcaption>Скриншот demo.machines.mellonis.ru с запущенным Brainfuck UTM: многоленточное представление слева, исходник движка справа</figcaption></figure>
<h2 id="napominanie"><a class="heading-anchor" href="#napominanie">Напоминание</a></h2>
<p><a href="https://mellonis.ru/ru/articles/three-majors-two-mistakes/">«Три мажора, две ошибки»</a> разбирает движок и v4-API паузы — <code>onStep</code>, <code>onDebugBreak</code>, флаги <code>debug</code> на состояниях. Здесь я опираюсь на неё, не повторяясь слишком. Траектория v4 → v6 включает три брейкинг-мажора: переименование хука, ужесточение семантики halt и схлопывание lifecycle-тиков. Первые два всплыли в демо. Третий — в документации.</p>
<h2 id="demo-v4-v5"><a class="heading-anchor" href="#demo-v4-v5">Демо: v4 → v5</a></h2>
<p>Пока v4 уезжала в релиз, я начал собирать <a href="https://demo.machines.mellonis.ru"><code>machines-demo</code></a> — интерактивный отладчик Turing-машины и первый не-тестовый клиент движка.</p>
<p>Демо — естественный первый потребитель: у него двойная цель. Продуктовая — публичное распространение и демонстрация движка в действии. Техническая — обкатка изменений и проверка концепций на живой API-поверхности. Обе цели делают разработку демо неотъемлемой частью релизного цикла, а не опциональной добавкой к нему.</p>
<p>Демо задействовало оба хука одновременно: <code>onStep</code> заполнял буфер команд для UI-трейса на каждой итерации; <code>onDebugBreak</code> управлял циклом «пауза / продолжение».</p>
<p>Демо собиралось. Тесты проходили. Но писать его было неудобно, по двум причинам.</p>
<p>Во-первых, after-сработка <code>onDebugBreak</code> приходила с данными предыдущего yield’а — теми же, что предыдущий <code>onStep</code> уже показал. Демо обрабатывало одно и то же дважды, и вопрос «почему два хука для одного события?» задавал не я, а как будто сам код. Я открыл <a href="https://github.com/mellonis/turing-machine-js/issues/109"><code>turing-machine-js#109</code></a> как RFC об отношениях этих двух хуков, перечислил четыре эскиза; резолюция сузилась до <em>именования</em>. <code>onDebugBreak</code> ставил целью использования «отладку», глагол же потребителя — «пауза». Переименование без зазрения совести и без алиаса. v5.</p>
<p>Во-вторых, в UI демо появился сценарий «приостановиться перед halt» — дать пользователю заглянуть в финальное состояние машины до того, как она завершится. Естественная реализация — флажок <code>debug</code> на самом <code>haltState</code>. Первый тест-кейс установил <code>haltState.debug = { before: true, after: true }</code>, потому что симметрия выглядела правильной. Срабатывал же только <code>before</code>. Хуже того: after-сработка на самой итерации, которая <em>привела</em> к halt, не доходила до обработки — цикл выходил, как только <code>state.isHalt</code> становилось <code>true</code>. <a href="https://github.com/mellonis/turing-machine-js/issues/108"><code>turing-machine-js#108</code></a> разбил это на две части: восстановить потерянную after-сработку (баг); бросать исключение при записи в <code>haltState.debug.after</code> (API).</p>
<p>Обе претензии пришли со стороны потребителя. Демо не показал баг в коде — он показал <em>формат взаимодействия с API</em>. Имена не подходили под применение. Снисходительность к входным данным не соответствовала тому, что пользователь пытался сделать. Тесты сверяли поведение с собственной внутренней моделью движка — ок, зе́лено. Демо сверило поведение с ментальной моделью потребителя — и выдало две конкретные претензии (не ок).</p>
<h2 id="dokumentatsiya-v5-v6"><a class="heading-anchor" href="#dokumentatsiya-v5-v6">Документация: v5 → v6</a></h2>
<p>v5 уехала. README требовал обновления. Новый раздел про порядок диспатча должен был объяснить — словами, — когда срабатывают <code>onPause(before)</code>, <code>onStep</code> и <code>onPause(after)</code> относительно той итерации, которую они описывают.</p>
<p>Первый честный абзац выглядел примерно так:</p>
<blockquote>
<p><code>onPause(after, K)</code> срабатывает на yield’е итерации K+1, с payload’ом, подставленным из снэпшота итерации K, до того как сработают <code>onPause(before, K+1)</code> или <code>onStep(K+1)</code>.</p>
</blockquote>
<p>Я какое-то время смотрел на это предложение. Короче не получалось. Предложение о подстановке — это не то, что читатель должен был встретить.</p>
<p>Код работал. Тесты проходили. Демо корректно потребляло хуки. Ошибка была не в этом — она была в <em>форме</em> диспатча, и эта форма становилась видна только с той стороны, где её приходилось формулировать словами.</p>
<p>Фикс схлопнул lifecycle: <code>before(K) → step(K) → after(K)</code> на одном и том же yield’е. Без подстановок. Без межитерационного планирования. Без финального добора для останавливающейся итерации (в «Трёх мажорах, двух ошибках» я перевёл это как «пост-цикловой добор»; сейчас, пожалуй, обошёлся бы покороче). Абзац README теперь звучит так:</p>
<blockquote>
<p>На yield’е итерации K хуки срабатывают в lifecycle-порядке.</p>
</blockquote>
<p>А такое предложение читатель проходит не задумываясь. <a href="https://github.com/mellonis/turing-machine-js/issues/119"><code>turing-machine-js#119</code></a> уехал как v6.</p>
<p>Прямая цитата из предыдущей статьи:</p>
<blockquote>
<p>Код работал, тесты проходили, документация была корректной. Просто дизайн был неправильным.</p>
</blockquote>
<p>К этому хочется добавить: документация была корректной только при условии, что читатель смирится с тремя объясняющими предложениями, которые ему читать не следовало. Это не «корректная документация» — это документация, извиняющаяся за форму.</p>
<p>Ревью документацией поймало <em>форму</em>, не <em>использование</em>. Демо работало. Тесты проходили. Только <em>давление прозы</em> в этот раз вскрыло внутренние проблемы реализации.</p>
<h2 id="tri-revyu-tri-sloya"><a class="heading-anchor" href="#tri-revyu-tri-sloya">Три ревью, три слоя</a></h2>
<p>Опишем, что проверяет каждое:</p>
<ul>
<li><strong>Тесты</strong> сверяют код с самим собой. Внутренняя консистентность. Движок выдаёт то, что движок должен выдавать. Зелёные тесты — это база.</li>
<li><strong>Первый настоящий потребитель</strong> сверяет код с ментальной моделью потребителя. <em>Совпадает ли формат API с тем, что пользователь пытается сделать?</em> Ревью реального взаимодействия с API привело к его изменению: переименованию и запрету halt-after.</li>
<li><strong>Документация в виде <em>прозы</em></strong> (не JSDoc, а связный README-нарратив) сверяет код с авторским объяснением. <em>Есть ли у дизайна честное однопараграфное описание?</em> Отсутствие такового заставило внимательно посмотреть на танец с подстановкой и отказаться от него.</li>
</ul>
<p>Каждое ревью ловит то, что не ловит слой ниже. Тесты не ловят форму; потребитель не ловит проблемы взаимодействия, которые он умело может обойти; документация не ловит UX-неровности, которые ей упоминать не пришлось.</p>
<p>Стоимость асимметричная. Разработка настоящего потребителя — самое дорогое из трёх ревью; написание документации — самое дешёвое. И всё-таки даже самое дорогое из них обходится дешевле, чем переделывать API после того, как проблемы вскроются у пользователей: отсюда и «почти бесплатные» в заголовке. Настоящего потребителя нужно разработать <em>до</em> мажорного релиза — только это ревью делает API комфортным в использовании. Документация после этого ограждает то, что осталось.</p>
<h2 id="evristiki-na-sleduyushchii-mazhor"><a class="heading-anchor" href="#evristiki-na-sleduyushchii-mazhor">Эвристики на следующий мажор</a></h2>
<ol>
<li><strong>Соберите первого настоящего потребителя до мажора.</strong> Не тест-фикстуру — а потребителя со своей ментальной моделью. Трудности, с которыми он сталкивается, — это те же трудности, с которыми потом столкнутся все ваши пользователи.</li>
<li><strong>Напишите параграф про порядок диспатча до того, как зафиксируете порядок диспатча.</strong> Если не получается одной фразой описать, что и когда срабатывает, — диспатч неправильный. Решения такого рода должны приниматься на этапе проектирования, а не документации, тогда когда не написано даже и строчки кода.</li>
<li><strong>Перечитайте документацию глазами незнакомца.</strong> Если параграф читается как извинение за форму — документация работает, она <em>явила</em> ошибку формы. Чинить надо форму, а не параграф.</li>
</ol>
<p>Три мажора. Один README. Одно демо. Тестам сказать было нечего.</p>
<hr>
<p><em>Код: <a href="https://github.com/mellonis/turing-machine-js"><code>turing-machine-js</code></a> (движок) и <a href="https://demo.machines.mellonis.ru"><code>machines-demo</code></a> (первый не-тестовый потребитель, на котором всплыли v4 → v5).</em></p>]]></content:encoded>
    </item>
    <item>
      <title>Три мажора, две ошибки: проектирование API приостановки для интерпретатора машины Тьюринга</title>
      <link>https://mellonis.ru/ru/articles/three-majors-two-mistakes/</link>
      <guid isPermaLink="false">https://mellonis.ru/ru/articles/three-majors-two-mistakes/</guid>
      <pubDate>Mon, 11 May 2026 00:00:00 GMT</pubDate>
      <description>Две ошибки в проектировании API приостановки для машины Тьюринга и три мажорных релиза, ушедшие на их вычищение</description>
      <content:encoded><![CDATA[<figure><img src="https://mellonis.ru/articles/three-majors-two-mistakes/tm.webp?v=4db093ab" alt="Скриншот demo.machines.mellonis.ru с запущенной машиной Тьюринга" width="3680" height="2392"><figcaption>Скриншот demo.machines.mellonis.ru с запущенной машиной Тьюринга</figcaption></figure>
<p>За последние две недели я выкатил подряд четыре ломающих мажорных релиза <a href="https://github.com/mellonis/turing-machine-js"><code>@turing-machine-js/machine</code></a> — v3, v4, v5, v6 — и самое интересное здесь было не в какой-то одной фиче. Интересно было смотреть, как один и тот же участок API (хук приостановки/брейкпоинта в управляющем цикле) переделывался дважды за три версии, и каждый раз потому, что в прошлый раз там выпирало то, что выпирать не должно.</p>
<p>Этот пост — разбор полётов. Если вы проектируете API приостановки/шага/брейкпоинта для интерпретатора на цикле-генераторе, для планировщика или рантайма DSL, ошибки, которые я допустил, нетрудно повторить — и полезнее сначала встретить их в чужом коде.</p>
<h2 id="postanovka-zadachi"><a class="heading-anchor" href="#postanovka-zadachi">Постановка задачи</a></h2>
<p>Движок исполняет машину Тьюринга. Внутри — генератор: каждый <code>yield</code> — один шаг (одно срабатывание перехода на одном символе ленты). Управляющий цикл выглядит примерно так:</p>
<pre class="shiki shiki-themes github-light github-dark language-javascript" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">async</span><span style="color:#D73A49;--shiki-dark:#F97583"> function</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> run</span><span style="color:#24292E;--shiki-dark:#E1E4E8">({ </span><span style="color:#E36209;--shiki-dark:#FFAB70">initialState</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, </span><span style="color:#E36209;--shiki-dark:#FFAB70">onStep</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> }) {</span></span>
<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">  for</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="color:#D73A49;--shiki-dark:#F97583">const</span><span style="color:#005CC5;--shiki-dark:#79B8FF"> m</span><span style="color:#D73A49;--shiki-dark:#F97583"> of</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> runStepByStep</span><span style="color:#24292E;--shiki-dark:#E1E4E8">({ initialState })) {</span></span>
<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    await</span><span style="color:#6F42C1;--shiki-dark:#B392F0"> onStep</span><span style="color:#24292E;--shiki-dark:#E1E4E8">?.(m);</span></span>
<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (m.state.isHalt) </span><span style="color:#D73A49;--shiki-dark:#F97583">return</span><span style="color:#24292E;--shiki-dark:#E1E4E8">;</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p><code>MachineState</code> (<code>m</code>) — снимок состояния машины на каждой итерации: какое состояние сейчас будет исполнено, текущий и следующий символы, движения, которые сделают головки. Потребитель — логгер, UI, тест — может подцепиться к <code>onStep</code> и наблюдать.</p>
<p>В v4 я хотел добавить способ <em>приостановить</em> выполнение в выбранных точках — как брейкпоинт в отладчике. У любого <code>State</code> должна быть возможность задать конфигурацию <code>debug</code>:</p>
<pre class="shiki shiki-themes github-light github-dark language-javascript" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">myState.debug </span><span style="color:#D73A49;--shiki-dark:#F97583">=</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> { before: [symA], after: [symA] };</span></span></code></pre>
<p>...а управляющий цикл должен давать потребителю возможность заглянуть внутрь машины и решить, когда продолжить.</p>
<h2 id="v4-vikativaem"><a class="heading-anchor" href="#v4-vikativaem">v4: выкатываем</a></h2>
<p>Дизайн v4 был простым. Я сделал <code>run()</code> асинхронным, добавил хук <code>onDebugBreak</code> и спроектировал его так:</p>
<pre class="shiki shiki-themes github-light github-dark language-javascript" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">await</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> machine.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">run</span><span style="color:#24292E;--shiki-dark:#E1E4E8">({</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  initialState,</span></span>
<span class="line"><span style="color:#6F42C1;--shiki-dark:#B392F0">  onStep</span><span style="color:#24292E;--shiki-dark:#E1E4E8">: (</span><span style="color:#E36209;--shiki-dark:#FFAB70">m</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) </span><span style="color:#D73A49;--shiki-dark:#F97583">=></span><span style="color:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="color:#6A737D;--shiki-dark:#6A737D">/* каждый шаг */</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#6F42C1;--shiki-dark:#B392F0">  onDebugBreak</span><span style="color:#24292E;--shiki-dark:#E1E4E8">: </span><span style="color:#D73A49;--shiki-dark:#F97583">async</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="color:#E36209;--shiki-dark:#FFAB70">m</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) </span><span style="color:#D73A49;--shiki-dark:#F97583">=></span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (m.debugBreak?.before) console.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">log</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(</span><span style="color:#032F62;--shiki-dark:#9ECBFF">'before:'</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, m.state.name);</span></span>
<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (m.debugBreak?.after)  console.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">log</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(</span><span style="color:#032F62;--shiki-dark:#9ECBFF">'after:'</span><span style="color:#24292E;--shiki-dark:#E1E4E8">,  m.state.name);</span></span>
<span class="line"><span style="color:#6A737D;--shiki-dark:#6A737D">    // зависаем здесь до резолва промиса</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">});</span></span></code></pre>
<p>Поле <code>debug</code> у состояния — мутабельное. <code>state.debug = { before: true }</code> ставит брейк перед шагом; <code>before: [symA]</code> фильтрует по тому, что читает головка. Хук вызывается через <code>await</code>, поэтому любой потребитель может реализовать сценарий «приостановить машину, пока человек не нажмёт Продолжить» — просто не резолвить промис.</p>
<p>Также хотелось ставить брейкпоинты на halt:</p>
<pre class="shiki shiki-themes github-light github-dark language-javascript" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">haltState.debug </span><span style="color:#D73A49;--shiki-dark:#F97583">=</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> { before: </span><span style="color:#005CC5;--shiki-dark:#79B8FF">true</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> };  </span><span style="color:#6A737D;--shiki-dark:#6A737D">// приостановка перед выходом</span></span></code></pre>
<p>Это работало. Фильтры по списку символов на <code>haltState</code> молча игнорировались — у <code>haltState</code> нет символа под головкой, по которому можно было бы фильтровать. Ну и ладно, подумал я, — будем снисходительны к входным данным, главное, что универсальный <code>true</code> срабатывает.</p>
<p>В этом дизайне было два просчёта. Я не замечал ни одного, пока не сел писать документацию, а следом — UI.</p>
<h2 id="oshibka-1-khuk-opisivaet-dvizhok-a-ne-potrebitelya"><a class="heading-anchor" href="#oshibka-1-khuk-opisivaet-dvizhok-a-ne-potrebitelya">Ошибка № 1: хук описывает движок, а не потребителя</a></h2>
<p><code>onDebugBreak</code> на бумаге читается как вполне разумное имя. Движок выстреливает «debug break»; вы цепляете на него хук.</p>
<p>Но что потребитель в этом хуке <em>делает</em>? Приостанавливает. Заглядывает внутрь. Ждёт ввод. Возобновляет. Хук на самом деле не уведомляет о том, что произошло событие, — он предлагает точку сотрудничества.</p>
<p>Имя <code>onDebugBreak</code> несёт одну конкретную рамку — «отладка». Но тот же хук — это ровно то, что нужно для пошаговой визуализации, замедленного воспроизведения, плавной анимации, UI с «нажмите пробел, чтобы двинуться дальше». Ничто из этого не отладка. Это всё <em>приостановка</em>.</p>
<p>Кажется мелочью. Это не мелочь — потому что, увидев имя, потребители начинают проектировать вокруг него: их обработчик называется <code>handleDebugBreak</code>, в их стейт-машине заводится булева <code>debugging</code>, кнопка в UI подписана «Остановить отладку». Имя расползается по словарю каждого потребителя.</p>
<p>Я написал RFC (<a href="https://github.com/mellonis/turing-machine-js/issues/109">turing-machine-js#109</a>) и переименовал хук в v5. Жёсткое переименование, без алиаса с депрекацией:</p>
<pre class="shiki shiki-themes github-light github-dark language-diff" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">await machine.run({</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">    initialState,</span></span>
<span class="line"><span style="color:#B31D28;--shiki-dark:#FDAEB7">-   onDebugBreak: (m) => { ... },</span></span>
<span class="line"><span style="color:#22863A;--shiki-dark:#85E89D">+   onPause: (m) => { ... },</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  });</span></span></code></pre>
<p>Поле пейлоада <code>m.debugBreak</code> осталось — это метаданные о том, <em>почему</em> сработал этот yield (<code>{ before: true }</code> или <code>{ after: true }</code>), и «break» внутри пейлоада нормально читается. Но имя хука — это контракт с потребителем: <code>onPause</code> описывает, что он делает, а не что делает движок.</p>
<p><strong>Правило на следующий раз:</strong> называя хук, называйте глагол потребителя, а не событие движка. Хуки — это точки сотрудничества, а не event listeners.</p>
<h2 id="ogranichenie-haltstate-debug-after-bessmislitsa"><a class="heading-anchor" href="#ogranichenie-haltstate-debug-after-bessmislitsa">Ограничение: <code>haltState.debug.after</code> — бессмыслица</a></h2>
<p>Вторая ошибка v4 пряталась под инстинктом быть снисходительным к входным данным. <code>haltState.debug.after</code> молча принимал запись. Просто не срабатывал.</p>
<p>Почему? Потому что семантика <code>after</code> в этом движке означает «сработать на итерации <em>после</em> той, что соответствовала фильтру». Для обычных состояний это логично: вы выходите из состояния K, исполняется следующая итерация (K+1), <em>и на её yield-е</em> потребителю сообщают: «кстати, фильтр after на K сработал на прошлом шаге». Но halt — терминальное. После halt никакого K+1 нет. Событию <code>after</code> не за что зацепиться.</p>
<p>В v4 я молча проглатывал <code>haltState.debug.after = true</code>. Хуже того, я пропускал также <code>{ before: true, after: true }</code> — половина присваивания была осмысленной, половина была no-op-ом, и потребитель об этом не узнавал.</p>
<p>В v5 я сделал так, чтобы обе формы бросали исключение при записи (<a href="https://github.com/mellonis/turing-machine-js/issues/108">turing-machine-js#108</a>, часть 2):</p>
<pre class="shiki shiki-themes github-light github-dark language-diff" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B31D28;--shiki-dark:#FDAEB7">- haltState.debug = { before: true, after: true }; // v5: бросает — 'after' на halt не за что зацепиться</span></span>
<span class="line"><span style="color:#22863A;--shiki-dark:#85E89D">+ haltState.debug = { before: true };</span></span></code></pre>
<p><strong>Правило на следующий раз:</strong> если у конфигурации нет семантики — кидайте исключение пораньше. Молчаливый no-op на входе <em>выглядит</em> юзерфрендли — ровно до момента, когда потребитель полдня выясняет, почему у него UI не срабатывает на halt. «Будь снисходителен» — ложная экономия, когда снисходительность глушит реальную ошибку.</p>
<p>Заодно я починил связанный баг: фильтр <code>after</code> <em>самой</em> останавливающейся итерации тоже не срабатывал (<a href="https://github.com/mellonis/turing-machine-js/issues/108">turing-machine-js#108</a>, часть 1). Управляющий цикл выходил, как только <code>state.isHalt</code> становилось <code>true</code>, — и after-событие от предыдущей итерации, которое в норме сработало бы на этом финальном yield-е, просто проваливалось. В v5 я добавил пост-цикловой добор, чтобы оно сработало.</p>
<p>К этому я ещё вернусь.</p>
<p>И один бонус v5: общий выключатель на <code>run()</code> для всей системы приостановки.</p>
<pre class="shiki shiki-themes github-light github-dark language-javascript" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">await</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> machine.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">run</span><span style="color:#24292E;--shiki-dark:#E1E4E8">({ initialState, debug: </span><span style="color:#005CC5;--shiki-dark:#79B8FF">false</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, onPause: </span><span style="color:#D73A49;--shiki-dark:#F97583">...</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> });</span></span></code></pre>
<p>При <code>false</code> все срабатывания приостановки подавляются вне зависимости от того, что прописано в <code>state.debug</code> по всему графу (<a href="https://github.com/mellonis/turing-machine-js/issues/106">turing-machine-js#106</a>). Можно A/B-тестировать «режим отладки», не переписывая граф и не вычищая каждое поле <code>state.debug</code>. Флаг управляет диспатчем <code>onPause</code>; само поле полезной нагрузки <code>m.debugBreak</code> по-прежнему заполняется на yield-ах генератора (это свойство итерации, а не того, как <code>run()</code> решил его выставить наружу).</p>
<h2 id="oshibka-2-tanets-podstanovki"><a class="heading-anchor" href="#oshibka-2-tanets-podstanovki">Ошибка №2: танец подстановки</a></h2>
<p>Эту я поймал с особой гордостью — код работал, тесты проходили, документация была корректной. Просто дизайн был неправильным.</p>
<p>В v4 и v5 <code>after K</code> (срабатывание after для итерации K) на самом деле происходило на yield-е итерации <strong>K+1</strong>. Управляющий цикл проносил между yield-ами флажок <code>pendingAfterFromPrev</code>, и на yield-е следующей итерации <em>сначала</em> диспатчил хук <code>after</code>, потом хук <code>before</code>, потом <code>onStep</code>. Хук для after-срабатывания K должен был видеть состояние K, а не K+1. Поэтому я <em>подставлял</em> снимок предыдущего yield-а в поле <code>m.state</code> итерации K+1 — только на время диспатча <code>after</code>.</p>
<p>Это работало. Но у этого были три побочки:</p>
<ol>
<li>
<p><strong>Подстановка протекала.</strong> Потребители хотели доступ к неподставленному, «настоящему» состоянию итерации (тому, которое видит хук <code>step</code>), — см. <a href="https://github.com/mellonis/turing-machine-js/issues/107">turing-machine-js#107</a>. Часть пользователей читала <code>MachineState.debugBreak</code> напрямую из <code>runStepByStep</code> и удивлялась, что <code>m.state</code> относится к итерации, на которой <code>after</code> сработал, а не к той, что его породила.</p>
</li>
<li>
<p><strong>Halt требовал отдельной ветки.</strong> Как уже сказано: собственный <code>after</code> останавливающейся итерации срабатывает <em>после</em> выхода из цикла. В v5 для этого появился пост-цикловой добор. И у этого добора своя ветка кода, со своей подстановкой, отдельной от диспатча внутри цикла.</p>
</li>
<li>
<p><strong>Возвращаемый тип генератора расширился.</strong> Поскольку пост-цикловому добору надо было <em>вернуть</em> финальный yield-нутый объект наружу из итератора, тип возврата <code>runStepByStep</code> стал <code>Generator&#x3C;MachineState, MachineState | null></code> — yield-тип <em>и</em> return-тип. Канонический потребитель на <code>for..of</code> return-значения не видит, но всякий, кто читал сигнатуру типа, теперь должен был разбираться, почему слотов два.</p>
</li>
</ol>
<p>Всё это выросло из одного проектного решения: диспатчить <code>after K</code> на yield-е итерации K+1, а не на собственном yield-е итерации K.</p>
<p>В v6 я это схлопнул (<a href="https://github.com/mellonis/turing-machine-js/issues/119">turing-machine-js#119</a>). Жизненный цикл одной итерации теперь честно <code>before → step → after</code>. Срабатывание <code>after</code> приходится на тот же yield, что и итерация, которая его породила. Нет подстановки. Нет <code>pendingAfterFromPrev</code>. Нет пост-циклового добора. Тип возврата генератора сужается обратно до <code>Generator&#x3C;MachineState></code>.</p>
<pre class="shiki shiki-themes github-light github-dark language-javascript" style="background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D;--shiki-dark:#6A737D">// v6</span></span>
<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">await</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> machine.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">run</span><span style="color:#24292E;--shiki-dark:#E1E4E8">({</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  initialState,</span></span>
<span class="line"><span style="color:#6F42C1;--shiki-dark:#B392F0">  onStep</span><span style="color:#24292E;--shiki-dark:#E1E4E8">: (</span><span style="color:#E36209;--shiki-dark:#FFAB70">m</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) </span><span style="color:#D73A49;--shiki-dark:#F97583">=></span><span style="color:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="color:#6A737D;--shiki-dark:#6A737D">/* без изменений */</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#6F42C1;--shiki-dark:#B392F0">  onPause</span><span style="color:#24292E;--shiki-dark:#E1E4E8">: (</span><span style="color:#E36209;--shiki-dark:#FFAB70">m</span><span style="color:#24292E;--shiki-dark:#E1E4E8">) </span><span style="color:#D73A49;--shiki-dark:#F97583">=></span><span style="color:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (m.debugBreak?.before) console.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">log</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(</span><span style="color:#032F62;--shiki-dark:#9ECBFF">'before:'</span><span style="color:#24292E;--shiki-dark:#E1E4E8">, m.state.name);</span></span>
<span class="line"><span style="color:#D73A49;--shiki-dark:#F97583">    if</span><span style="color:#24292E;--shiki-dark:#E1E4E8"> (m.debugBreak?.after)  console.</span><span style="color:#6F42C1;--shiki-dark:#B392F0">log</span><span style="color:#24292E;--shiki-dark:#E1E4E8">(</span><span style="color:#032F62;--shiki-dark:#9ECBFF">'after:'</span><span style="color:#24292E;--shiki-dark:#E1E4E8">,  m.state.name);</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#24292E;--shiki-dark:#E1E4E8">});</span></span></code></pre>
<p>Порядок диспатча <em>между</em> хуками поменялся (любой тест, утверждавший <code>pause(after K-1) → pause(before K) → step(K)</code>, пришлось перевернуть в <code>pause(before K) → step(K) → pause(after K)</code>), но множество вызванных диспатчей и семантика на каждой итерации не изменились. Потребители, относящиеся к хукам как к независимым наблюдателям, не видят никаких различий (<a href="https://github.com/mellonis/turing-machine-js/issues/107">turing-machine-js#107</a> — «отдать наружу неподставленное состояние» — исчез как проблема: подстановки больше нет, обходить нечего).</p>
<p><strong>Правило на следующий раз:</strong> если пейлоаду хука нужно подставлять состояние из другой итерации, а не из той, что сейчас yield-ит, — последовательность диспатча неверна. Фазы жизненного цикла (<code>before</code>, <code>step</code>, <code>after</code>) принадлежат той итерации, которую описывают. Подстановка была не хитрым приёмом, а протечкой — она говорила, что события стоят не на том такте.</p>
<h2 id="chto-ya-zaberu-v-sleduyushchii-api-priostanovki-breikpointov"><a class="heading-anchor" href="#chto-ya-zaberu-v-sleduyushchii-api-priostanovki-breikpointov">Что я заберу в следующий API приостановки/брейкпоинтов</a></h2>
<ol>
<li>
<p><strong>Называйте хуки глаголом потребителя, а не событием движка.</strong> <code>onPause</code>, а не <code>onDebugBreak</code>. <code>onProgress</code>, а не <code>onChunkReceived</code>. Имя расползается по ментальной модели каждого потребителя — выбирайте ту рамку, которую хотите им передать.</p>
</li>
<li>
<p><strong>Кидайте исключение при невозможных конфигурациях — и пораньше.</strong> «Снисходительность к входным данным» подходит для форм, которые <em>могут</em> быть осмысленными; это ловушка для форм, которые осмысленными быть <em>не могут</em>. У <code>haltState.debug.after = true</code> не было возможной семантики. Молчаливое проглатывание стоило одному пользователю полдня — пока я не заметил.</p>
</li>
<li>
<p><strong>Фазы жизненного цикла принадлежат той итерации, которую описывают.</strong> Если хочется диспатчить <code>after K</code> на yield-е K+1 — спросите, какой пейлоад приходится подставлять, чтобы это выглядело правильно. Подстановка — это и есть сигнал.</p>
</li>
<li>
<p><strong>Общий выключатель стоит дёшево и оправдывает себя.</strong> Булева на входной точке, которая выключает всю фичу целиком (без переписывания флажков на уровне графа), делает фичу безопасной для прода и удобной для тестов. <code>run({ debug: false })</code> был одной из самых мелких добавок v5 — и одной из самых востребованных среди зависимых проектов.</p>
</li>
<li>
<p><strong>Ломающие изменения стоят мажора.</strong> Три мажора за две недели звучат дорого. На самом деле — нет, если синхронный downstream — один репозиторий (в моём случае <a href="https://github.com/mellonis/post-machine-js"><code>@post-machine-js/machine</code></a>, который синхронно с движком обновил peer-зависимость <code>^4.0.0</code> → <code>^6.0.0</code> и переименовал свой внутренний <code>__onDebugBreak</code> → <code>__onPause</code>). Эргономика API копится у всех зависимых проектов на весь срок жизни библиотеки — заплатите цену миграции, пока API ещё молодой.</p>
</li>
</ol>
<p>Дизайн v6 — это то, что мне следовало выпустить ещё в v4. Тогда у меня не было нужного словаря — я думал «выставить наружу события движка», а не «спроектировать контракт потребителя». Именно работа над документацией вскрыла обе ошибки. Тут есть мета-урок: документация — самое дешёвое дизайн-ревью, которое можно у себя устроить, но это уже тема для другого поста.</p>
<hr>
<p><em>Код:</em> <a href="https://github.com/mellonis/turing-machine-js"><code>turing-machine-js</code></a> <em>(движок) и</em> <a href="https://github.com/mellonis/post-machine-js"><code>post-machine-js</code></a> <em>(машина Поста поверх него, версии синхронизированы). Интерактивное демо на</em> <a href="https://demo.machines.mellonis.ru">demo.machines.mellonis.ru</a> <em>использует v6-хук</em> <code>onPause</code> <em>для кнопок Step / Run / Stop.</em></p>]]></content:encoded>
    </item>
  </channel>
</rss>