ember-concurrency
Version:
Improved concurrency/async primitives for Ember.js
427 lines (415 loc) • 14.6 kB
JavaScript
import { GeneratorState } from '../generator-state.js';
import { INITIAL_STATE } from './initial-state.js';
import { YIELDABLE_CONTINUE, YIELDABLE_CANCEL, YIELDABLE_RETURN, YIELDABLE_THROW, cancelableSymbol, yieldableSymbol } from '../yieldables.js';
import { COMPLETION_ERROR, COMPLETION_SUCCESS, COMPLETION_CANCEL } from './completion-states.js';
import { CancelRequest, CANCEL_KIND_YIELDABLE_CANCEL, didCancel, TASK_CANCELATION_NAME, CANCEL_KIND_PARENT_CANCEL, CANCEL_KIND_LIFESPAN_END } from './cancelation.js';
const PERFORM_TYPE_DEFAULT = 'PERFORM_TYPE_DEFAULT';
const PERFORM_TYPE_UNLINKED = 'PERFORM_TYPE_UNLINKED';
const PERFORM_TYPE_LINKED = 'PERFORM_TYPE_LINKED';
const CANCEL_RETURN_VALUE_SENTINEL = {};
let TASK_INSTANCE_STACK = [];
function getRunningInstance() {
return TASK_INSTANCE_STACK[TASK_INSTANCE_STACK.length - 1];
}
class TaskInstanceExecutor {
constructor({
generatorFactory,
env,
debug
}) {
this.generatorState = new GeneratorState(generatorFactory);
this.state = Object.assign({}, INITIAL_STATE);
this.index = 1;
this.disposers = [];
this.finalizeCallbacks = [];
this.env = env;
this.debug = debug;
this.cancelRequest = null;
}
start() {
if (this.state.hasStarted || this.cancelRequest) {
return;
}
this.setState({
hasStarted: true
});
this.proceedSync(YIELDABLE_CONTINUE, undefined);
this.taskInstance.onStarted();
}
cancel(cancelRequest) {
if (!this.requestCancel(cancelRequest)) {
cancelRequest.finalize();
return cancelRequest.promise;
}
if (this.state.hasStarted) {
this.proceedWithCancelAsync();
} else {
this.finalizeWithCancel();
}
return this.cancelRequest.promise;
}
setState(state) {
Object.assign(this.state, state);
this.taskInstance.setState(this.state);
}
proceedChecked(index, yieldResumeType, value) {
if (this.state.isFinished) {
return;
}
if (!this.advanceIndex(index)) {
return;
}
if (yieldResumeType === YIELDABLE_CANCEL) {
this.requestCancel(new CancelRequest(CANCEL_KIND_YIELDABLE_CANCEL), value);
this.proceedWithCancelAsync();
} else {
this.proceedAsync(yieldResumeType, value);
}
}
proceedWithCancelAsync() {
this.proceedAsync(YIELDABLE_RETURN, CANCEL_RETURN_VALUE_SENTINEL);
}
proceedAsync(yieldResumeType, value) {
this.advanceIndex(this.index);
this.env.async(() => this.proceedSync(yieldResumeType, value));
}
proceedSync(yieldResumeType, value) {
if (this.state.isFinished) {
return;
}
this.dispose();
if (this.generatorState.done) {
this.handleResolvedReturnedValue(yieldResumeType, value);
} else {
this.handleResolvedContinueValue(yieldResumeType, value);
}
}
/**
* This method is called when a previously yielded value from
* the generator has been resolved, and now it's time to pass
* it back into the generator. There are 3 ways to "resume" a
* generator:
*
* - call `.next(value)` on it, which is used to pass in a resolved
* value (the fulfilled value of a promise), e.g. if a task generator fn
* does `yield Promise.resolve(5)`, then we take that promise yielded
* by the generator, detect that it's a promise, resolve it, and then
* pass its fulfilled value `5` back into the generator function so
* that it can continue executing.
* - call `.throw(error)` on it, which throw an exception from where the
* the generator previously yielded. We do this when the previously
* yielded value resolves to an error value (e.g. a rejected promise
* or a TaskInstance that finished with an error). Note that when you
* resume a generator with a `.throw()`, it can still recover from that
* thrown error and continue executing normally so long as the `yield`
* was inside a `try/catch` statement.
* - call `.return(value)` on it, causes the generator function to return
* from where it previously `yield`ed. We use `.return()` when cancelling
* a TaskInstance; by `.return`ing, rather than `.throw`ing, it allows
* the generator function to skip `catch(e) {}` blocks, which is usually
* reserved for actual errors/exceptions; if we `.throw`'d cancellations,
* it would require all tasks that used try/catch to conditionally ignore
* cancellations, which is annoying. So we `.return()` from generator functions
* in the case of errors as a matter of convenience.
*
* @private
*/
handleResolvedContinueValue(iteratorMethod, resumeValue) {
let beforeIndex = this.index;
let stepResult = this.generatorStep(resumeValue, iteratorMethod);
// TODO: what is this doing? write breaking test.
if (!this.advanceIndex(beforeIndex)) {
return;
}
if (stepResult.errored) {
this.finalize(stepResult.value, COMPLETION_ERROR);
return;
}
this.handleYieldedValue(stepResult);
}
/**
* This method is called when the generator function is all
* out of values, and the last value returned from the function
* (possible a thenable/yieldable/promise/etc) has been resolved.
*
* Possible cases:
* - `return "simple value";` // resolved value is "simple value"
* - `return undefined;` // (or omitted return) resolved value is undefined
* - `return someTask.perform()` // resolved value is the value returned/resolved from someTask
*
* @private
*/
handleResolvedReturnedValue(yieldResumeType, value) {
switch (yieldResumeType) {
case YIELDABLE_CONTINUE:
case YIELDABLE_RETURN:
this.finalize(value, COMPLETION_SUCCESS);
break;
case YIELDABLE_THROW:
this.finalize(value, COMPLETION_ERROR);
break;
}
}
handleYieldedUnknownThenable(thenable) {
let resumeIndex = this.index;
thenable.then(value => {
this.proceedChecked(resumeIndex, YIELDABLE_CONTINUE, value);
}, error => {
this.proceedChecked(resumeIndex, YIELDABLE_THROW, error);
});
}
/**
* The TaskInstance internally tracks an index/sequence number
* (the `index` property) which gets incremented every time the
* task generator function iterator takes a step. When a task
* function is paused at a `yield`, there are two events that
* cause the TaskInstance to take a step: 1) the yielded value
* "resolves", thus resuming the TaskInstance's execution, or
* 2) the TaskInstance is canceled. We need some mechanism to prevent
* stale yielded value resolutions from resuming the TaskFunction
* after the TaskInstance has already moved on (either because
* the TaskInstance has since been canceled or because an
* implementation of the Yieldable API tried to resume the
* TaskInstance more than once). The `index` serves as
* that simple mechanism: anyone resuming a TaskInstance
* needs to pass in the `index` they were provided that acts
* as a ticket to resume the TaskInstance that expires once
* the TaskInstance has moved on.
*
* @private
*/
advanceIndex(index) {
if (this.index === index) {
return ++this.index;
}
}
handleYieldedValue(stepResult) {
let yieldedValue = stepResult.value;
if (!yieldedValue) {
this.proceedWithSimpleValue(yieldedValue);
return;
}
this.addDisposer(yieldedValue[cancelableSymbol]);
if (yieldedValue[yieldableSymbol]) {
this.invokeYieldable(yieldedValue);
} else if (typeof yieldedValue.then === 'function') {
this.handleYieldedUnknownThenable(yieldedValue);
} else {
this.proceedWithSimpleValue(yieldedValue);
}
}
proceedWithSimpleValue(yieldedValue) {
this.proceedAsync(YIELDABLE_CONTINUE, yieldedValue);
}
addDisposer(maybeDisposer) {
if (typeof maybeDisposer !== 'function') {
return;
}
this.disposers.push(maybeDisposer);
}
/**
* Runs any disposers attached to the task's most recent `yield`.
* For instance, when a task yields a TaskInstance, it registers that
* child TaskInstance's disposer, so that if the parent task is canceled,
* dispose() will run that disposer and cancel the child TaskInstance.
*
* @private
*/
dispose() {
let disposers = this.disposers;
if (disposers.length === 0) {
return;
}
this.disposers = [];
disposers.forEach(disposer => disposer());
}
/**
* Calls .next()/.throw()/.return() on the task's generator function iterator,
* essentially taking a single step of execution on the task function.
*
* @private
*/
generatorStep(nextValue, iteratorMethod) {
TASK_INSTANCE_STACK.push(this);
let stepResult = this.generatorState.step(nextValue, iteratorMethod);
TASK_INSTANCE_STACK.pop();
// TODO: fix this!
if (this._expectsLinkedYield) {
let value = stepResult.value;
if (!value || value.performType !== PERFORM_TYPE_LINKED) {
// eslint-disable-next-line no-console
console.warn('You performed a .linked() task without immediately yielding/returning it. This is currently unsupported (but might be supported in future version of ember-concurrency).');
}
this._expectsLinkedYield = false;
}
return stepResult;
}
maybeResolveDefer() {
if (!this.defer || !this.state.isFinished) {
return;
}
if (this.state.completionState === COMPLETION_SUCCESS) {
this.defer.resolve(this.state.value);
} else {
this.defer.reject(this.state.error);
}
}
onFinalize(callback) {
this.finalizeCallbacks.push(callback);
if (this.state.isFinished) {
this.runFinalizeCallbacks();
}
}
runFinalizeCallbacks() {
this.finalizeCallbacks.forEach(cb => cb());
this.finalizeCallbacks = [];
this.maybeResolveDefer();
this.maybeThrowUnhandledTaskErrorLater();
}
promise() {
if (!this.defer) {
this.defer = this.env.defer();
this.asyncErrorsHandled = true;
this.maybeResolveDefer();
}
return this.defer.promise;
}
maybeThrowUnhandledTaskErrorLater() {
if (!this.asyncErrorsHandled && this.state.completionState === COMPLETION_ERROR && !didCancel(this.state.error)) {
this.env.async(() => {
if (!this.asyncErrorsHandled) {
this.env.reportUncaughtRejection(this.state.error);
}
});
}
}
requestCancel(request) {
if (this.cancelRequest || this.state.isFinished) {
return false;
}
this.cancelRequest = request;
return true;
}
finalize(value, completionState) {
if (this.cancelRequest) {
return this.finalizeWithCancel();
}
let state = {
completionState
};
if (completionState === COMPLETION_SUCCESS) {
state.isSuccessful = true;
state.value = value;
} else if (completionState === COMPLETION_ERROR) {
state.isError = true;
state.error = value;
} else if (completionState === COMPLETION_CANCEL) {
state.error = value;
}
this.finalizeShared(state);
}
finalizeWithCancel() {
let cancelReason = this.taskInstance.formatCancelReason(this.cancelRequest.reason);
let error = new Error(cancelReason);
if (this.debugEnabled()) {
// eslint-disable-next-line no-console
console.log(cancelReason);
}
error.name = TASK_CANCELATION_NAME;
this.finalizeShared({
isCanceled: true,
completionState: COMPLETION_CANCEL,
error,
cancelReason
});
this.cancelRequest.finalize();
}
debugEnabled() {
return this.debug || this.env.globalDebuggingEnabled();
}
finalizeShared(state) {
this.index++;
state.isFinished = true;
this.setState(state);
this.runFinalizeCallbacks();
this.dispatchFinalizeEvents(state.completionState);
}
dispatchFinalizeEvents(completionState) {
switch (completionState) {
case COMPLETION_SUCCESS:
this.taskInstance.onSuccess();
break;
case COMPLETION_ERROR:
this.taskInstance.onError(this.state.error);
break;
case COMPLETION_CANCEL:
this.taskInstance.onCancel(this.state.cancelReason);
break;
}
}
invokeYieldable(yieldedValue) {
try {
let maybeDisposer = yieldedValue[yieldableSymbol](this.taskInstance, this.index);
this.addDisposer(maybeDisposer);
} catch (e) {
this.env.reportUncaughtRejection(e);
}
}
/**
* `onYielded` is called when this task instance has been
* yielded in another task instance's execution. We take
* this opportunity to conditionally link up the tasks
* so that when the parent or child cancels, the other
* is cancelled.
*
* Given the following case:
*
* ```js
* parentTask: task(function * () {
* yield otherTask.perform();
* })
* ```
*
* Then the `parent` param is the task instance that is executing, `this`
* is the `otherTask` task instance that was yielded.
*
* @private
*/
onYielded(parent, resumeIndex) {
this.asyncErrorsHandled = true;
this.onFinalize(() => {
let completionState = this.state.completionState;
if (completionState === COMPLETION_SUCCESS) {
parent.proceed(resumeIndex, YIELDABLE_CONTINUE, this.state.value);
} else if (completionState === COMPLETION_ERROR) {
parent.proceed(resumeIndex, YIELDABLE_THROW, this.state.error);
} else if (completionState === COMPLETION_CANCEL) {
parent.proceed(resumeIndex, YIELDABLE_CANCEL, null);
}
});
let performType = this.getPerformType();
if (performType === PERFORM_TYPE_UNLINKED) {
return;
}
return () => {
this.detectSelfCancelLoop(performType, parent);
this.cancel(new CancelRequest(CANCEL_KIND_PARENT_CANCEL));
};
}
getPerformType() {
return this.taskInstance.performType || PERFORM_TYPE_DEFAULT;
}
detectSelfCancelLoop(performType, parent) {
if (performType !== PERFORM_TYPE_DEFAULT) {
return;
}
let parentCancelRequest = parent.executor && parent.executor.cancelRequest;
// Detect that the parent was cancelled by a lifespan ending and
// that the child is still running and not cancelled.
if (parentCancelRequest && parentCancelRequest.kind === CANCEL_KIND_LIFESPAN_END && !this.cancelRequest && !this.state.isFinished) {
this.taskInstance.selfCancelLoopWarning(parent);
}
}
}
export { PERFORM_TYPE_DEFAULT, PERFORM_TYPE_LINKED, PERFORM_TYPE_UNLINKED, TaskInstanceExecutor, getRunningInstance };
//# sourceMappingURL=executor.js.map