turing-machine-js — интерпретатор машины Тьюринга. За последние два мажора у движка успело сложиться двухчастное API отладки: одни хуки наблюдают за исполнением, другие координируют его. Про первое — статья «Три мажора, две ошибки: проектирование API приостановки для интерпретатора машины Тьюринга» (1). Про второе — статья «Пауза, двойственная по природе: контракт хука и протокол воркера» (2). Статья (2) остановилась на работающем протоколе между основным потоком и воркером machines-demo: пара сообщений idle/busy, флаг stepRequested, синтетическое событие paused по клику, поле intervalMs в resume. Четыре механизма, каждый закрывает одну конкретную проблему. Эта статья — про следующий шаг.
Предположим, к движку приходит второй потребитель. Не воркер демо, а, скажем, IDE-расширение или встроенный учебный отладчик без Web Worker’а. Что из протокола статьи (2) он унесёт с собой? Сторожевой таймер ему не нужен — нет границы потоков. Очередь сообщений не нужна — обработчики живут в том же потоке. Но «флаг шага», «координация паузы», «события paused по нескольким причинам» — это всё ему понадобится. И он, не разобрав статью (2), переизобретёт всё это заново.
Эта статья — про то, что переизобретать координацию приходится не потому, что воркер «специальный», а потому, что в v6 у координации не было своего места в библиотеке. v7 это место создал — класс-компаньон DebugSession рядом с TuringMachine в том же пакете.
Что переизобрёл воркер
Перечитаем протокол из статьи (2) под новым углом. Четыре механизма:
idle/busyвокруг асинхронной приостановки вonIter— обвязка для сторожевого таймера.- Флаг
stepRequestedвнутри воркера — модель «шага» как одноразового запроса. - Синтетическое событие
pausedпо клику «Пауза» — обработка внешней команды паузы. - Поле
intervalMsвresume— чтение скорости «в момент клика», не «в момент старта».
Разнесём их по принадлежности. Первый и четвёртый — про воркер: сторожевой таймер живёт на основном потоке потому, что у него есть граница между потоками; intervalMs приходит на каждом resume потому, что поле ввода интервала и движок разъехались по разным потокам. Второй и третий — про координацию: модель шага как одноразового события, перевод внешней команды паузы в стандартное событие. Web Worker’ы тут ни при чём — те же самые механизмы понадобились бы в любом отладчике, который переключает движок между паузой и исполнением.
Два механизма из четырёх — кандидаты на подъём из потребителя в библиотеку. Воркеру осталось бы тогда два: обвязка сторожевого таймера и проброс параметра скорости при возобновлении. Это уже не «переизобретение координации», а адаптер между двумя интерфейсами: между протоколом сообщений воркера и API сессии.
Где может жить координация
В v6 координацию можно было разместить в двух местах:
- На стороне потребителя. Каждый новый отладчик строит её заново. Пока потребитель один, выбор места для координации выглядит обоснованным: сравнить не с чем. Двое уже подсветили бы дублирование, трое — подтвердили бы паттерн.
- В хуках движка. Этот соблазн разобран в статье (2): расширение
onStepдо асинхронной сигнатуры (v6.2.0) склеивало наблюдение и координацию. Появление выделенного хукаonIter(v6.4.0) решило задачу «где живёт приостановка», но не сняло принципиального ограничения: хук — это точка уведомления, а не машина состояний. У хука нет своего внутреннего состояния, лайфсайкла, методов управления — он отдаёт обработчику только текущий момент и ждёт его Promise. А координация — это именно машина состояний: «пауза при брейкпоинте → continue / step-in / step-over / step-out / stop». При этом для step-over и step-out момент следующей паузы определяется по глубине стека.
Третий вариант в v6 не появился, потому что вопрос не был ещё вынужденным. С двумя потребителями (или тремя, или N) ответ становится очевидным: координация живёт в соседнем классе при движке. Не вниз по графу зависимостей (в потребителе), не внутри самого класса движка (в виде ещё одного хука), а вбок — в классе-компаньоне на том же уровне библиотеки.
Побочное доказательство: в v7 TuringMachine.run() снова стал синхронным, возвращающим void — без колбэков. В v4 этот же метод стал асинхронным ради асинхронного хука паузы (onDebugBreak, в v5 переименованного в onPause). Подъём координации обнулил эту причину, и сигнатура вернулась в исходное состояние. Если бы движку самому нужна была асинхронность, после исчезновения onPause сигнатура не вернулась бы к прежней форме — а вернулась. Значит, async в run() поддерживался только ради этого хука, а не ради самой логики исполнения.
Чем обладает DebugSession
Класс лежит в packages/machine/src/classes/DebugSession.ts — там же, где TuringMachine. Конструируется напрямую — new DebugSession(machine, {initialState}). Движок про сессию ничего не знает; сессия знает про движок только через его публичный синхронный генератор runStepByStep плюс один пакет-приватный аксессор по символу MACHINE_STATE_INTERNAL, который сосед-модуль может прочитать, а публичный index.ts не реэкспортирует. Тот же паттерн пакет-приватной видимости через Symbol, что и STATE_INTERNAL (turing-machine-js#180) — даёт сессии доступ к снимку halt-стека на каждой итерации, не выпуская его наружу через публичный API.
Сессия слушает четыре события, каждое со своим договором о диспетчеризации:
step— без ожидания Promise. Синхронный, горячий цикл наблюдения; Promise, возвращённый слушателем, не дожидается. Один-в-один контрактonStepиз v6.iter— дожидается. Покадровая координация: приостановка между итерациями, синтез границы шага, всё, ради чего движку нужно дождаться слушателя. Один-в-один контрактonIterиз статьи (2).pause— слушатели вызываются без ожидания Promise (как уstepиhalt: Promise, который вернул бы async-слушатель, не дожидается). Но после их вызова сама сессия блокируется на собственном внутреннем Promise — и пока этот Promise не разрешится, цикл#driveне дёрнет следующий шаг генератора. Разрешает этот Promise один из методов управления сессией:continue()/stepIn()/stepOver()/stepOut()/stop(). Все они по дизайну вызываются откуда-то снаружи — из клика в UI, из сообщения от основного потока (когда сессия живёт в воркере) или по таймеру.halt— без ожидания Promise. Терминальное событие сессии.
Управление сессией зеркалит подходы DevTools там, где это имеет смысл. pause() — внешний запрос паузы; срабатывает на before-стороне следующей итерации с cause: ’manual’. stepIn() — пауза на следующей итерации. stepOver() — пауза на следующей итерации с depth <= clickTimeDepth: фреймы, которые текущая итерация уложила в стек, доигрываются до возврата без остановки. stepOut() — пауза, когда глубина halt-стека упадёт ниже момента клика (depth < clickTimeDepth): текущая обёртка закончилась, управление перешло в её override. stepOut() из глубины 0 бросает исключение: «выйти не из чего» — это ошибка в коде потребителя, а не молчаливая no-op. setRunInterval(ms) — задаёт покадровую задержку между итерациями. stop() — терминал, сессия завершается. Сессия одноразовая: start() вызывается один раз; для повторного прогона — новая сессия.
Обнаружение брейкпоинтов тоже переехало. В v6 генератор runStepByStep сам решал, какая итерация «должна выстрелить» по state.debug. В v7 генератор отдаёт минимальное MachineState, а сессия через MACHINE_STATE_INTERNAL читает текущий символ ленты, проверяет его на попадание в фильтры before/after у state.debug с помощью matchFilter и сама диспетчеризует pause с cause: ’breakpoint’. У движкового генератора больше нет «отладочной» логики — он чистое исполнение. У сессии — все три причины паузы (breakpoint, step, manual) в одном описании PauseInfo { side, cause } с порядком приоритета breakpoint > step > manual, если несколько причин совпали на одной итерации.
Это и есть «координация как машина состояний», которая не помещалась в хук: внутреннее состояние (Promise паузы, который держит движок остановленным; активный режим step-in/over/out; зафиксированная при клике глубина halt-стека), методы управления, лайфсайкл — всё в одном объекте, у которого есть собственный конструктор и собственный метод start().
Что осталось в воркере
После подъёма координации воркер не оказался пустым. За что он отвечает теперь:
- Сама граница Web Worker’а — протокол сообщений, сэндбокс пользовательского кода, лайфсайкл запросов на сборку, прогон, шаг. Воркер — естественное место для этого слоя.
- Покадровая задержка, обёрнутая сообщениями
idle/busyдля сторожевого таймераWORKER_TIMEOUT_MS. У сессии для задержки между итерациями есть свойsetRunInterval(ms). Но воркеру он не годится: эта задержка выполняется внутри#drive-цикла сессии, и обернуть её парой сообщенийidle/busyснаружи невозможно — а без обвязки сторожевой таймер на основном потоке сработает на легитимной задержке как на зависании. Поэтому воркер реализует задержку в собственном обработчике событияiter(которое сессия дожидается асинхронно): внутри обработчика пара сообщенийidleиbusyоборачиваетawait setTimeout(intervalMs)ровно там, где нужно. - Хранение и обновление
intervalMs. Семантика «withPauseчитается в момент клика» из статьи (2) никуда не делась — поле интервала в UI это UI-уровень. Воркер держит актуальное значение в своей локальной переменной и обновляет его на каждом сообщенииresumeот основного потока. - Трансляция UI-кликов в методы сессии: клик «Пауза» →
activeSession.pause(), клик «Шаг» →activeSession.stepIn(), клик «Продолжить» →activeSession.continue(). Воркер больше не синтезирует событиеpausedизonIter— все паузы (брейкпоинт, шаг, ручная) возвращаются через единственное событиеpauseсессии с дискриминантомcause.
Граница чистая. Осталось то, чем сессия не может владеть, не залезая на территорию потребителя. Ушло то, что воркер-специфичным никогда не было — это была координация, замаскированная под воркер-специфику. Другой потребитель — встроенный в IDE отладчик, DAP-сервер, учебная демонстрация без Worker’а — соберёт DebugSession иначе (без postMessage, без сторожевого таймера), но с тем же самым API сессии.
Подъём обобщается
PostMachine.debugRun() возвращает PostDebugSession, который оборачивает движковый DebugSession. Те же четыре события, те же методы шага, тот же лайфсайкл. Сверху — оборачивание MachineState в post-специфичные arrivalPath / candidatePaths и фильтрация по реестру брейкпоинтов PostMachine перед прокидыванием события pause наружу. Воркер демо обрабатывает обе сессии через структурный тип SessionLike — оба выглядят как одно и то же снаружи.
Это и есть компаньон-компаньона: паттерн обобщается на один уровень выше. Библиотека движка один раз поднимает координацию в свой класс-компаньон; следующая библиотека сверху поднимает свою специфику в свой класс-компаньон, оборачивающий движковый. Доказательство того, что «класс-компаньон» — не одноразовый трюк под одну библиотеку, а воспроизводимая форма.
Что из этого следует
В линейке статей про API движка вырисовываются три урока подряд:
- Из статьи (1): именование лайфсайкла фиксирует решения о форме API — назовите состояния правильно, и поверхность сама окажется в нужной форме.
- Из статьи (2): наблюдение и координация — разные задачи, и каждой нужен свой договор хука со своей семантикой диспетчеризации.
- Из этой статьи: когда N потребителей переизобретают одну и ту же координационную обвязку поверх ваших хуков, ваш хук-контракт не неполный — он на не той гранулярности. Координационная машина состояний — это сосед хуков, а не их расширение.
Диагностика для автора библиотеки: если приходится расширять сигнатуру хука, чтобы потребитель смог сделать ту работу, которая у него и так есть (мажор v6.2.0 из статьи (2)), — или если форма обработчика повторяется из потребителя в потребителя без существенных различий (тот случай, который и подтолкнул к этому рефакторингу), — значит, от хука просят то, чего хук в принципе сделать не может.
Для движка это вылилось в чёткую границу: публичный синхронный runStepByStep, его координационный компаньон DebugSession, и любой потребитель — воркер демо, DAP-сервер, in-process отладочная панель — пишет адаптер между своей точкой входа и API сессии, а не координационный слой заново.
Код: turing-machine-js (движок, v7 — DebugSession), post-machine-js (PostDebugSession) и machines-demo (воркер демо, переписанный вокруг сессии в PR #80).