Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 65 additions & 67 deletions src/effected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2614,86 +2614,84 @@ export function runAsync<E extends Effected<Effect, unknown>>(
: UnhandledEffect<ExtractUnhandled<F>>
: never,
): Promise<E extends Effected<infer F, infer R> ? ExtractDefaultTerminateType<F> | R : never> {
const iterator = (effected as Iterable<any>)[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<any>)[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();
Expand Down
140 changes: 140 additions & 0 deletions test/effected.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, never>(() => {
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<never, never>(() => {
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<void>(() => {}));
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[] = [];
Expand Down
Loading