Раздел 46 · Effect: Тихая Академия

Край программы: runMain и ManagedRuntime

middle-senior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В уроке 45 край программы был одним Effect.runPromise. Для ночи, которая идёт до утра, этого мало: процесс должен уметь остановиться по сигналу, а финализаторы из прошлых уроков должны успеть отработать. Этот урок о двух видах края. Первый: башня сама себе программа, от запуска до кода выхода. Второй: башня живёт внутри чужой программы и обслуживает её запросы.

Код выхода

Программа сообщает операционной системе, чем кончилась, числом: кодом выхода. Effect считает его по Exit главной квитанции:

  • 0 для успеха;
  • 130 для провала, в причине которого только прерывания: программу погасили сигналом;
  • 1 для всего остального.

Число 130 не выдумано Effect: так оболочки Unix сообщают, что процесс остановили по Ctrl+C.

runMain

В Node программу на Effect запускают через NodeRuntime.runMain из пакета @effect/platform-node. Он делает то, что нужно ночному сторожу: запускает главную квитанцию, слушает SIGINT и SIGTERM, по сигналу прерывает её, ждёт финализаторов и выходит с кодом по правилам выше.

В ядре effect своего runMain нет: платформы разные, и сигналы в браузере, Node и Bun устроены по-своему. Есть заготовка, из которой его собирает каждая платформа, Runtime.makeRunMain. Соберём из неё свой, чтобы увидеть, что внутри:

/** Хозяин программы: умеет сказать «гаси» и принять код выхода. */
export interface Host {
  readonly onSignal: (stop: () => void) => void;
  readonly exit: (code: number) => void;
}

export const runMainOn = (host: Host) =>
  Runtime.makeRunMain(({ fiber, teardown }) => {
    host.onSignal(() => void Effect.runFork(Fiber.interrupt(fiber)));
    fiber.addObserver((exit) => teardown(exit, host.exit));
  });

makeRunMain запускает квитанцию в главном файбере и отдаёт его тебе вместе с teardown. Дальше два шага. По сигналу хозяина файбер прерывают: финализаторы исполняются с исходом interrupt. Когда файбер кончился, teardown считает код выхода и отдаёт его хозяину. Для Node хозяин это { onSignal: (stop) => process.once('SIGINT', stop), exit: (code) => process.exit(code) }, для теста объект, который сигналит руками:

const ok = host('успех');
runMainOn(ok)(Effect.log('ночь прошла'));
const failed = host('провал');
runMainOn(failed)(Effect.fail('обрыв'), { disableErrorReporting: true });
const night = host('сигнал');
runMainOn(night)(Effect.never.pipe(Effect.ensuring(Effect.log('линия закрыта'))));
setTimeout(() => night.signal(), 50);
[19:48:30.058] INFO (#1): ночь прошла
успех: код 0
провал: код 1
[19:48:30.112] INFO (#3): линия закрыта
сигнал: код 130

Ночь, которая шла бы вечно, получила сигнал, закрыла линию и вышла с кодом 130. disableErrorReporting выключает запись о необработанной ошибке в журнал Effect: по умолчанию runMain её печатает, и для настоящей программы это правильно.

Где тут дыра

process.on('SIGINT', () => process.exit(0));

void Effect.runPromise(
  Effect.never.pipe(Effect.ensuring(Effect.sync(() => console.log('линия закрыта')))),
);
setTimeout(() => process.kill(process.pid, 'SIGINT'), 50);
код выхода: 0

Строки «линия закрыта» нет. process.exit убивает процесс сразу: квитанция не получила прерывания, финализаторы не исполнились, линия осталась открытой на складе. А код выхода врёт: ноль значит «всё хорошо», и планировщик, который перезапускает упавшие программы, решит, что ночь прошла как надо.

Лечится тем, что сигнал превращают в прерывание главного файбера, а выход оставляют тому, кто дождался финализаторов. Ровно это делает runMain.

ManagedRuntime

Второй край: башня внутри чужого сервера. Сервер Почтамта зовёт обработчики на каждый запрос, и промисы он понимает, а квитанции нет. Наивный ответ из урока 50, Effect.provide(слой) в каждом обработчике, собирает башню на каждый запрос: новый журнал, новая линия, склад ругается.

ManagedRuntime собирает слой один раз и исполняет на нём сколько угодно квитанций:

export const serve = <E>(layer: Layer.Layer<Wire | Ledger | Journal | CallSign | TowerKey, E>) => {
  const runtime = ManagedRuntime.make(layer);
  return {
    night: (lines: readonly string[]): Promise<Watch> => runtime.runPromise(watch(lines)),
    close: (): Promise<void> => runtime.dispose(),
  };
};
const desk = serve(tower);
console.log((await desk.night(['T-5->T-1|10|Ping|'])).journal.length, log);
console.log((await desk.night(['T-5->T-1|11|Ping|'])).journal.length, log);
await desk.close();
console.log(log);
4 [ 'открыт линия' ]
8 [ 'открыт линия' ]
[ 'открыт линия', 'закрыт линия: ok' ]

Линия открылась один раз, при первой ночи. Журнал общий: во второй ночи в нём восемь строк, четыре от первой. close разобрал слой, и склад получил линию назад. Обработчики сервера зовут desk.night, и снаружи это обычные промисы.

ManagedRuntime.make собирает слой лениво, при первом запуске. Ошибка сборки, тут E, всплывёт в первом же runPromise. А dispose нужно позвать, когда сервер гасят: это его способ сказать «смена кончилась».

Под капотом

Внутри ManagedRuntime три вещи: область, таблица MemoMap и собранный в этой области контекст. runPromise(квитанция) исполняет её с этим контекстом. Вложенные Effect.provide внутри квитанций пользуются той же таблицей, поэтому слои, уже собранные рантаймом, не собираются заново: правило 1 из урока 50 работает и здесь. dispose закрывает область, и финализаторы слоя исполняются.

Башня, этап 23

Башня получает два края. Свой runMain: сигнал хозяина прерывает главный файбер, финализаторы отрабатывают, хозяин получает код выхода. И стол для чужого сервера: слой собирается один раз на все ночи и разбирается по close.

Практика

Дальше

Пондер показывает Рыдкуллу записи склада за ночь.

Общие линии, которые живут, пока ими пользуются, ждут в следующем уроке.