В уроке 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.
Практика
Дальше
Пондер показывает Рыдкуллу записи склада за ночь.
Общие линии, которые живут, пока ими пользуются, ждут в следующем уроке.