From 48710b3b4e5aa240a9dadd71926b225321f7dd2c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 17 Dec 2024 15:32:18 -0600 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Add=20`scoped()`=20higher=20order=20op?= =?UTF-8?q?eration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One of the many overlapping concerns of `action()` and `call()` were to establish context boundaries, error boundaries, and concurency boundaries, but this was problematic because it required the creation of a new frame and thus a new iterator every time you wanted to do one of those things, so stack traces were lost. It also was problematic because sometimes you would use `call()` for an aysnc function, other times, you wanted to use it to encapsulate an operation, so when reading code you never knew why exactly `call()`/`action()` etc. were being used. However v4 has the ability to separate all these concerns: error handling, concurrency delimiting, and context delimiting, but they are all separate functions and a lot to teach. Instead, we want a single function that we can use for encapsulating all effects. This introduces the `scoped()` operation which takes any operation, traps errors that may emit from it, and wraps a new internal scope around it to prevent any effects from leaking out of it. --- lib/mod.ts | 1 + lib/scoped.ts | 31 +++++++++ test/scoped.test.ts | 158 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 lib/scoped.ts create mode 100644 test/scoped.test.ts diff --git a/lib/mod.ts b/lib/mod.ts index b93e0bfd..49d73178 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -22,3 +22,4 @@ export * from "./abort-signal.ts"; export * from "./main.ts"; export * from "./with-resolvers.ts"; export * from "./async.ts"; +export * from "./scoped.ts"; diff --git a/lib/scoped.ts b/lib/scoped.ts new file mode 100644 index 00000000..197691d2 --- /dev/null +++ b/lib/scoped.ts @@ -0,0 +1,31 @@ +import type { Operation } from "./types.ts"; +import { trap } from "./task.ts"; +import { createScopeInternal } from "./scope.ts"; +import { useCoroutine } from "./coroutine.ts"; + +/** + * Encapsulate an operation so that no effects will persist outside of + * it. All active effects such as concurrent tasks and resources will be + * shut down, and all contexts will be restored to their values outside + * of the scope. + * + * @param operation - the operation to be encapsulated + * + * @returns the scoped operation + */ +export function scoped(operation: () => Operation): Operation { + return { + *[Symbol.iterator]() { + let routine = yield* useCoroutine(); + let original = routine.scope; + let [scope, destroy] = createScopeInternal(original); + try { + routine.scope = scope; + return yield* trap(operation); + } finally { + routine.scope = original; + yield* destroy(); + } + }, + }; +} diff --git a/test/scoped.test.ts b/test/scoped.test.ts new file mode 100644 index 00000000..88a03c6f --- /dev/null +++ b/test/scoped.test.ts @@ -0,0 +1,158 @@ +import { + createContext, + resource, + run, + scoped, + sleep, + spawn, + suspend, +} from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; + +describe("scoped", () => { + describe("task", () => { + it("shuts down after completion", () => + run(function* () { + let didEnter = false; + let didExit = false; + + yield* scoped(function* () { + yield* spawn(function* () { + try { + didEnter = true; + yield* suspend(); + } finally { + didExit = true; + } + }); + yield* sleep(0); + }); + + expect(didEnter).toBe(true); + expect(didExit).toBe(true); + })); + + it("shuts down after error", () => + run(function* () { + let didEnter = false; + let didExit = false; + + try { + yield* scoped(function* () { + yield* spawn(function* () { + try { + didEnter = true; + yield* suspend(); + } finally { + didExit = true; + } + }); + yield* sleep(0); + throw new Error("boom!"); + }); + } catch (error) { + expect(error).toMatchObject({ message: "boom!" }); + expect(didEnter).toBe(true); + expect(didExit).toBe(true); + } + })); + + it("delimits error boundaries", () => + run(function* () { + try { + yield* scoped(function* () { + yield* spawn(function* () { + throw new Error("boom!"); + }); + yield* suspend(); + }); + } catch (error) { + expect(error).toMatchObject({ message: "boom!" }); + } + })); + }); + describe("resource", () => { + it("shuts down after completion", () => + run(function* () { + let status = "pending"; + yield* scoped(function* () { + yield* resource(function* (provide) { + try { + status = "open"; + yield* provide(); + } finally { + status = "closed"; + } + }); + yield* sleep(0); + expect(status).toEqual("open"); + }); + expect(status).toEqual("closed"); + })); + + it("shuts down after error", () => + run(function* () { + let status = "pending"; + try { + yield* scoped(function* () { + yield* resource(function* (provide) { + try { + status = "open"; + yield* provide(); + } finally { + status = "closed"; + } + }); + yield* sleep(0); + expect(status).toEqual("open"); + throw new Error("boom!"); + }); + } catch (error) { + expect((error as Error).message).toEqual("boom!"); + expect(status).toEqual("closed"); + } + })); + + it("delimits error boundaries", () => + run(function* () { + try { + yield* scoped(function* () { + yield* resource(function* (provide) { + yield* spawn(function* () { + yield* sleep(0); + throw new Error("boom!"); + }); + yield* provide(); + }); + yield* suspend(); + }); + } catch (error) { + expect(error).toMatchObject({ message: "boom!" }); + } + })); + }); + describe("context", () => { + let context = createContext("greetting", "hi"); + it("is restored after exiting scope", () => + run(function* () { + yield* scoped(function* () { + yield* context.set("hola"); + }); + expect(yield* context.get()).toEqual("hi"); + })); + + it("is restored after erroring", () => + run(function* () { + try { + yield* scoped(function* () { + yield* context.set("hola"); + throw new Error("boom!"); + }); + } catch (error) { + expect(error).toMatchObject({ message: "boom!" }); + } finally { + expect(yield* context.get()).toEqual("hi"); + } + })); + }); +});