From b205b4a8aed9da68a316d376b95e21aae4bbbfbe Mon Sep 17 00:00:00 2001 From: Ge Gao Date: Mon, 19 Oct 2026 06:28:26 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9E=20fix:=20Settle=20default=20termin?= =?UTF-8?q?ation=20in=20runAsync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/effected.ts | 10 ++-- test/effected.spec.ts | 124 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/effected.ts b/src/effected.ts index 3b1bc99..38da3a5 100644 --- a/src/effected.ts +++ b/src/effected.ts @@ -2623,7 +2623,7 @@ export function runAsync>( return new Promise((resolve, reject) => { const iterate = (...args: [] | [unknown]) => { - if (context.terminated) return context.terminatedValue; + if (context.terminated) return resolve(context.terminatedValue as never); let done: boolean | undefined; let value: any; @@ -2636,7 +2636,8 @@ export function runAsync>( } // We use a while loop to avoid stack overflow when there are many synchronous effects - while (!done) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (!done && !context.terminated) { if (!value) { reject( new Error( @@ -2649,7 +2650,7 @@ export function runAsync>( 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 resolve(context.terminatedValue as never); } if (value instanceof Effect) { reject(new UnhandledEffectError(value, `Unhandled effect: ${stringifyEffect(value)}`)); @@ -2693,7 +2694,8 @@ export function runAsync>( return; } - resolve(value); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + resolve(context.terminated ? (context.terminatedValue as never) : value); }; iterate(); diff --git a/test/effected.spec.ts b/test/effected.spec.ts index 6f39193..79a5d46 100644 --- a/test/effected.spec.ts +++ b/test/effected.spec.ts @@ -774,6 +774,130 @@ describe("Default handlers", () => { expect(badProgram.runSync()).toBe("Cannot divide by zero"); }); + describe("runAsync termination settlement", () => { + it.each([false, true])( + "should resolve a default termination value after an async boundary: %s", + async (afterAsync) => { + const events: string[] = []; + const stop = effect<[], never, string>()("stop", { + defaultHandler: ({ terminate }) => terminate("stopped"), + }); + const program = effected(function* () { + if (afterAsync) yield* effectify(Promise.resolve()); + yield* stop(); + events.push("continued"); + return "completed"; + }); + + await expect(program.runAsync()).resolves.toBe("stopped"); + expect(events).toEqual([]); + }, + 1000, + ); + + it("should resolve when a default generator terminates after awaiting", async () => { + const events: string[] = []; + const stop = effect<[], never, number>()("stop", { + *defaultHandler({ terminate }) { + yield* effectify(Promise.resolve()); + terminate(7); + }, + }); + const program = effected(function* () { + yield* stop(); + events.push("continued"); + return 99; + }); + + await expect(program.runAsync()).resolves.toBe(7); + expect(events).toEqual([]); + }); + + it("should resolve when a default generator terminates after a synchronous effect", async () => { + const events: string[] = []; + const resume = effect<[], void>()("resume", { + defaultHandler: ({ resume }) => resume(), + }); + const stop = effect<[], never, number>()("stop", { + *defaultHandler({ terminate }) { + yield* resume(); + terminate(7); + }, + }); + const program = effected(function* () { + yield* stop(); + events.push("continued"); + return 99; + }); + + await expect(program.runAsync()).resolves.toBe(7); + expect(events).toEqual([]); + }); + + it("should stop parent handlers when a nested default generator terminates after awaiting", async () => { + const events: string[] = []; + const stop = effect<[], never, number>()("stop", { + *defaultHandler({ terminate }) { + yield* effectify(Promise.resolve()); + terminate(7); + }, + }); + const outer = effect<[], number, number>()("outer", { + *defaultHandler({ resume }) { + yield* stop(); + events.push("outer continued"); + resume(1); + }, + }); + const program = effected(function* () { + yield* outer(); + events.push("program continued"); + return 99; + }); + + await expect(program.runAsync()).resolves.toBe(7); + expect(events).toEqual([]); + }); + + it("should resolve a default termination without a value", async () => { + const stop = effect<[], never, void>()("stop", { + defaultHandler: ({ terminate }) => terminate(), + }); + const program = effected(function* () { + yield* effectify(Promise.resolve()); + yield* stop(); + return "completed"; + }); + + await expect(program.runAsync()).resolves.toBeUndefined(); + }, 1000); + + it.each(["stopped", undefined])( + "should resolve when a default handler terminates asynchronously with %s", + async (value) => { + let terminate!: (value: string | undefined) => void; + const events: string[] = []; + const stop = effect<[], never, string | undefined>()("stop", { + defaultHandler: (context) => { + terminate = context.terminate; + }, + }); + const program = effected(function* () { + yield* stop(); + events.push("continued"); + return "completed"; + }); + const result = program.runAsync(); + + terminate(value); + + await expect(result).resolves.toBe(value); + expect(events).toEqual([]); + }, + 1000, + ); + }); + it("should handle async operations in default handlers", async () => { const fetchData = effect<[url: string], any>()("fetchData", { defaultHandler: ({ resume }, url) => {