From bf7944f51686b9b3cc01f187bc467a5a9b3488d2 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 10 Dec 2019 23:40:38 -0600 Subject: [PATCH] Convert `fork` into an operation Originally, this change came out of problems with sequencing a fork. Specifically, if there was an error inside of fork, and so it tried to throw() inside of its parent, then the error was hidden by the fact that [its parent generator was already running]. This was symptomatic of a deeper problem of the generator syntax being too tightly coupled to the underlying concept of the execution of a sequence of steps, and also of the concept of synchronous and asynchronous execution being too coupled. As a purely practical consideration, we wondered "what if the parent generator was yielded at the time we tried to create the fork?" That way, the fork would be either be at its first yield point, or have failed when we tried to resume the parent, but this turned out to be a profoundly positive re-orientation of the effection architecture to separate out the concepts of context and executions from the generator synax entirely which enables all sorts of nice things. Fundamentally, this change makes effection a function-oriented effects library rather than a generator-oriented effects library. That is, you don't _need_ generators to declare a tree of processes, it's just nicer to do it that way. For example: ```js enter(join(fork(({ resume, ensure })=> resume(5)))) ``` The nice effect that this has is that the generator syntax is implemented as just a plugin to the basic runtime, which means that in addition to solving the problem at hand, it also [allows you to fork a control function directly][2], something that had always felt like it should work, but annoyingly didn't. This feels like the right direction to take it, because it is the solution to several seemingly unrelated problems, and it even feels as though it could provide a pathway to an [asynchronous halt operation][3] by making not just `fork` but also `halt` an operation. Finally, because structural concurrency is implemented ultimately as operations that do nothing more than manipulate the execution context, other structured effects (state, messaging, etc...) can be implemented in user space. Using this library, I was able to implement messaging operations based on the [send-and-receive][4] branch completely in user-space, and in which both `send()` and `receive()` were operations. There are still some kinks to be worked out. For example, it's awkward to jump into generator syntax from function syntaxt (nothing blocking though, and certainly, nothing worse than what came before before). From the perspective of the JavaScript runtime, execution is still completely synchronous internally, and the code is much, much simpler and (in my opinion) easier to follow. [1]: https://github.com/thefrontside/effection.js/issues/26 [2]: https://github.com/thefrontside/effection.js/issues/33 [3]: https://github.com/thefrontside/effection.js/issues/35 [4]: https://github.com/thefrontside/effection.js/pull/49 --- README.md | 24 ++-- examples/async.js | 23 +--- examples/countdown.js | 18 +-- examples/inerruptable.js | 16 +++ src/context.js | 167 ++++++++++++++++++++++ src/control.js | 158 +++++++++++++++++++++ src/fork.js | 245 --------------------------------- src/index.js | 10 +- src/noop.js | 1 - src/promise-of.js | 21 --- src/timeout.js | 8 +- tests/async.test.js | 98 +++++++------ tests/controller.test.js | 32 +++-- tests/execute.test.js | 212 ---------------------------- tests/execution.test.js | 115 ++++++++++++++++ tests/fork-as-promise.test.js | 137 +++++++++--------- tests/generator-syntax.test.js | 187 +++++++++++++++++++++++++ tests/promise-of.test.js | 8 +- tests/root.test.js | 10 +- types/execute.test.ts | 26 ++-- types/imports.test.ts | 4 +- types/index.d.ts | 30 ++-- types/promise.test.ts | 14 +- 23 files changed, 861 insertions(+), 703 deletions(-) create mode 100644 examples/inerruptable.js create mode 100644 src/context.js create mode 100644 src/control.js delete mode 100644 src/fork.js delete mode 100644 src/noop.js delete mode 100644 src/promise-of.js delete mode 100644 tests/execute.test.js create mode 100644 tests/execution.test.js create mode 100644 tests/generator-syntax.test.js diff --git a/README.md b/README.md index 6f3f402e4..6383ad008 100755 --- a/README.md +++ b/README.md @@ -64,20 +64,20 @@ siblings are immediately halted. ## Execution -The process primitive is the `Execution`. To create (and start) an +The process primitive is the `Execution`. To create (and enter) an `Execution`, use the `fork` function and pass it a generator. This simplest example waits for 1 second, then prints out "hello world" to the console. ``` javascript -import { fork, timeout } from 'effection'; +import { enter, timeout } from 'effection'; -let process = fork(function*() { +let execution = enter(function*() { yield timeout(1000); return 'hello world'; }); -process.isRunning //=> true +execution.isRunning //=> true // 1000ms passes // process.isRunning //=> false // process.result //=> 'hello world' @@ -87,7 +87,7 @@ Child processes can be composed freely. So instead of yielding for 1000 ms, we could instead, yield 10 times for 100ms. ``` javascript -fork(function*() { +enter(function*() { yield function*() { for (let i = 0; i < 10; i++) { yield timeout(100); @@ -100,7 +100,7 @@ fork(function*() { And in fact, processes can be easily and arbitrarly deeply nested: ``` javascript -let process = fork(function*() { +let process = enter(function*() { return yield function*() { return yield function*() { return yield function*() { @@ -118,13 +118,13 @@ You can pass arguments to an operation by invoking it. ``` javascript -import { fork, timeout } from 'effection'; +import { enter, timeout } from 'effection'; function* waitForSeconds(durationSeconds) { yield timeout(durationSeconds * 1000); } -fork(waitforseconds(10)); +enter(waitforseconds(10)); ``` ### Asynchronous Execution @@ -137,11 +137,11 @@ part of your main process. To do this, you would use the `fork` method on the execution: ``` javascript -import { fork } from 'effection'; +import { enter, fork } from 'effection'; -fork(function*() { - fork(createFileServer); - fork(createHttpServer); +enter(function*() { + yield fork(createFileServer); + yield fork(createHttpServer); }); ``` diff --git a/examples/async.js b/examples/async.js index f91ca2804..181d5e99f 100644 --- a/examples/async.js +++ b/examples/async.js @@ -1,14 +1,14 @@ /* eslint no-console: 0 */ -/* eslint require-yield: 0 */ -import { fork, timeout } from '../src/index'; +import { enter, fork, timeout } from '../src/index'; +import { interruptable } from './inerruptable'; /** * Fires up some random servers */ -fork(interruptable(function*() { - fork(randoLogger('Bob')); - fork(randoLogger('Alice')); +enter(interruptable(function*() { + yield fork(randoLogger('Bob')); + yield fork(randoLogger('Alice')); console.log('Up and running with random number servers Bob and Alice....'); })); @@ -32,16 +32,3 @@ function randoLogger(name) { } }; } - - -function interruptable(proc) { - return function*() { - let interrupt = () => console.log('') || this.halt(); - process.on('SIGINT', interrupt); - try { - yield proc; - } finally { - process.off('SIGINT', interrupt); - } - }; -} diff --git a/examples/countdown.js b/examples/countdown.js index bbf908b5a..0c73aafdf 100644 --- a/examples/countdown.js +++ b/examples/countdown.js @@ -1,6 +1,7 @@ /* eslint no-console: 0 */ -import { fork, timeout } from '../src/index'; +import { enter, timeout } from '../src/index'; +import { interruptable } from './inerruptable'; /** * A simple script that counts down from 5 to 1, pausing for one @@ -24,23 +25,10 @@ import { fork, timeout } from '../src/index'; * handler is uninstalled. Once again, the node process is left with * nothing left to do and no event handlers, so it exits. */ -fork(interruptable(function*() { +enter(interruptable(function*() { for (let i = 5; i > 0; i--) { console.log(`${i}...`); yield timeout(1000); } console.log('liftoff!'); })); - - -function interruptable(proc) { - return function*() { - let interrupt = () => console.log('') || this.halt(); - process.on('SIGINT', interrupt); - try { - yield proc; - } finally { - process.off('SIGINT', interrupt); - } - }; -} diff --git a/examples/inerruptable.js b/examples/inerruptable.js new file mode 100644 index 000000000..993fa66e6 --- /dev/null +++ b/examples/inerruptable.js @@ -0,0 +1,16 @@ +/* eslint no-console: 0 */ + +import { fork, join } from '../src/index'; + +export function interruptable(operation) { + return function*() { + let child = yield fork(operation); + let interrupt = () => console.log('') || child.halt(); + process.on('SIGINT', interrupt); + try { + yield join(child); + } finally { + process.off('SIGINT', interrupt); + } + }; +} diff --git a/src/context.js b/src/context.js new file mode 100644 index 000000000..2d5c19fba --- /dev/null +++ b/src/context.js @@ -0,0 +1,167 @@ +import { ControlFunction, HaltError } from './control'; + +export class ExecutionContext { + static ids = 1; + get isUnstarted() { return this.state === 'unstarted'; } + get isRunning() { return this.state === 'running'; } + get isWaiting() { return this.state === 'waiting'; } + get isCompleted() { return this.state === 'completed'; } + get isErrored() { return this.state === 'errored'; } + get isHalted() { return this.state === 'halted'; } + + get isBlocking() { return this.isRunning || this.isWaiting || this.isUnstarted; } + + get hasBlockingChildren() { + for (let child of this.children) { + if (child.isBlocking) { + return true; + } + } + return false; + } + + constructor(parent = undefined) { + this.id = this.constructor.ids++; + this.parent = parent; + this.children = new Set(); + this.exitHooks = new Set(); + this.state = 'unstarted'; + this.resume = this.resume.bind(this); + this.fail = this.fail.bind(this); + this.ensure = this.ensure.bind(this); + } + + get promise() { + this._promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + this.finalizePromise(); + return this._promise; + } + + finalizePromise() { + if(this.isCompleted && this.resolve) { + this.resolve(this.result); + } else if(this.isErrored && this.reject) { + this.reject(this.result); + } else if(this.isHalted && this.reject) { + this.reject(new HaltError(this.result)); + } + } + + then(...args) { + return this.promise.then(...args); + } + + catch(...args) { + return this.promise.catch(...args); + } + + finally(...args) { + return this.promise.finally(...args); + } + + get root() { + if (!this.parent) { + return this; + } else { + return this.parent.root; + } + } + + createChild() { + let child = new ExecutionContext(this); + child.ensure(() => this.children.delete(child)); + this.children.add(child); + return child; + } + + ensure(hook) { + let { exitHooks } = this; + exitHooks.add(hook); + return () => exitHooks.delete(hook); + } + + enter(operation) { + if (this.isUnstarted) { + let controller = this.createController(operation); + this.operation = operation; + this.state = 'running'; + controller.call({ + resume: this.resume, + fail: this.fail, + ensure: this.ensure, + context: this + }); + } else { + throw new Error(` +Tried to call Fork#resume() on a Fork that has already been finalized. This +should never happen and so is almost assuredly a bug in effection. All of +its users would be in your eternal debt were you to please take the time to +report this issue here: +https://github.com/thefrontside/effection.js/issues/new + +Thanks!`); + } + } + + halt(reason) { + if (this.isBlocking) { + this.finalize('halted', reason); + } + } + + resume(value) { + if (this.isRunning) { + this.result = value; + if (this.hasBlockingChildren) { + this.state = 'waiting'; + } else { + this.finalize('completed', value); + } + } else { + throw new Error(` +Tried to call Fork#resume() on a Fork with state '${this.state}' This +should never happen and so is almost assuredly a bug in effection. All of +its users would be in your eternal debt were you to please take the time to +report this issue here: +https://github.com/thefrontside/effection.js/issues/new + +Thanks!`); + } + } + + fail(error) { + // if (!this.isBlocking) {error} + this.finalize('errored', error); + } + + finalize(state, result) { + this.result = result || this.result; + this.state = state; + for (let hook of this.exitHooks) { + this.exitHooks.delete(hook); + hook(this); + } + for (let child of this.children) { + this.children.delete(child); + child.halt(result); + } + this.finalizePromise(); + } + + createController(operation) { + let controller = ControlFunction.for(operation); + if (!controller) { + throw new Error(`cannot find controller for ${operation}`); + } + return controller; + } + + toString(indent = '') { + let name = this.operation ? this.operation.name || '' : ''; + let children = [...this.children].map(child => `${child.toString(indent + ' ')}`); + return [`${indent}-> [${this.id}](${name}): ${this.state}`, ...children].join("\n"); + } +} diff --git a/src/control.js b/src/control.js new file mode 100644 index 000000000..83cb7323f --- /dev/null +++ b/src/control.js @@ -0,0 +1,158 @@ +import { isGeneratorFunction, isGenerator } from './generator-function'; + +export class ControlFunction { + static of(call) { + return new this(call); + } + + /** + * Builtin controls for promises, generators, generator functions + * and raw functions. + */ + static for(operation) { + if (operation == null) { + return ControlFunction.of(x => x); + } else if (operation instanceof ControlFunction) { + return operation; + } else if (isGeneratorFunction(operation)) { + return GeneratorFunctionControl(operation); + } else if (isGenerator(operation)) { + return GeneratorControl(operation); + } else if (typeof operation.then === 'function') { + return PromiseControl(operation); + } else if (typeof operation === 'function') { + return ControlFunction.of(operation); + } + } + + constructor(call) { + this.call = call; + } +} + +export function PromiseControl(promise) { + return ControlFunction.of(function control({ resume, fail, ensure }) { + + let resolve = resume; + let reject = fail; + let noop = x => x; + + // return values of succeed and fail are deliberately ignored. + // see https://github.com/thefrontside/effection.js/pull/44 + promise.then(value => { resolve(value); }, error => { reject(error); }); + + // this execution has passed out of scope, so we don't care + // what happened to the promise, so make the callbacks noops. + // this effectively "unsubscribes" to the promise. + ensure(() => resolve = reject = noop); + }); +} + +/** + * Marker class for Fork operations used in GeneratorControl functions + * TODO: is this necessary if we pass `call` along with + */ +export class ForkControl extends ControlFunction {} + +/** + * Controls the execution of Generator Functions. It just invokes the + * generator function to get a reference to the generator, and then + * delegates to the GeneratorControl to do all the work. + * + * enter(function*() { yield timeout(10); return 5; }); + */ +export const GeneratorFunctionControl = sequence => ControlFunction.of((...args) => { + return GeneratorControl(sequence()).call(...args); +}); + + +/** + * Control a sequence of operations expressed as a generator. + * For each step of the generator. Each `yield` expression of + * the generator should pass an operation which will then be + * executed in its own context. These child container contexts have + * their own `fail` and `resume` functions local to the generator + * that proceed to the next step. Once the generator is finished or + * throws an exception, control is passed back to the calling parent. + */ +export const GeneratorControl = generator => ControlFunction.of(({ context }) => { + + context.ensure(() => generator.return()); + + function fail(error) { + try { + resume(generator.throw(error)); + } catch (e) { + context.fail(e); + } + } + + function resume(value) { + try { + let next = generator.next(value); + if (next.done) { + context.resume(next.value); + } else { + let operation = next.value; + if (operation instanceof ForkControl) { + operation.call({ resume, fail, context }); + } else { + let child = context.createChild(); + let { ensure } = child; + join(child).call({ resume, fail, ensure, context}); + child.enter(operation); + } + } + } catch (error) { + context.fail(error); + } + } + + resume(); +}); + +export function fork(operation) { + return ForkControl.of(({ resume, context } ) => { + let child = context.createChild(); + + child.ensure(() => { + if (child.isErrored) { + context.fail(child.result); + } else if (context.isWaiting && !context.hasBlockingChildren) { + context.finalize('completed'); + } + }); + child.enter(operation); + + if (context.isBlocking) { + resume(child); + } + return child; + }); +} + +export function join(antecedent) { + return ControlFunction.of(({ resume, fail, ensure, context }) => { + let disconnect = antecedent.ensure(function join() { + if (context.isRunning) { + let { result } = antecedent; + if (antecedent.isCompleted) { + resume(result); + } else if (antecedent.isErrored) { + fail(result); + } else if (antecedent.isHalted) { + fail(new HaltError(antecedent.result)); + } + } + }); + + ensure(disconnect); + }); +} + +export class HaltError extends Error { + constructor(cause) { + super(`Interrupted: ${cause}`); + this.cause = cause; + } +} diff --git a/src/fork.js b/src/fork.js deleted file mode 100644 index abb307249..000000000 --- a/src/fork.js +++ /dev/null @@ -1,245 +0,0 @@ -import { noop } from './noop'; -import { promiseOf } from './promise-of'; -import { isGenerator, isGeneratorFunction, toGeneratorFunction } from './generator-function'; - -class Fork { - static ids = 0; - get isUnstarted() { return this.state === 'unstarted'; } - get isRunning() { return this.state === 'running'; } - get isWaiting() { return this.state === 'waiting'; } - get isCompleted() { return this.state === 'completed'; } - get isErrored() { return this.state === 'errored'; } - get isHalted() { return this.state === 'halted'; } - - get isBlocking() { return this.isRunning || this.isWaiting; } - - get hasBlockingChildren() { - for (let child of this.children) { - if (child.isBlocking) { - return true; - } - } - return false; - } - - constructor(operation, parent, sync) { - this.id = Fork.ids++; - this.operation = toGeneratorFunction(operation); - this.parent = parent; - this.sync = sync; - this.children = new Set(); - this.state = 'unstarted'; - this.exitPrevious = noop; - } - - get promise() { - this._promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - this.finalizePromise(); - return this._promise; - } - - then(...args) { - return this.promise.then(...args); - } - - catch(...args) { - return this.promise.catch(...args); - } - - finally(...args) { - return this.promise.finally(...args); - } - - halt(value) { - if (this.isRunning) { - this.exitPrevious(); - this.iterator.return(value); - } - if (this.isBlocking) { - this.finalize('halted', value); - } - } - - throw(error) { - if (this.isRunning) { - this.thunk(iterator => iterator.throw(error)); - } else if (this.isWaiting) { - this.finalize('errored', error); - } else { - throw new Error(` -Tried to call Fork#throw() on a Fork that has already been finalized. This -should never happen and so is almost assuredly a bug in effection. All of -its users would be in your eternal debt were you to please take the time to -report this issue here: -https://github.com/thefrontside/effection.js/issues/new - -Thanks!`); - } - } - - resume(value) { - if (this.isUnstarted) { - this.iterator = this.operation.call(this); - this.state = 'running'; - this.resume(value); - } else if (this.isRunning) { - this.thunk(iterator => iterator.next(value)); - } else { - throw new Error(` -Tried to call Fork#resume() on a Fork that has already been finalized. This -should never happen and so is almost assuredly a bug in effection. All of -its users would be in your eternal debt were you to please take the time to -report this issue here: -https://github.com/thefrontside/effection.js/issues/new - -Thanks!`); - } - return this; - } - - fork(operation, sync = false) { - let child = new Fork(operation, this, sync); - this.children.add(child); - child.resume(); - return child; - } - - join(child) { - if (!this.children.has(child)) { - return; - } - if (!this.isBlocking) { - throw new Error(` -Tried to call Fork#join() on a Fork that has already been finalized which means -that a sub-fork is being finalized _after_ its parent. This should never happen -and so is almost assuredly a bug in effection. All of its users would be -in your eternal debt were you to please take the time to report this issue here: -https://github.com/thefrontside/effection.js/issues/new - -Thanks! -`); - } - if (child.isBlocking) { - throw new Error(` -Tried to call Fork#join() with a child that has not been -finalized. This should never happen and so probably indicates a bug -in effection. All of its users would be in your eternal debt were you -to please take the time to report this issue here: -https://github.com/thefrontside/effection.js/issues/new -`); - } - this.children.delete(child); - - if (child.isCompleted) { - if (this.isWaiting && !this.hasBlockingChildren) { - this.finalize('completed'); - } else if (this.isRunning && child.sync) { - this.resume(child.result); - } - } else if (child.isHalted) { - if (this.isWaiting && !this.hasBlockingChildren) { - this.finalize('completed'); - } else if (this.isRunning && child.sync) { - this.throw(new Error(`Interupted: ${child.result}`)); - } - } else if (child.isErrored) { - this.throw(child.result); - } - } - - thunk(fn) { - let next; - let previouslyExecuting = Fork.currentlyExecuting; - try { - Fork.currentlyExecuting = this; - - this.exitPrevious(); - - try { - next = fn(this.iterator); - } catch(error) { - this.finalize('errored', error); - return; - } - - if (next.done) { - if (this.hasBlockingChildren) { - this.state = 'waiting'; - } else { - this.finalize('completed', next.value); - } - } else { - let controller = controllerFor(next.value); - /// what happens here if control is synchronous. and resumes execution immediately? - let exit = controller(this); - this.exitPrevious = typeof exit === 'function' ? exit : noop; - } - } finally { - Fork.currentlyExecuting = previouslyExecuting; - } - } - - finalize(state, result) { - this.state = state; - this.result = result; - - this.children.forEach(child => { - this.children.delete(child); - child.halt(result); - }); - if (this.parent) { - this.parent.join(this); - } - this.finalizePromise(); - } - - finalizePromise() { - if(this.isCompleted && this.resolve) { - this.resolve(this.result); - } else if(this.isErrored && this.reject) { - this.reject(this.result); - } else if(this.isHalted && this.reject) { - this.reject(new HaltError(this.result)); - } - } - - get root() { - if(this.parent) { - return this.parent.root; - } else { - return this; - } - } -} - -export function fork(operation, parent = Fork.currentlyExecuting) { - if (parent) { - return parent.fork(operation); - } else { - return new Fork(operation).resume(); - } -} - -class HaltError extends Error { - constructor(cause) { - super("halt"); - this.cause = cause; - } -} - -function controllerFor(value) { - if (isGeneratorFunction(value) || isGenerator(value)) { - return parent => parent.fork(value, true); - } else if (typeof value === 'function') { - return value; - } else if (value == null) { - return x => x; - } else if (typeof value.then === 'function') { - return promiseOf(value); - } else { - throw new Error(`generators should yield either another generator or control function, not '${value}'`); - } -} diff --git a/src/index.js b/src/index.js index abd08d897..bc2fb47f2 100644 --- a/src/index.js +++ b/src/index.js @@ -1,2 +1,10 @@ export { timeout } from './timeout'; -export { fork } from './fork'; +export { fork, join } from './control'; + +import { ExecutionContext } from './context'; + +export function enter(operation) { + let top = new ExecutionContext(); + top.enter(operation); + return top; +} diff --git a/src/noop.js b/src/noop.js deleted file mode 100644 index 90b92f291..000000000 --- a/src/noop.js +++ /dev/null @@ -1 +0,0 @@ -export const noop = x => x; diff --git a/src/promise-of.js b/src/promise-of.js deleted file mode 100644 index 5088e25d4..000000000 --- a/src/promise-of.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * An execution controller that resumes or throws based - * on a promise. - */ -export function promiseOf(promise) { - return function control(execution) { - - let succeed = value => execution.resume(value); - let fail = err => execution.throw(err); - let noop = x => x; - - // return values of succeed and fail are deliberately ignored. - // see https://github.com/thefrontside/effection.js/pull/44 - promise.then(value => { succeed(value); }, error => { fail(error); }); - - // this execution has passed out of scope, so we don't care - // what happened to the promise, so make the callbacks noops. - // this effectively "unsubscribes" to the promise. - return () => succeed = fail = noop; - }; -} diff --git a/src/timeout.js b/src/timeout.js index 89d6f485f..19d190453 100644 --- a/src/timeout.js +++ b/src/timeout.js @@ -7,9 +7,9 @@ * console.log(`Hello ${target}!`); * } */ -export function timeout(durationMillis = 0) { - return function(execution) { - let timeoutId = setTimeout(() => execution.resume(), durationMillis); - return () => clearTimeout(timeoutId); +export function timeout(duration) { + return ({ resume, ensure }) => { + let timeoutId = setTimeout(resume, duration); + ensure(() => clearTimeout(timeoutId)); }; } diff --git a/tests/async.test.js b/tests/async.test.js index 17da97724..fe773cc3b 100644 --- a/tests/async.test.js +++ b/tests/async.test.js @@ -4,25 +4,19 @@ import expect from 'expect'; -import { fork } from '../src/index'; +import { enter, fork, join } from '../src/index'; describe('Async executon', () => { describe('with asynchronously executing children', () => { let execution, one, two, three; beforeEach(() => { - execution = fork(function() { - fork(function*() { - yield cxt => one = cxt; - }); + execution = enter(function* outer() { + one = yield fork(); - fork(function*() { - yield cxt => two = cxt; - }); + two = yield fork(); - fork(function*() { - yield cxt => three = cxt; - }); + three = yield fork(); }); }); it('begins execution of each child immediately', () => { @@ -109,7 +103,7 @@ describe('Async executon', () => { let boom; beforeEach(() => { boom = new Error('boom!'); - one.throw(boom); + one.fail(boom); }); it('errors out the parent', () => { @@ -128,11 +122,13 @@ describe('Async executon', () => { let execution, one, two, sync, boom; beforeEach(() => { boom = new Error('boom!'); - execution = fork(function*() { - fork(function*() { yield cxt => one = cxt; }); - fork(function*() { yield cxt => two = cxt; }); + execution = enter(function*() { + one = yield fork(); + two = yield fork(); yield function*() { - yield cxt => sync = cxt; + yield ({ context }) => { + sync = context; + }; }; }); expect(one).toBeDefined(); @@ -202,7 +198,7 @@ describe('Async executon', () => { describe('throwing from within the synchronous task', () => { beforeEach(() => { - sync.throw(boom); + sync.fail(boom); }); it('errors out the top level execution', () => { @@ -218,7 +214,7 @@ describe('Async executon', () => { describe('throwing from within one of the async tasks', () => { beforeEach(() => { - one.throw(boom); + one.fail(boom); }); it('errors out the top level execution', () => { @@ -253,8 +249,8 @@ describe('Async executon', () => { describe('A parent that block, but also has an async child', () => { let parent, child; beforeEach(() => { - parent = fork(function*() { - fork(function*() { yield cxt => child = cxt; }); + parent = enter(function*() { + child = yield fork(); yield x => x; }); }); @@ -286,48 +282,50 @@ describe('Async executon', () => { }); }); - describe('the fork function', () => { - let forkReturn, forkContext; + describe('joining a fork', () => { + let root, child, getNumber; beforeEach(() => { - fork(function*() { - forkReturn = fork(function*() { - forkContext = this; + root = enter(function*() { + child = yield fork(function*() { + getNumber = yield fork(); + let number = yield join(getNumber); + return number * 2; }); + let value = yield join(child); + return value + 1; }); }); - it('returns the forked child', () => { - expect(forkReturn).toBeDefined(); - expect(forkReturn).toEqual(forkContext); - }); - }); + describe('when the child resumes', () => { + beforeEach(() => { + getNumber.resume(5); + }); - describe('yielding on fork', () => { - let root, child; - beforeEach(() => { - root = fork(function*() { - child = fork(function*() { - let number = yield; - return number * 2; - }); - let value = yield child; - return value + 1; + it('awaits the value from the child', () => { + expect(root.result).toBe(11); }); - }); - it('awaits the value from the child', async () => { - child.resume(5); - await expect(root).resolves.toBe(11); }); - it('throws if child is thrown', async () => { - child.throw(new Error("boom")); - await expect(root).rejects.toThrow("boom"); + describe('when the child fails', () => { + beforeEach(() => { + getNumber.fail(new Error("boom!")); + + }); + + it('throws if child is thrown', () => { + expect(root.result.message).toEqual('boom!'); + }); }); - it('throws if child is halted', async () => { - child.halt(); - await expect(root).rejects.toThrow("halt"); + describe('when the child halts', () => { + beforeEach(() => { + getNumber.halt(); + }); + + it('throws if child is halted', () => { + expect(root.result.message).toMatch("Interrupt"); + }); }); }); }); diff --git a/tests/controller.test.js b/tests/controller.test.js index 120e6f09a..3f7204173 100644 --- a/tests/controller.test.js +++ b/tests/controller.test.js @@ -2,30 +2,32 @@ import expect from 'expect'; -import { fork } from '../src/index'; +import { enter } from '../src/index'; + import mock from 'jest-mock'; describe('Controlling execution', () => { - let execution, controller, relinquish; + let execution, resume, fail, relinquish; - function control(ctl) { - controller = ctl; - return relinquish; + function control(controller) { + ({ resume, fail } = controller); + controller.ensure(relinquish); } beforeEach(() => { - execution = controller = relinquish = undefined; + execution = resume = fail = relinquish = undefined; }); describe('from the last step in an execption', () => { beforeEach(() => { relinquish = mock.fn(); - execution = fork(function*() { + execution = enter(function*() { yield control; }); - expect(controller).toBeDefined(); + expect(resume).toBeDefined(); + expect(fail).toBeDefined(); }); it('does not call the relinquish upon entering the control context', () => { @@ -34,7 +36,7 @@ describe('Controlling execution', () => { describe('resuming execution', () => { beforeEach(() => { - controller.resume(); + resume(); }); it('invokes the release function', () => { expect(relinquish).toHaveBeenCalled(); @@ -43,7 +45,7 @@ describe('Controlling execution', () => { describe('erroring execution', () => { beforeEach(() => { - controller.throw(new Error('boom!')); + fail(new Error('boom!')); expect(execution.isErrored).toEqual(true); expect(execution.result.message).toEqual('boom!'); }); @@ -68,12 +70,12 @@ describe('Controlling execution', () => { describe('from an intermediate step in an execution', () => { beforeEach(() => { relinquish = mock.fn(); - fork(function* () { + enter(function* () { yield control; yield ctl => ctl.resume(); }); - expect(controller).toBeDefined(); - controller.resume(); + expect(resume).toBeDefined(); + resume(); }); it('still invokes the relinquish function', () => { @@ -84,13 +86,13 @@ describe('Controlling execution', () => { describe('a release function that throws an error', () => { beforeEach(() => { relinquish = () => { throw new Error('this is a bug in the release control!'); }; - fork(function* () { + enter(function* () { yield control; }); }); it('throws that error when trying to resume execution', () => { - expect(() => controller.resume()).toThrow(/this is a bug/); + expect(() => resume()).toThrow(/this is a bug/); }); }); }); diff --git a/tests/execute.test.js b/tests/execute.test.js deleted file mode 100644 index 6f7395df8..000000000 --- a/tests/execute.test.js +++ /dev/null @@ -1,212 +0,0 @@ -/* global describe, beforeEach, it */ -/* eslint require-yield: 0 */ -/* eslint no-unreachable: 0 */ - -import expect from 'expect'; -import mock from 'jest-mock'; - -import { fork } from '../src/index'; - -describe('Exec', () => { - describe('deeply nested task', () => { - let inner, execution, error; - let onSuccess, onError, onFinally; - beforeEach(() => { - onSuccess = mock.fn(x => x); - onError = mock.fn(x => x); - onFinally = mock.fn(x => x); - execution = fork(function*() { - try { - return yield function*() { - return yield function*() { - return yield ctl => inner = ctl; - }; - }; - } catch (e) { - error = e; - } - }); - }); - - it('has an id', () => { - expect(typeof execution.id).toEqual('number'); - }); - - it('calls all the way through to the inner child', () => { - expect(inner).toBeDefined(); - }); - - it('allocates a bigger number to the child id', () => { - expect(inner.id > execution.id).toEqual(true); - }); - - it('does not invoke any callback', () => { - expect(onSuccess).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - expect(onFinally).not.toHaveBeenCalled(); - }); - - describe('resuming the inner child', () => { - beforeEach(() => { - expect(inner).toBeDefined(); - inner.resume(10); - }); - - it('completes the outer execution', () => { - expect(execution.isCompleted).toEqual(true); - expect(execution.isBlocking).toEqual(false); - }); - - it('completes the inner execution', () => { - expect(inner.isCompleted).toEqual(true); - expect(inner.isBlocking).toEqual(false); - }); - - it('passes values up through the stack', () => { - expect(execution.result).toEqual(10); - }); - }); - - describe('throwing an error into the inner child', () => { - let err; - beforeEach(() => { - expect(inner).toBeDefined(); - inner.throw(err = new Error('boom!')); - }); - - it('errors out the inner execution', () => { - expect(inner.isErrored).toEqual(true); - expect(inner.isBlocking).toEqual(false); - }); - - it('completes the outer execution', () => { - expect(error).toEqual(err); - expect(execution.isCompleted).toEqual(true); - expect(execution.isBlocking).toEqual(false); - }); - }); - - describe('halting the inner child', () => { - beforeEach(() => { - expect(inner).toBeDefined(); - inner.halt('kill it with fire'); - }); - - it('halts the inner child', () => { - expect(inner.isHalted).toEqual(true); - }); - - it('errors out the parents that depended on it', () => { - expect(execution.isCompleted).toEqual(true); - expect(error.message).toMatch(/kill it with fire/); - }); - }); - - describe('halting the outer execution', () => { - beforeEach(() => { - execution.halt('shut it down'); - }); - - it('halts the inner most child', () => { - expect(inner.isHalted).toEqual(true); - expect(inner.result).toEqual('shut it down'); - }); - }); - - }); - - describe('deeply nested task that throws an error', () => { - let execution, error; - beforeEach(() => { - execution = fork(function*() { - try { - return yield function*() { - return yield function*() { - throw new Error('boom!'); - }; - }; - } catch (e) { - error = e; - } - }); - }); - it('throws the error all the way up to the top', () => { - expect(error.message).toEqual('boom!'); - }); - - it('completes the execution', () => { - expect(execution.isCompleted).toEqual(true); - }); - }); - - describe('An execution with an empty yield', () => { - let execution; - - beforeEach(() => { - execution = fork(function*() { - return yield; - }); - }); - - it('is running while yielded', () => { - expect(execution.isRunning).toEqual(true); - }); - - describe('resuming the execution externally', () => { - beforeEach(() => { - execution.resume(10); - }); - - it('completes', async () => { - expect(execution.state).toEqual('completed'); - expect(execution.result).toEqual(10); - await expect(execution).resolves.toBe(10); - }); - }); - - describe('erroring the execution externally', () => { - beforeEach(() => { - execution.throw(new Error('boom')); - }); - - it('errors', async () => { - expect(execution.state).toEqual('errored'); - expect(execution.result.message).toEqual('boom'); - await expect(execution).rejects.toThrow('boom'); - }); - }); - }); - - describe('executing a generators', () => { - let execution; - function* add(a, b) { - return a + b; - } - - describe('nested inside generator functions', () => { - beforeEach(() => { - - execution = fork(function*() { - let one = yield function*() { return 1; }; - let two = yield function*() { return 2; }; - return yield add(one, two); - }); - }); - - it('computes the result just fine', () => { - expect(execution.isCompleted).toEqual(true); - expect(execution.result).toEqual(3); - }); - }); - - describe('directly', () => { - beforeEach(() => { - execution = fork(add(1, 2)); - }); - it('computes the result just fine', () => { - expect(execution.isCompleted).toEqual(true); - expect(execution.result).toEqual(3); - }); - }); - }); -}); diff --git a/tests/execution.test.js b/tests/execution.test.js new file mode 100644 index 000000000..936bc7c3f --- /dev/null +++ b/tests/execution.test.js @@ -0,0 +1,115 @@ +import expect from 'expect'; +import mock from 'jest-mock'; +import { enter, join, fork } from '../src/index'; + +describe('execution', () => { + + describe('raw functions that resume immediately', () => { + let exec, exit; + beforeEach(() => { + exit = mock.fn(); + exec = enter(({ resume, ensure }) => { + ensure(exit); + resume(1234); + }); + }); + it('is completed', () => { + expect(exec.isCompleted).toBe(true); + }); + it('has the result that was resumed', () => { + expect(exec.result).toEqual(1234); + }); + it('invokes any exit handlers', () => { + expect(exit).toHaveBeenCalled(); + }); + }); + describe('raw functions that fail immediately', () => { + let exec, exit; + beforeEach(() => { + exit = mock.fn(); + exec = enter(({ fail, ensure }) => { + ensure(exit); + fail(new Error('boom!')); + }); + }); + it('is errored', () => { + expect(exec.isErrored).toEqual(true); + }); + it('has the error as its result', () => { + let { result } = exec; + expect(result).toBeDefined(); + expect(result.message).toEqual('boom!'); + }); + it('invokes any exit handlers', () => { + expect(exit).toHaveBeenCalled(); + }); + }); + + describe('an operation that joins the other', () => { + let exec, resume, fail; + + beforeEach(() => { + exec = enter(join(enter(context => ({ resume, fail } = context)))); + }); + it('is still running', () => { //invariant + expect(exec.isRunning).toBe(true); + }); + + describe('when the joined operation resumes', () => { + beforeEach(() => { + resume(1234); + }); + it('is completed', () => { + expect(exec.isCompleted).toBe(true); + }); + it('has the same result ', () => { + expect(exec.result).toEqual(1234); + }); + }); + + describe('when the joined operation fails', () => { + beforeEach(() => { + fail(new Error('boom!')); + }); + it('is errored', () => { + expect(exec.isErrored).toBe(true); + }); + }); + }); + + describe('forking from a process', () => { + let exec, resume, fail; + + beforeEach(() => { + exec = enter(fork(context => ({ resume, fail } = context))); + }); + + it('makes the external process waiting and blocking', () => { + expect(exec.isBlocking).toBe(true); + expect(exec.isWaiting).toBe(true); + }); + + describe('resuming the forked process', () => { + beforeEach(() => { + resume(); + }); + it('completes the external process', () => { + expect(exec.isCompleted).toBe(true); + }); + }); + + describe('failing the forked process', () => { + beforeEach(() => { + fail(new Error('boom!')); + }); + + it('fails the process', () => { + expect(exec.isErrored).toBe(true); + }); + it('has the error as the result', () => { + expect(exec.result).toBeDefined(); + expect(exec.result.message).toEqual('boom!'); + }); + }); + }); +}); diff --git a/tests/fork-as-promise.test.js b/tests/fork-as-promise.test.js index 17eb71949..6dce043a0 100644 --- a/tests/fork-as-promise.test.js +++ b/tests/fork-as-promise.test.js @@ -1,20 +1,18 @@ import expect from 'expect'; import mock from 'jest-mock'; -import { fork } from '../src/index'; +import { enter, fork } from '../src/index'; +const HALT = expect.stringContaining('Interrupted'); async function suspend() {} describe('forks as promises', () => { - let root, child; + let root, child, awaken; beforeEach(async () => { - root = fork(function*() { - child = fork(function* () { - return yield; - }); - - return yield; + root = enter(function*() { + child = yield fork(); + return yield ({ resume }) => awaken = resume; }); }); @@ -32,99 +30,106 @@ describe('forks as promises', () => { }); it('starts off in pending state', async () => { - await suspend(); - expect(onResolveRoot).not.toHaveBeenCalled(); expect(onResolveChild).not.toHaveBeenCalled(); expect(onRejectRoot).not.toHaveBeenCalled(); expect(onRejectChild).not.toHaveBeenCalled(); }); - it('resolves inner when inner operation finishes', async () => { - child.resume(123); - await suspend(); + describe('when the inner operation finishes', () => { + beforeEach(async () => { + child.resume(123); + }); - expect(onResolveRoot).not.toHaveBeenCalled(); - expect(onResolveChild).toHaveBeenCalledWith(123); - expect(onRejectRoot).not.toHaveBeenCalled(); - expect(onRejectChild).not.toHaveBeenCalled(); + it('resolves inner promise', () => { + expect(onResolveRoot).not.toHaveBeenCalled(); + expect(onResolveChild).toHaveBeenCalledWith(123); + expect(onRejectRoot).not.toHaveBeenCalled(); + expect(onRejectChild).not.toHaveBeenCalled(); + }); }); - it('resolves when operation and all children finish', async () => { - child.resume(123); - root.resume(567); - await suspend(); + describe('when operation and all childen finish', () => { + beforeEach(async () => { + child.resume(123); + awaken(567); + }); - expect(onResolveRoot).toHaveBeenCalledWith(567); - expect(onResolveChild).toHaveBeenCalledWith(123); - expect(onRejectRoot).not.toHaveBeenCalled(); - expect(onRejectChild).not.toHaveBeenCalled(); + it('resolves', () => { + expect(onResolveRoot).toHaveBeenCalledWith(567); + expect(onResolveChild).toHaveBeenCalledWith(123); + expect(onRejectRoot).not.toHaveBeenCalled(); + expect(onRejectChild).not.toHaveBeenCalled(); + }); }); - it('rejects when child errors', async () => { - child.throw(new Error('boom')); - await suspend(); + describe('when child errors', () => { + beforeEach(async () => { + child.fail(new Error('boom')); + }); + + it('rejects when child errors', () => { + expect(onResolveRoot).not.toHaveBeenCalled(); + expect(onResolveChild).not.toHaveBeenCalled(); + expect(onRejectRoot).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); + expect(onRejectChild).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); + }); - expect(onResolveRoot).not.toHaveBeenCalled(); - expect(onResolveChild).not.toHaveBeenCalled(); - expect(onRejectRoot).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); - expect(onRejectChild).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); }); - it('rejects when parent errors (child is halted)', async () => { - root.throw(new Error('boom')); - await suspend(); + describe('when parent errors because child is halted', () => { + beforeEach(async () => { + root.fail(new Error('boom')); + }); - expect(onResolveRoot).not.toHaveBeenCalled(); - expect(onResolveChild).not.toHaveBeenCalled(); - expect(onRejectRoot).toHaveBeenCalledWith( - expect.objectContaining({ message: 'boom' }) - ); - expect(onRejectChild).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'halt', - cause: expect.objectContaining({ message: 'boom' }) - }) - ); + it('rejects', async () => { + expect(onResolveRoot).not.toHaveBeenCalled(); + expect(onResolveChild).not.toHaveBeenCalled(); + expect(onRejectRoot).toHaveBeenCalledWith( + expect.objectContaining({ message: 'boom' }) + ); + expect(onRejectChild).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Interrupted'), + cause: expect.objectContaining({ message: 'boom' }) + }) + ); + }); }); - it('rejects with halt when parent halts', async () => { - root.halt(); - await suspend(); + describe('when parent halts', () => { + beforeEach(async () => { + root.halt(999); + }); - expect(onResolveRoot).not.toHaveBeenCalled(); - expect(onResolveChild).not.toHaveBeenCalled(); - expect(onRejectRoot).toHaveBeenCalledWith(expect.objectContaining({ message: 'halt' })); - expect(onRejectChild).toHaveBeenCalledWith(expect.objectContaining({ message: 'halt' })); + it('rejects with halt with result', () => { + expect(onResolveRoot).not.toHaveBeenCalled(); + expect(onResolveChild).not.toHaveBeenCalled(); + expect(onRejectRoot).toHaveBeenCalledWith(expect.objectContaining({ message: HALT, cause: 999 })); + expect(onRejectChild).toHaveBeenCalledWith(expect.objectContaining({ message: HALT, cause: 999 })); + }); }); - it('rejects with halt with result when parent halts', async () => { - root.halt(999); - await suspend(); - expect(onResolveRoot).not.toHaveBeenCalled(); - expect(onResolveChild).not.toHaveBeenCalled(); - expect(onRejectRoot).toHaveBeenCalledWith(expect.objectContaining({ message: 'halt', cause: 999 })); - expect(onRejectChild).toHaveBeenCalledWith(expect.objectContaining({ message: 'halt', cause: 999 })); - }); }); describe('with promise attached after finalization', function() { it('starts is resolved immediately', async () => { child.resume(567); - root.resume(123); + awaken(123); await expect(child).resolves.toBe(567); await expect(root).resolves.toBe(123); }); it('starts is rejected immediately if halted', async () => { - root.halt(); - await expect(root).rejects.toThrow('halt'); + root.halt('stop'); + await suspend(); + await expect(root).rejects.toThrow("stop"); }); it('starts is rejected immediately if errored', async () => { - root.throw(new Error('boom')); - await expect(root).rejects.toThrow('boom'); + root.fail(new Error('boom')); + expect(root.result.message).toEqual('boom'); }); }); }); diff --git a/tests/generator-syntax.test.js b/tests/generator-syntax.test.js new file mode 100644 index 000000000..f016a20a7 --- /dev/null +++ b/tests/generator-syntax.test.js @@ -0,0 +1,187 @@ +/* global describe, beforeEach, it */ +/* eslint require-yield: 0 */ +/* eslint no-unreachable: 0 */ + +import expect from 'expect'; +import mock from 'jest-mock'; + +import { enter, fork, join } from '../src/index'; + +describe('Co-routine guarantees', () => { + let top, inner, error; + let finalizeTop, finalizeMiddle; + + beforeEach(() => { + finalizeTop = mock.fn(); + finalizeMiddle = mock.fn(); + top = enter(function* top() { + try { + return yield function* middle() { + inner = yield fork(); + try { + return yield join(inner); + } finally { + finalizeMiddle(); + } + }; + } catch (e) { + error = e; + } finally { + finalizeTop(); + } + }); + }); + + + it('has an id', () => { + expect(typeof top.id).toEqual('number'); + }); + + it('calls all the way through to the inner child', () => { + expect(inner).toBeDefined(); + }); + + it('allocates a bigger number to the child id', () => { + expect(inner.id > top.id).toEqual(true); + }); + + it('does not invoke any callback', () => { + expect(finalizeTop).not.toHaveBeenCalled(); + expect(finalizeMiddle).not.toHaveBeenCalled(); + }); + + describe('resuming the inner child', () => { + beforeEach(() => { + expect(inner).toBeDefined(); + inner.resume(10); + }); + + it('completes the outer execution', () => { + expect(top.isCompleted).toEqual(true); + expect(top.isBlocking).toEqual(false); + }); + + it('completes the inner execution', () => { + expect(inner.isCompleted).toEqual(true); + expect(inner.isBlocking).toEqual(false); + }); + + it('passes values up through the stack', () => { + expect(top.result).toEqual(10); + }); + + it('invokes all finalizers', () => { + expect(finalizeMiddle).toHaveBeenCalled(); + expect(finalizeTop).toHaveBeenCalled(); + }); + }); + + describe('throwing an error into the inner child', () => { + let err; + beforeEach(() => { + expect(inner).toBeDefined(); + inner.fail(err = new Error('boom!')); + }); + + it('errors out the inner execution', () => { + expect(inner.isErrored).toEqual(true); + expect(inner.isBlocking).toEqual(false); + }); + + it('completes the outer execution', () => { + expect(error).toEqual(err); + expect(top.isCompleted).toEqual(true); + expect(top.isBlocking).toEqual(false); + }); + + it('invokes all finalizers', () => { + expect(finalizeTop).toHaveBeenCalled(); + expect(finalizeMiddle).toHaveBeenCalled(); + }); + }); + + describe('halting the inner child', () => { + beforeEach(() => { + expect(inner).toBeDefined(); + inner.halt('kill it with fire'); + }); + + it('halts the inner child', () => { + expect(inner.isHalted).toEqual(true); + }); + + it('errors out the parents that depended on it', () => { + expect(top.isCompleted).toEqual(true); + expect(error.message).toMatch(/kill it with fire/); + }); + }); + + describe('halting the outer execution', () => { + beforeEach(() => { + top.halt('shut it down'); + }); + + it('halts the inner most child', () => { + expect(inner.isHalted).toEqual(true); + expect(inner.result).toEqual('shut it down'); + }); + }); + +}); + +describe('deeply nested task that throws an error', () => { + let execution, error; + beforeEach(() => { + execution = enter(function*() { + try { + return yield function*() { + return yield function*() { + throw new Error('boom!'); + }; + }; + } catch (e) { + error = e; + } + }); + }); + it('throws the error all the way up to the top', () => { + expect(error.message).toEqual('boom!'); + }); + + it('completes the execution', () => { + expect(execution.isCompleted).toEqual(true); + }); +}); + +describe('executing a generators', () => { + let execution; + function* add(a, b) { + return a + b; + } + + describe('nested inside generator functions', () => { + beforeEach(() => { + + execution = enter(function*() { + let one = yield function*() { return 1; }; + let two = yield function*() { return 2; }; + return yield add(one, two); + }); + }); + + it('computes the result just fine', () => { + expect(execution.isCompleted).toEqual(true); + expect(execution.result).toEqual(3); + }); + }); + + describe('directly', () => { + beforeEach(() => { + execution = enter(add(1, 2)); + }); + it('computes the result just fine', () => { + expect(execution.isCompleted).toEqual(true); + expect(execution.result).toEqual(3); + }); + }); +}); diff --git a/tests/promise-of.test.js b/tests/promise-of.test.js index 25e811836..9729fb9c4 100644 --- a/tests/promise-of.test.js +++ b/tests/promise-of.test.js @@ -1,6 +1,6 @@ import expect from 'expect'; -import { fork } from '../src/index'; +import { enter } from '../src/index'; describe('yielding on a promise', () => { let execution, deferred, error; @@ -8,7 +8,7 @@ describe('yielding on a promise', () => { beforeEach(() => { error = undefined; deferred = new Deferred(); - execution = fork(function*() { + execution = enter(function*() { try { return yield deferred.promise; } catch (e) { @@ -18,9 +18,7 @@ describe('yielding on a promise', () => { }); describe('when the promise resolves', () => { - beforeEach(() => { - return deferred.resolve('hello'); - }); + beforeEach(() => deferred.resolve('hello')); it('completes the execution with the result', () => { expect(execution.isCompleted).toEqual(true); diff --git a/tests/root.test.js b/tests/root.test.js index f6dce462d..a08c3101f 100644 --- a/tests/root.test.js +++ b/tests/root.test.js @@ -4,17 +4,15 @@ import expect from 'expect'; -import { fork } from '../src/index'; +import { enter, fork } from '../src/index'; describe('Root', () => { let execution, child, grandchild; beforeEach(() => { - execution = fork(function() { - child = fork(function*() { - grandchild = fork(function*() { - yield; - }); + execution = enter(function* () { + child = yield fork(function*() { + grandchild = yield fork(); }); }); }); diff --git a/types/execute.test.ts b/types/execute.test.ts index 5119393d8..e0dc11f16 100644 --- a/types/execute.test.ts +++ b/types/execute.test.ts @@ -1,25 +1,25 @@ -import { Execution, Sequence, fork } from 'effection'; +import { Context, Sequence, enter, fork } from 'effection'; function* operation(): Sequence {} -let execution: Execution; +let execution: Context; -execution = fork(operation); +execution = enter(fork(operation)); -execution = fork(operation()); +execution = enter(operation()); -execution = fork(Promise.resolve("hello world")); +execution = enter(Promise.resolve("hello world")); -execution = fork(function*() {}); +execution = enter(function*() {}); -execution = fork(undefined); +execution = enter(undefined); -execution = fork((execution: Execution) => { - execution.id; - execution.resume(10); - execution.halt("optional reason"); - execution.halt(); - execution.throw(new Error('boom!')); +execution = enter(({ resume, fail, ensure, context }) => { + context.id; + resume(10); + context.halt(); + ensure((c: Context) => console.log('done')); + fail(new Error('boom!')); }); // $ExpectError diff --git a/types/imports.test.ts b/types/imports.test.ts index b3167fa65..f407a66a0 100644 --- a/types/imports.test.ts +++ b/types/imports.test.ts @@ -1,7 +1,9 @@ import { Operation, Sequence, - Execution, + SequenceFunction, + Context, fork, + join, timeout } from 'effection'; diff --git a/types/index.d.ts b/types/index.d.ts index 81df2c042..5490d24d2 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,21 +1,29 @@ // TypeScript Version: 3.6 declare module "effection" { - export type Operation = SequenceFn | Sequence | Promise | Controller | Execution | undefined; - export type SequenceFn = (this: Execution) => Sequence; - export type Controller = (execution: Execution) => void | (() => void); + + export type Operation = Sequence | SequenceFunction | ControlFunction | PromiseLike | undefined; export interface Sequence extends Generator {} + export interface SequenceFunction { (): Sequence; } - export interface Execution extends PromiseLike { - id: number; + export interface ControlFunction { + (controls: Controls): void; + } + + export interface Controls { resume(result: T): void; - throw(error: Error): void; - halt(reason?: any): void; - catch(fn: (error: Error) => R): Promise; - finally(fn: () => void): Promise; + fail(error: Error): void; + ensure(callback: (context: Context) => void): void; + context: Context; } - export function fork(operation: Operation): Execution; + export interface Context extends Promise { + id: number; + halt(reason?: any): void; + } - export function timeout(durationMillis: number): Operation; + export function enter(operation: Operation): Context; + export function fork(operation: Operation): Operation>; + export function join(context: Context): Operation; + export function timeout(durationMillis: number): Operation; } diff --git a/types/promise.test.ts b/types/promise.test.ts index 3a8019780..1a9a1edb8 100644 --- a/types/promise.test.ts +++ b/types/promise.test.ts @@ -1,21 +1,21 @@ -import { fork } from 'effection'; +import { enter } from 'effection'; async function someAsyncFunction() { - await fork(function*() { + await enter(function*() { yield }); await Promise.all([ - fork(function*() { yield }), - fork(function*() { yield }), + enter(function*() { yield }), + enter(function*() { yield }), ]); - let someFork = fork(function*() { + let someFork = enter(function*() { yield return 123; }); someFork.then((value: number) => {}, (error) => {}); - someFork.catch((error: Error) => "string").then((some: string) => {}); - someFork.finally(() => "string").then((some: number) => {}); + someFork.catch((error: Error) => "string").then((some) => {}); + someFork.finally(() => "string").then((some) => {}); }