diff --git a/src/effected.ts b/src/effected.ts index 3b1bc99..1522eee 100644 --- a/src/effected.ts +++ b/src/effected.ts @@ -2614,86 +2614,84 @@ export function runAsync>( : UnhandledEffect> : never, ): Promise ? ExtractDefaultTerminateType | R : never> { - const iterator = (effected as Iterable)[Symbol.iterator](); - const context = { - interceptIterator: null as typeof iterator | null, - terminated: false as false | "with-value" | "without-value", - terminatedValue: undefined as unknown, - }; - return new Promise((resolve, reject) => { - const iterate = (...args: [] | [unknown]) => { - if (context.terminated) return context.terminatedValue; + const iterator = (effected as Iterable)[Symbol.iterator](); + const context = { + interceptIterator: null as typeof iterator | null, + terminated: false as false | "with-value" | "without-value", + terminatedValue: undefined as unknown, + }; - let done: boolean | undefined; - let value: any; + const iterate = (...args: [] | [unknown]) => { try { - ({ done, value } = (context.interceptIterator || iterator).next(...args)); - } catch (e) { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(e); - return; - } - - // We use a while loop to avoid stack overflow when there are many synchronous effects - while (!done) { - if (!value) { - reject( - new Error( - `Invalid effected program: an effected program should yield only effects (received ${stringify(value)})`, - ), - ); - return; - } - if (value instanceof Effect) { - while (value instanceof Effect && typeof (value as any).defaultHandler === "function") { - value = handleEffect(context, effect.name, value, (value as any).defaultHandler).value; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (context.terminated) return context.terminatedValue; + if (context.terminated) return context.terminatedValue; + + let { done, value } = (context.interceptIterator || iterator).next(...args); + + // We use a while loop to avoid stack overflow when there are many synchronous effects + while (!done) { + if (!value) { + reject( + new Error( + `Invalid effected program: an effected program should yield only effects (received ${stringify(value)})`, + ), + ); + return; } if (value instanceof Effect) { - reject(new UnhandledEffectError(value, `Unhandled effect: ${stringifyEffect(value)}`)); - return; + while (value instanceof Effect && typeof (value as any).defaultHandler === "function") { + value = handleEffect( + context, + effect.name, + value, + (value as any).defaultHandler, + ).value; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (context.terminated) return context.terminatedValue; + } + if (value instanceof Effect) { + reject( + new UnhandledEffectError(value, `Unhandled effect: ${stringifyEffect(value)}`), + ); + return; + } } - } - if (value._effectSync) { - try { + if (value._effectSync) { ({ done, value } = (context.interceptIterator || iterator).next( ...("value" in value ? [value.value] : []), )); - } catch (e) { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(e); - return; - } - continue; - } - if (value._effectAsync) { - if (value.interruptable) { - let resolve!: (value: unknown) => void; - const promise = new Promise((_resolve) => (resolve = _resolve)); - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - value.onComplete(resolve, (...args: unknown[]) => reject(...args.slice(0, 1))); - ({ done, value } = (context.interceptIterator || iterator).next({ - _effectInterrupt: value.interruptable, - with: promise, - })); continue; - } else { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - value.onComplete(iterate, (...args: unknown[]) => reject(...args.slice(0, 1))); - return; } + if (value._effectAsync) { + if (value.interruptable) { + let resolve!: (value: unknown) => void; + const promise = new Promise((_resolve) => (resolve = _resolve)); + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + value.onComplete(resolve, (...args: unknown[]) => reject(...args.slice(0, 1))); + ({ done, value } = (context.interceptIterator || iterator).next({ + _effectInterrupt: value.interruptable, + with: promise, + })); + continue; + } else { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + value.onComplete(iterate, (...args: unknown[]) => reject(...args.slice(0, 1))); + return; + } + } + reject( + new Error( + `Invalid effected program: an effected program should yield only effects (received ${stringify(value)})`, + ), + ); + return; } - reject( - new Error( - `Invalid effected program: an effected program should yield only effects (received ${stringify(value)})`, - ), - ); - return; - } - resolve(value); + resolve(value); + } catch (e) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(e); + } }; iterate(); diff --git a/test/effected.spec.ts b/test/effected.spec.ts index 6f39193..522f3e9 100644 --- a/test/effected.spec.ts +++ b/test/effected.spec.ts @@ -620,6 +620,146 @@ describe("effected", () => { }); }); +describe("runAsync error settlement", () => { + it("should reject when the iterator factory throws", async () => { + const failure = new Error("iterator factory failed"); + const program = effected(() => { + throw failure; + }); + + const result = program.runAsync(); + + await expect(result).rejects.toBe(failure); + expect(() => program.runSync()).toThrow(failure); + }); + + it("should reject when an Effected.all child iterator factory throws", async () => { + const failure = new Error("child iterator factory failed"); + const child = effected(() => { + throw failure; + }); + + const result = Effected.all([Effected.of(1), child]).runAsync(); + + await expect(result).rejects.toBe(failure); + }); + + it.each([false, true])( + "should reject a default handler throw after an async boundary: %s", + async (afterAsync) => { + const failure = new Error("default handler failed"); + const fail = effect<[], void>()("fail", { + defaultHandler: () => { + throw failure; + }, + }); + const program = effected(function* () { + if (afterAsync) yield* effectify(Promise.resolve()); + yield* fail(); + }); + + await expect(program.runAsync()).rejects.toBe(failure); + expect(() => fail().runSync()).toThrow(failure); + }, + 1000, + ); + + it("should reject a default generator handler throw after an async boundary", async () => { + const failure = new Error("generator handler failed"); + const fail = effect<[], void>()("fail", { + *defaultHandler() { + yield* Effected.of(undefined); + throw failure; + }, + }); + const program = effected(function* () { + yield* effectify(Promise.resolve()); + yield* fail(); + }); + + await expect(program.runAsync()).rejects.toBe(failure); + }, 1000); + + it("should reject a default handler throw after callback-based resumption", async () => { + const failure = new Error("default handler failed"); + let resume!: () => void; + const pause = effect<[], void>()("pause", { + defaultHandler: (context) => { + resume = context.resume; + }, + }); + const fail = effect<[], void>()("fail", { + defaultHandler: () => { + throw failure; + }, + }); + const program = effected(function* () { + yield* pause(); + yield* fail(); + }); + const result = program.runAsync(); + + expect(() => resume()).not.toThrow(); + await expect(result).rejects.toBe(failure); + }, 1000); + + it.each([ + { afterAsync: false, interruptable: false }, + { afterAsync: true, interruptable: false }, + { afterAsync: false, interruptable: true }, + { afterAsync: true, interruptable: true }, + ])( + "should reject a registration throw (afterAsync: $afterAsync, interruptable: $interruptable)", + async ({ afterAsync, interruptable }) => { + const failure = new Error("async registration failed"); + const input = Promise.resolve(); + vi.spyOn(input, "then").mockImplementation(() => { + throw failure; + }); + const waiting = effectify(input); + const program = effected(function* () { + if (afterAsync) yield* effectify(Promise.resolve()); + if (interruptable) yield* Effected.all([waiting]); + else yield* waiting; + }); + + await expect(program.runAsync()).rejects.toBe(failure); + }, + 1000, + ); + + it.each([false, true])( + "should reject an iterator throw while interrupting after an async boundary: %s", + async (afterAsync) => { + const failure = new Error("child iteration failed"); + const waiting = effectify(new Promise(() => {})); + const fail = Effected.from(() => { + throw failure; + }); + const program = effected(function* () { + if (afterAsync) yield* effectify(Promise.resolve()); + yield* Effected.all([waiting, fail]); + }); + + await expect(program.runAsync()).rejects.toBe(failure); + }, + 1000, + ); + + it("should preserve rejection identity after a default handler suspends", async () => { + const failure = { message: "generator handler failed" }; + const fail = effect<[], void>()("fail", { + *defaultHandler() { + yield* effectify(Promise.resolve()); + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw failure; + }, + }); + + await expect(fail().runAsync()).rejects.toBe(failure); + }); +}); + describe("Default handlers", () => { it("should apply default handlers when no explicit handler is provided", () => { const logs: string[] = [];