Power Scopes
Closed this issue · 0 comments
Scopes are a powerful API to "contain" a set of running operations. They are programmatic interface to the tree of effects, and are absolutely critical when interfacing with non-Effection code such as web servers or native UI events. However, after some usage, it's clear that the semantics of the scope API as it is in v3 is not quite right. This is an effort to document the problems with things as they currently stand and propose some solutions.
Error handling
There is no way to protect code from a scope that it is using. In other words, it is impossible to catch the following error from within the current operation.
let scope = yield* useScope();
scope.run(function*() {
yield* sleep(10_000);
throw new Error("time bomb!");
});
While it is possible to create an error boundary above the current operation, it is often awkward because it is the code itself that knows how errors should be handled because it is in actual proximity of the code that raises the error.
An example of this is that it is currently impossible to catch an error from an all()
operation that has a failing resource that fails before the all operation completes.
try {
yield* all([failingResource, sleep(1000)]);
} catch (error) {
// never hit if resource fails before the sleep
}
In other words, any errors that happen before the transaction completes should be catchable.
Shared Context
Operations run within the same context stomp all over each other's context.
const Count = createContext<number>("count");
function* increment() {
let count = yield* Count;
yield* Count.set(count + 1);
}
function* run() {
let scope = yield* useScope();
yield* Count.set(1);
scope.run(function*() {
yield* increment();
})
scope.run(function*() {
yield* increment();
});
console.log(yield* Count); //=> logs `3` to the console.
}
Proposal
useScope()
will accept an optional link
operation that defines, among other things, how the lifetime of a child frame is connected to the current frame.
declare function useScope(link?: (error: Error) => Operation<void>): Scope;
The term "link" is derived from erlang nomenclature
If no link
operation is provided, then the default link operation will be to run the child in isolation from the current frame. E.g.
yield* useScope(function*(error) {
// do nothing! it is your responsibility to deal with all errors;
});
But if we wanted to re-route and log errors, this also allows us to establish a very natural boundary.
let errors = createChannel<Error>();
yield* useScope(function*(error) {
yield* errors.input.send(error);
});
Once a scope is closed, errors are no longer catchable via this mechanism. In other words, normal operation of the scope ceases, and if you want to catch errors that happen while closing, then you have to do so at the point you are calling yield* scope.close()
Different scopes can have different links and it won't affect each other:
let dangerous = yield* useScope(propagate);
let safe = yield* useScope();
Implementation
The first step of implementing this is that every call to run()
will create its own frame that is a child of the current frame rather than running the block of instructions in the current frame as is the case now. This will prevent the context clobbering problem straight away. In fact, there will only every be one block of instructions per frame (and each block will run completely) where as now, there can be an infinite number of blocks per frame.
But this presents a challenge. What happens with this scenario:
scope.run(() => spawn(operation));
Currently, this will spawn operation as a child of the scope's frame, but this would now create a frame within a frame which would then be shut down immediately because the operation only contained one spawn instruction.
In the event it will act exactly as though you called a top level run:
await run(function*() {
yield* spawn(operation);
});
The spawned operation will be halted as soon as the enclosing block exits.
This might be confusing, but it is consistent.