До сих пор башня жила внутри Effect. Единственный выход наружу был в уроке 36: sendLine оборачивал промис провода в Effect.tryPromise, и отказ провода становился SendFailed в типе. С чтением так не выйдет: у старого провода нет промиса, есть только функция, которую он позовёт, когда строка придёт.
Этот урок о мосте в обе стороны. Внутрь: как превратить колбэк и промис в квитанцию, чтобы ошибка попала в E, а не в консоль. Наружу: где квитанцию можно запустить и превратить обратно в промис, и почему такое место в программе одно.
Провод на колбэках
Вот что Магистраль отдаёт башне:
export interface CallbackWire {
readonly read: (callback: (error: Error | null, line?: string) => void) => void;
}
Это колбэк с ошибкой первым аргументом: провод зовёт callback(null, строка), когда строка пришла, и callback(new Error(...)), когда провод оборвался. Бывает и третий ответ, о котором сигнатура молчит: ни ошибки, ни строки. Старый провод так отвечает, когда на линии тишина.
Ринцвальд уже обернул провод. По-городскому, промисом:
const readLine = (wire: CallbackWire): Promise<string> =>
new Promise((resolve) => {
wire.read((error, line) => {
if (error) throw error;
resolve(line!);
});
});
// провод для опыта отвечает по списку, каждый ответ через 10 мс
declare const oldWire: (answers: ReadonlyArray<readonly [Error | null, string?]>) => CallbackWire;
const main = async () => {
try {
console.log(await readLine(oldWire([[null, 'T-3->T-1|1|Ping|']])));
console.log(await readLine(oldWire([[null]])));
console.log(await readLine(oldWire([[new Error('обрыв')]])));
} catch (error) {
console.log('поймал:', error);
}
};
void main();
T-3->T-1|1|Ping|
undefined
rintsvald.ts:8
if (error) throw error;
^
Error: обрыв
Первая строка пришла как надо. Вторая пришла как undefined, хотя в типе написано Promise<string>: восклицательный знак в line! сказал компилятору «поверь мне», и компилятор поверил. Третья хуже. throw стоит внутри колбэка, а колбэк провод зовёт позже, из таймера, когда new Promise давно вернулся. Исключению некуда идти: промис его не видит, catch в main до него не доходит. Node падает целиком. Браузер пишет ошибку в консоль, а промис так и висит неразрешённым, и await ждёт вечно.
Это скоропись в чистом виде: ошибка есть, но к тому, кто ждёт ответа, она не попадает.
Effect.callback
У Effect для таких проводов есть Effect.callback. Ему дают функцию register, а он отдаёт ей resume: «позови, когда будет ответ». Отвечают через resume не значением, а квитанцией. Успех это resume(Effect.succeed(строка)), провал это resume(Effect.fail(ошибка)). Успех и провал идут одной дорогой.
Перевод с Ук
Первая попытка, сразу с ошибкой в типе:
import { Effect } from 'effect';
import { WireDown } from './errors';
import type { CallbackWire } from './tower';
export const readLine = (wire: CallbackWire) =>
Effect.callback((resume) => {
wire.read((error, line) => {
if (error !== null) resume(Effect.fail(new WireDown({ reason: error.message })));
else if (line === undefined) resume(Effect.fail(new WireDown({ reason: 'пустой ответ' })));
else resume(Effect.succeed(line));
});
});
wire.ts(9,34): error TS2379: Argument of type 'Effect<never, WireDown, never>' is not assignable to parameter of type 'Effect<unknown, never, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Type 'WireDown' is not assignable to type 'never'.
wire.ts(10,43): error TS2379: Argument of type 'Effect<never, WireDown, never>' is not assignable to parameter of type 'Effect<unknown, never, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Type 'WireDown' is not assignable to type 'never'.
Читаем снизу вверх. Последняя строка каждой ошибки: WireDown не подходит к never. Строка выше говорит, где: в вызове resume, строки 9 и 10, там, где мы отвечаем провалом. resume ждёт Effect<unknown, never, never>, то есть квитанцию, которая не падает, а мы даём Effect<never, WireDown, never>.
Откуда взялся never? Effect.callback объявлен как callback<A, E = never, R = never>, и вывести A и E ему не из чего. Компилятор не заглядывает внутрь колбэка провода и не собирает типы из вызовов resume: для него это обычное тело функции. Поэтому A стал unknown, а E взял значение по умолчанию, never. Квитанция, которая по типу не падает, не может ответить провалом.
Совет в конце первой строки про undefined и exactOptionalPropertyTypes к делу не относится. Компилятор дописывает его, когда этот флаг включён, а в типе, куда не подошло значение, есть необязательные поля; у Effect они есть. Собери тот же файл без флага, и выйдет ошибка TS2345 без всякого совета и с той же последней строкой. Верь последней строке.
Лечится тем, что тип ответа пишут сами:
export const readLine = (wire: CallbackWire): Effect.Effect<string, WireDown> =>
Effect.callback<string, WireDown>((resume) => {
wire.read((error, line) => {
if (error !== null) resume(Effect.fail(new WireDown({ reason: error.message })));
else if (line === undefined) resume(Effect.fail(new WireDown({ reason: 'пустой ответ' })));
else resume(Effect.succeed(line));
});
});
Тишина на линии стала таким же обрывом, как ошибка, только со своей причиной. line! больше не нужен: проверка line === undefined сузила тип, и в последней ветке line уже string.
Тот же провод, те же три ответа, но теперь все три приходят по адресу:
const wire = oldWire([[null, 'T-3->T-1|1|Ping|'], [null], [new Error('обрыв')]]);
const three = Effect.forEach([1, 2, 3], () =>
Effect.match(readLine(wire), {
onSuccess: (line) => line,
onFailure: (error) => `WireDown: ${error.reason}`,
}),
);
void Effect.runPromise(three).then((answers) => console.log(answers));
[ 'T-3->T-1|1|Ping|', 'WireDown: пустой ответ', 'WireDown: обрыв' ]
Effect.match разбирает исход квитанции на две ветки и не падает: для опыта нам нужно увидеть все три ответа, а не остановиться на первом обрыве.
У resume три правила, и тесты этапа проверяют все три.
- Ответ один. Если провод позовёт колбэк дважды, квитанция возьмёт первый ответ, а второй Effect пропустит. Наш провод на каждый
readотвечает одной строкой, поэтому за следующей строкой зовутreadзаново. - Ответ может прийти сразу. Если провод зовёт колбэк синхронно, прямо внутри
read, всё работает так же. - Бросок внутри
registerэто дефект. Еслиwire.readсам бросит исключение, квитанция кончитсяDie, какEffect.syncиз урока 40. Ожидаемые ошибки отдают черезresume(Effect.fail(...)), а не черезthrow.
Под капотом
Наш IO.callback из урока 33 получал register(resume) и мог вернуть отменялку. Считался только первый звонок, синхронный resume не запускал новый цикл изнутри старого. Настоящий Effect.callback устроен так же, и у него две добавки.
Первая: register получает вторым аргументом AbortSignal. Это стандартный сигнал отмены из JavaScript, его понимают fetch и другие API, которые умеют прерываться. Если свиток прервут, пока он ждёт, сигнал сработает. Вторая: вместо отменялки register возвращает эффект очистки, обычную квитанцию, которую Effect исполнит при прерывании. Очистка тоже квитанция, поэтому её можно собрать из шагов, например записать в журнал, что ожидание бросили.
import { Effect, Fiber } from 'effect';
const log: string[] = [];
const waitFor = (ms: number) =>
Effect.callback<void>((resume, signal) => {
const timer = setTimeout(() => resume(Effect.void), ms);
signal.addEventListener('abort', () => log.push('signal: abort'));
return Effect.sync(() => {
clearTimeout(timer);
log.push('таймер убран');
});
});
const main = async () => {
const fiber = Effect.runFork(waitFor(1000));
await new Promise((resolve) => setTimeout(resolve, 10));
await Effect.runPromise(Fiber.interrupt(fiber));
console.log(log);
};
void main();
[ 'signal: abort', 'таймер убран' ]
У старого провода Магистрали отменять нечего: read не умеет забыть, что его спросили. Поэтому наш readLine ничего не возвращает из register. Если провод когда-нибудь получит cancel, место для него уже есть.
Промисы: tryPromise и promise
С промисами мост короче, и одна его половина тебе знакома. Effect.tryPromise из урока 36 берёт функцию, которая создаёт промис, и функцию catch, которая превращает отказ в ошибку дела. Функция в try тоже получает AbortSignal: если API промиса умеет отмену, сигнал можно передать ему.
Вторая половина это Effect.promise. Она для промисов, которые по договору не отказывают. Если такой промис всё же откажет, это не ошибка дела, а дефект:
const exit = await Effect.runPromiseExit(Effect.promise(() => Promise.reject(new Error('отказ'))));
console.log(Exit.isFailure(exit) ? exit.cause.reasons.map((reason) => reason._tag) : exit);
[ 'Die' ]
Выбирают между ними так же, как между Effect.try и Effect.sync: может ли отказ случиться по делу. Провод отказывает по делу, поэтому sendLine написан на tryPromise, а ошибка лежит в E. Промис, который по договору всегда разрешается, оборачивают в promise, и если договор нарушен, сломан чужой код, а не наше дело.
Край программы
Мост внутрь готов. Теперь наружу. Квитанция сама ничего не делает, её надо запустить: Effect.runPromise превращает её в промис, Effect.runSync исполняет на месте. Вопрос в том, где запускать.
Ответ Академии: в одном месте, на краю программы. Внутри все функции возвращают квитанции и собирают их друг из друга. Запуск один, там, где программу зовёт внешний мир. У башни это runNight, единственный runPromise во всём изделии:
export const runNight = (tower: Tower, lines: readonly string[]): Promise<readonly string[]> =>
Effect.runPromise(
Effect.all(
lines.map((line) => Effect.map(Effect.exit(handleChecked(tower, line)), describeExit)),
),
);
const tower: Tower = { send: async () => {}, sent: new Set([1]) };
console.log(await runNight(tower, ['T-3->T-1|1|Ping|', 'мусор', 'T-7->T-1|2|Ack|40']));
[ 'ok', 'fail:GarbledFrame', 'fail:PhantomAck' ]
Effect.exit превращает исход каждого кадра в значение, а describeExit из урока 41 пишет его словами. Квитанция, которую запускает runNight, по типу не падает, поэтому её промис не отказывает из-за мусора на проводе: мусор лежит в ответе строкой fail:GarbledFrame.
Зачем так строго? На краю тип ошибки кончается. Если запустить квитанцию с WireDown в E, runPromise откажет с самим объектом WireDown, но в catch (error) у error тип unknown: промис не умеет нести тип отказа. Всё, что Effect знал об ошибках, имена, теги, разницу между ошибкой и дефектом, на краю превращается в «что-то бросили». Чем ближе запуск к краю, тем больше кода видит ошибки в типе.
Где тут дыра
import { Effect, Exit } from 'effect';
import { handleChecked, type Tower } from './tower';
const tower: Tower = { send: async () => {}, sent: new Set([1]) };
// тип: Effect<Outcome[], never, never>
const handleAll = (lines: readonly string[]) =>
Effect.forEach(lines, (line) =>
Effect.promise(() => Effect.runPromise(handleChecked(tower, line))),
);
const exit = await Effect.runPromiseExit(handleAll(['T-3->T-1|1|Ping|', 'мусор']));
if (Exit.isFailure(exit)) {
for (const reason of exit.cause.reasons) console.log(reason._tag, reason._tag === 'Die' ? reason.defect : '');
}
Die { line: 'мусор', _tag: 'GarbledFrame' }
На одной строке Ping всё работает. Ночь с мусором показывает дыру. runPromise внутри обработчика вывел квитанцию наружу посреди программы, и тип ошибки кончился прямо там: у handleAll в E стоит never, как будто ошибок не бывает. Промис отказал с GarbledFrame, а Effect.promise по договору считает отказ дефектом. Ожидаемая ошибка дела, у которой было имя и тег, приехала как Die. Effect.catchTag(handleAll(...), 'GarbledFrame', ...) даже не соберётся: в типе нет ни одного тега, который можно поймать.
И это не всё. Каждый внутренний runPromise запускает свой файбер, о котором внешний ничего не знает. Прерви внешний свиток, и внутренний доработает до конца: кадр уйдёт по проводу уже после того, как башню остановили. Тот же Promise.race Ринцвальда из урока 35, только теперь внутри Effect.
Лечится тем, что запуск уезжает на край, а внутри остаются квитанции:
const handleAll = (lines: readonly string[]) =>
Effect.forEach(lines, (line) => handleChecked(tower, line));
Тип стал Effect<Outcome[], TowerError>, мусор приходит как fail:GarbledFrame, catchTag снова работает, и прерывание доходит до каждого кадра.
Башня, этап 10
Башня учится читать старый провод Магистрали. readLine превращает колбэк в квитанцию с WireDown в типе, readLines читает строки подряд и останавливается на первом обрыве, runNight прогоняет ночь и становится единственным местом, где квитанция превращается в промис. Тексты причин обрыва и формат ответа runNight в карточке заказа.
Практика
Глубже в архиве
Соглашение «ошибка первым аргументом» и то, почему исключение из колбэка не долетает до того, кто ждёт, разбирает урок «Колбеки и event loop».
Дальше
Башня читает старый провод, разбирает кадры, отвечает на них, отличает ошибку от дефекта, отбрасывает дубли и запускается в одном месте. Рыдкулл перечитывает распечатку Магистрали, которая лежит у него на столе с урока 36, и откладывает её.
Ночь целиком, с отчётом, где у каждой строки провода есть своё место, ждёт в следующем уроке.