Перейти к содержимому
Инженерия

Терминал, который (почти) никогда не умирает: как мы построили постоянный терминальный демон для Electron

Как мы построили изолированный по процессам хост терминала, который переживает перезапуски приложения, изящно справляется с backpressure и умеет холодное восстановление с диска.

R
Rox ·

Терминальные сессии Rox обслуживает отдельный процесс, поэтому перезапуск интерфейса не закрывает их. Ниже описана архитектура этого механизма. За его реализацию благодарим Andreas Asprou.


Когда мы строили встроенный терминал для Rox, мы столкнулись с неудобной проблемой: терминалы — эфемерные процессы. Это неудобно по многим причинам. Мы любим часто обновляться. Терять прогресс по 10 worktrees при каждом обновлении — серьёзный сдерживающий фактор для обновлений. Или если приложение падает, ты теряешь все свои процессы.

Используя tmux как доказательство существования, мы знали, что неэфемерные терминалы возможны.

Проблема: терминалы в Electron хрупки

В обычной связке Electron + node-pty жизненный цикл твоего терминала выглядит так:

В этом сценарии PTY порождается в главном процессе Node приложения. Этот процесс убивается при закрытии приложения, убивая заодно и свой дочерний процесс.

Заманчивое решение: просто взять tmux

В нашей первой итерации решения мы использовали tmux. Он проверен в бою, нативно справляется с сохранением состояния и имеет относительно простой интерфейс.

Наш рабочий процесс с tmux стал таким:

Мы также использовали tmux в headless-режиме, чтобы это ощущалось нативно в xterm.

** Почему это на самом деле плохо **

  1. Лишняя зависимость. Теперь вдобавок к установке приложения пользователям придётся ставить tmux.
  2. Не кроссплатформенно. Tmux не работает на Windows. Нам пришлось бы строить ещё одну эквивалентную интеграцию.
  3. Несовместимость с 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. Это может показаться избыточным — зачем эмулировать, если нет экрана?

Эмулятор даёт нам:

  1. Точные снимки состояния: когда подключается новый клиент, мы сериализуем текущее состояние экрана, а не просто сырой scrollback. Пользователь видит ровно то, что было на экране, включая положение курсора.

  2. Отслеживание режимов терминала: режим приложения, bracketed paste, отслеживание мыши — всё разобрано и отслежено, чтобы переподключающиеся клиенты получали корректное состояние.

  3. Определение CWD: разбирая escape-последовательности OSC, мы знаем текущую директорию оболочки, даже если сессия была создана несколько часов назад.

Извлечённые уроки

1. Версионирование протокола с первого дня

Когда мы ввели разделение на два сокета, существующие демоны не умели говорить на новом протоколе. Мы обрабатываем это изящно:

Всегда закладывай согласование версий в свои протоколы.

2. Двойное монтирование в React StrictMode — это реальность

StrictMode в React 18 дважды монтирует компоненты в режиме разработки. Наш терминальный компонент:

  1. Монтировался → createOrAttach() → получал холодное восстановление
  2. Размонтировался (очистка StrictMode)
  3. Монтировался снова → createOrAttach() → ???

Если мы перечитываем с диска, флаг холодного восстановления мог уже пропасть (мы записали endedAt). Решение: липкий кеш:

3. Не убивай при отключении

Когда приложение Electron закрывается, мы не убиваем сессии демона:

В этом весь смысл архитектуры с демоном. По умолчанию должно быть сохранение состояния, а не очистка.

4. Лимиты параллелизма предотвращают шторм порождений

Открытие рабочего пространства с 10 терминальными панелями раньше порождало 10 сессий одновременно, перегружая демон. Мы добавили семафор с приоритетом:

Пользователи сначала видят свой активный терминал, фоновые вкладки подгружаются постепенно.

Будущее: облачные бэкенды

Граница абстракции, которую мы построили, нужна не только для локального сохранения состояния. Интерфейс TerminalRuntime нейтрален к провайдеру:

Сегодня LocalTerminalRuntime оборачивает наш демон. Завтра CloudTerminalRuntime мог бы оборачивать SSH-соединения или удалённые tmux-сессии — тот же интерфейс, другой бэкенд. Рендереру не нужно знать, где живёт терминал.


Хочешь копнуть глубже? Загляни в исходники десктопа Rox ради полной реализации.