Терминал, который (почти) никогда не умирает: как мы построили постоянный терминальный демон для Electron
Как мы построили изолированный по процессам хост терминала, который переживает перезапуски приложения, изящно справляется с backpressure и умеет холодное восстановление с диска.

Одна из самых изящных фич Rox — то, как наш терминал переживает перезапуски приложения. Это глубокое погружение в то, как мы это построили. Огромная благодарность Andreas Asprou, который возглавил всё это начинание.
Когда мы строили встроенный терминал для Rox, мы столкнулись с неудобной проблемой: терминалы — эфемерные процессы. Это неудобно по многим причинам. Мы любим часто обновляться. Терять прогресс по 10 worktrees при каждом обновлении — серьёзный сдерживающий фактор для обновлений. Или если приложение падает, ты теряешь все свои процессы.
Используя tmux как доказательство существования, мы знали, что неэфемерные терминалы возможны.
Проблема: терминалы в Electron хрупки
В обычной связке Electron + node-pty жизненный цикл твоего терминала выглядит так:
В этом сценарии PTY порождается в главном процессе Node приложения. Этот процесс убивается при закрытии приложения, убивая заодно и свой дочерний процесс.
Заманчивое решение: просто взять tmux
В нашей первой итерации решения мы использовали tmux. Он проверен в бою, нативно справляется с сохранением состояния и имеет относительно простой интерфейс.
Наш рабочий процесс с tmux стал таким:
Мы также использовали tmux в headless-режиме, чтобы это ощущалось нативно в xterm.
** Почему это на самом деле плохо **
- Лишняя зависимость. Теперь вдобавок к установке приложения пользователям придётся ставить tmux.
- Не кроссплатформенно. Tmux не работает на Windows. Нам пришлось бы строить ещё одну эквивалентную интеграцию.
- Несовместимость с xterm. Будучи сам по себе симулятором терминала, tmux забирает себе скроллбар, выделение, хоткеи и тому подобное. Этот перехват внутри xterm делает работу крайне топорной.
Нам был нужен другой путь.
Архитектура: отсоединяемый демон
Наше решение разделяет управление терминалом на три процессных слоя:
При такой архитектуре приложение Electron становится просто клиентом терминального демона. Оно может:
- Перезапустить приложение → переподключиться к работающим сессиям
- Открыть несколько окон → все подключаются к одному и тому же демону
- Упасть и восстановиться → холодное восстановление из истории на диске
Порождение демона
Демон — это процесс Node.js, порождаемый хитрым трюком Electron:
Установка ELECTRON_RUN_AS_NODE=1 говорит Electron вести себя как обычный рантайм Node.js — идеально для фонового сервиса, которому не нужен Chromium.
Протокол: NDJSON поверх Unix-сокетов
Обмен между главным процессом и демоном использует JSON с разделителями-переводами строк поверх Unix-domain-сокетов:
Почему Unix-сокеты? Они быстры (нет накладных расходов TCP), безопасны (права доступа к файлам) и нативно поддерживают backpressure через буферы ядра.
Разделение на два сокета
Наша первая версия протокола использовала один сокет на всё. Это вызвало мерзкую проблему: блокировку начала очереди (head-of-line blocking).
Когда терминал выдаёт вывод быстрее, чем сокет успевает его сливать, буфер ядра заполняется. Каждый socket.write() для событий с данными блокировался бы, выстраивая за собой в очередь любые ответы RPC. Результат? Пользователь открывает новый терминал, но createOrAttach отваливается по таймауту, потому что ответ застрял за мегабайтами вывода cat bigfile.log.
Протокол v2 разделяет обмен:
Теперь stream-сокет может забиваться независимо, пока RPC остаётся отзывчивым.
Жизненный цикл сессии: создать, подключить, пережить, восстановить
Сессия проходит через чётко определённые состояния:
Вся магия — в семантике подключения:
Холодное восстановление: возвращение после падений демона
Когда демон умирает (перезагрузка машины, падение, kill -9), сессии теряются. Но у нас по-прежнему есть история на диске:
При следующем запуске приложения мы обнаруживаем нечистое завершение (нет endedAt в метаданных) и предлагаем холодное восстановление:
Это даёт пользователям лучшее из обоих миров: они видят, что было до падения, и могут вроде как продолжить с того места, где остановились.
Backpressure: скрытый вызов
Терминалы могут выдавать вывод быстро. Простой cat /dev/urandom | base64 затопит любой буфер, который ты ему подставишь. Без аккуратной обработки backpressure ты получаешь:
- Исчерпание памяти (неограниченные очереди)
- Зависания UI (заблокированные циклы событий)
- Потерю данных (отброшенные записи)
Мы реализуем многоуровневый backpressure от PTY до UI:
PTY-подпроцесс агрессивно батчит вывод:
Это позволяет избежать проблемы O(n²) при конкатенации строк, сохраняя при этом визуальные обновления на уровне ~30 кадров/с.
Headless-эмулятор: состояние без экрана
Каждая сессия демона запускает headless-эмулятор xterm.js. Это может показаться избыточным — зачем эмулировать, если нет экрана?
Эмулятор даёт нам:
-
Точные снимки состояния: когда подключается новый клиент, мы сериализуем текущее состояние экрана, а не просто сырой scrollback. Пользователь видит ровно то, что было на экране, включая положение курсора.
-
Отслеживание режимов терминала: режим приложения, bracketed paste, отслеживание мыши — всё разобрано и отслежено, чтобы переподключающиеся клиенты получали корректное состояние.
-
Определение CWD: разбирая escape-последовательности OSC, мы знаем текущую директорию оболочки, даже если сессия была создана несколько часов назад.
Извлечённые уроки
1. Версионирование протокола с первого дня
Когда мы ввели разделение на два сокета, существующие демоны не умели говорить на новом протоколе. Мы обрабатываем это изящно:
Всегда закладывай согласование версий в свои протоколы.
2. Двойное монтирование в React StrictMode — это реальность
StrictMode в React 18 дважды монтирует компоненты в режиме разработки. Наш терминальный компонент:
- Монтировался →
createOrAttach()→ получал холодное восстановление - Размонтировался (очистка StrictMode)
- Монтировался снова →
createOrAttach()→ ???
Если мы перечитываем с диска, флаг холодного восстановления мог уже пропасть (мы записали endedAt). Решение: липкий кеш:
3. Не убивай при отключении
Когда приложение Electron закрывается, мы не убиваем сессии демона:
В этом весь смысл архитектуры с демоном. По умолчанию должно быть сохранение состояния, а не очистка.
4. Лимиты параллелизма предотвращают шторм порождений
Открытие рабочего пространства с 10 терминальными панелями раньше порождало 10 сессий одновременно, перегружая демон. Мы добавили семафор с приоритетом:
Пользователи сначала видят свой активный терминал, фоновые вкладки подгружаются постепенно.
Будущее: облачные бэкенды
Граница абстракции, которую мы построили, нужна не только для локального сохранения состояния. Интерфейс TerminalRuntime нейтрален к провайдеру:
Сегодня LocalTerminalRuntime оборачивает наш демон. Завтра CloudTerminalRuntime мог бы оборачивать SSH-соединения или удалённые tmux-сессии — тот же интерфейс, другой бэкенд. Рендереру не нужно знать, где живёт терминал.
Хочешь копнуть глубже? Загляни в исходники десктопа Rox ради полной реализации.
Похожие материалы
Git worktrees: фича, которая десять лет ждала своего часа
Git worktrees существуют с 2015 года. Большую часть этого времени они были диковинкой. Теперь у них настал звёздный час. Это история о том, как они появились и почему вдруг стали важны.

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