# HG changeset patch # User cin # Date 2018-08-27 05:38:27 # Node ID c1c00bfb5487f0f6612fa9fdf6e206325b857de3 # Parent e1c664dbc684e2c583fb220177b8b9bdaee31bc2 Async operation cancellation proposal ActivatableMixin diff --git a/build.gradle b/build.gradle --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ def testDir = "$buildDir/test" task clean { doLast { delete buildDir + delete 'node_modules/@implab' } } diff --git a/docs/ActivatableMixin/activate.puml b/docs/ActivatableMixin/activate.puml new file mode 100644 --- /dev/null +++ b/docs/ActivatableMixin/activate.puml @@ -0,0 +1,25 @@ +@startuml + +participant Component as a +participant Other as b + +[-> a : activate(ct) +activate a +<-- a : promise +a -> a : onActivating(ct) +activate a +a -> b : doAsyncWork(ct) +deactivate a +deactivate a +activate b + +[-> b : ct.cancel +b --> a : reject(Cancelled) +deactivate b +activate a + +a -> a : setFailState() + +[<-- a : reject(Cancelled) + +@enduml \ No newline at end of file diff --git a/src/js/di/Container.js b/src/js/di/Container.js --- a/src/js/di/Container.js +++ b/src/js/di/Container.js @@ -110,7 +110,7 @@ define([ if (typeof (config) === "string") { p = new Deferred(); if (!contextRequire) { - var shim = [config, new Uuid()].join(config.indexOf("/") != -1 ? "-" : "/"); + var shim = [config, Uuid()].join(config.indexOf("/") != -1 ? "-" : "/"); define(shim, ["require", config], function (ctx, data) { p.resolve([data, { contextRequire: ctx diff --git a/src/js/log/ConsoleLogChannel.js b/src/js/log/ConsoleLogChannel.js deleted file mode 100644 --- a/src/js/log/ConsoleLogChannel.js +++ /dev/null @@ -1,30 +0,0 @@ -define( - [ "dojo/_base/declare", "../text/format" ], - function(declare, format) { - return declare( - null, - { - name : null, - - constructor : function(name) { - this.name = name; - }, - - log : function() { - console.log(this._makeMsg(arguments)); - }, - - warn : function() { - console.warn(this._makeMsg(arguments)); - }, - - error : function() { - console.error(this._makeMsg(arguments)); - }, - - _makeMsg : function(args) { - return this.name ? this.name + " " + - format.apply(null, args) : format.apply(null, args); - } - }); - }); \ No newline at end of file diff --git a/src/js/log/_LogMixin.js b/src/js/log/_LogMixin.js deleted file mode 100644 --- a/src/js/log/_LogMixin.js +++ /dev/null @@ -1,67 +0,0 @@ -define([ "dojo/_base/declare" ], - -function(declare) { - var cls = declare(null, { - _logChannel : null, - - _logLevel : 1, - - constructor : function(opts) { - if (typeof opts == "object") { - if ("logChannel" in opts) - this._logChannel = opts.logChannel; - if ("logLevel" in opts) - this._logLevel = opts.logLevel; - } - }, - - getLogChannel : function() { - return this._logChannel; - }, - - setLogChannel : function(v) { - this._logChannel = v; - }, - - getLogLevel : function() { - return this._logLevel; - }, - - setLogLevel : function(v) { - this._logLevel = v; - }, - - log : function(format) { - if (this._logChannel && this._logLevel > 2) - this._logChannel.log.apply(this._logChannel, arguments); - }, - warn : function(format) { - if (this._logChannel && this._logLevel > 1) - this._logChannel.warn.apply(this._logChannel, arguments); - }, - error : function(format) { - if (this._logChannel && this._logLevel > 0) - this._logChannel.error.apply(this._logChannel, arguments); - }, - - /** - * Used to by widgets - */ - startup : function() { - var me = this, parent; - if (!me.getLogChannel()) { - parent = me; - while (parent = parent.getParent()) { - if (parent.getLogChannel) { - me.setLogChannel(parent.getLogChannel()); - if(parent.getLogLevel) - me.setLogLevel(parent.getLogLevel()); - break; - } - } - } - this.inherited(arguments); - } - }); - return cls; -}); \ No newline at end of file diff --git a/src/js/safe.js b/src/js/safe.js --- a/src/js/safe.js +++ b/src/js/safe.js @@ -175,11 +175,13 @@ define([], } } - try { - return wrapresult(fn.apply(thisArg, arguments)); - } catch (e) { - return wrapresult(null, e); - } + return function() { + try { + return wrapresult(fn.apply(thisArg, arguments)); + } catch (e) { + return wrapresult(null, e); + } + }; }, create: function () { diff --git a/src/ts/EmptyCancellation.ts b/src/ts/EmptyCancellation.ts new file mode 100644 --- /dev/null +++ b/src/ts/EmptyCancellation.ts @@ -0,0 +1,20 @@ +import { ICancellation } from "./ICancellation"; + +export class EmptyCancellation implements ICancellation { + isSupported(): boolean { + return false; + } + throwIfRequested(): void { + } + + isRequested(): boolean { + return false; + } + + register(_cb: () => void): void { + + } + + static readonly default : EmptyCancellation = new EmptyCancellation(); + +} \ No newline at end of file diff --git a/src/ts/ICancellation.ts b/src/ts/ICancellation.ts new file mode 100644 --- /dev/null +++ b/src/ts/ICancellation.ts @@ -0,0 +1,6 @@ +export interface ICancellation { + throwIfRequested(): void; + isRequested(): boolean; + isSupported(): boolean; + register(cb: () => void): void; +} \ No newline at end of file diff --git a/src/ts/Uuid.ts b/src/ts/Uuid.ts --- a/src/ts/Uuid.ts +++ b/src/ts/Uuid.ts @@ -6,6 +6,8 @@ // Copyright (c) 2010-2012 Robert Kieffer // MIT License - http://opensource.org/licenses/mit-license.php +declare var window: any; + let _window : any = 'undefined' !== typeof window ? window : null; // Unique ID creation requires a high quality random # generator. We diff --git a/src/ts/components/ActivatableMixin.ts b/src/ts/components/ActivatableMixin.ts new file mode 100644 --- /dev/null +++ b/src/ts/components/ActivatableMixin.ts @@ -0,0 +1,92 @@ +import { IActivationController } from './IActivationController'; +import { IActivatable } from './IActivatable'; +import { AsyncComponent } from './AsyncComponent'; +import { ICancellation } from '../ICancellation'; +import { EmptyCancellation } from '../EmptyCancellation'; + +type Constructor = new (...args: any[]) => T; + +function ActivatableMixin>(Base: TBase) { + return class extends Base implements IActivatable { + _controller: IActivationController; + + _active: boolean; + + isActive() { + return this._active; + } + + getActivationController() { + return this._controller; + } + + setActivationController(controller: IActivationController) { + this._controller = controller; + } + + async onActivating(ct: ICancellation) { + if (this._controller) + await this._controller.activating(this, ct); + } + + async onActivated(ct: ICancellation) { + if (this._controller) + await this._controller.activated(this, ct); + } + + async activate(ct: ICancellation = EmptyCancellation.default) { + if (this.isActive()) + return; + ct = this.startOperation(ct); + try { + await this.onActivating(ct); + this._active = true; + try { + await this.onActivated(ct); + } catch { + // TODO log error + } + this.completeSuccess(); + } catch (e) { + this.completeFail(e); + } + return this.getCompletion(); + } + + async onDeactivating(ct: ICancellation) { + if (this._controller) + await this._controller.deactivating(this, ct); + } + + async onDeactivated(ct: ICancellation) { + if (this._controller) + await this._controller.deactivated(this, ct); + } + + async deactivate(ct: ICancellation = EmptyCancellation.default) { + if (!this.isActive()) + return; + ct = this.startOperation(ct); + try { + await this.onDeactivating(ct); + this._active = false; + try { + await this.onDeactivated(ct); + } catch { + // TODO log error + } + this.completeSuccess(); + } catch (e) { + this.completeFail(e); + } + return this.getCompletion(); + } + + } +} + +namespace ActivatableMixin { + +} + +export = ActivatableMixin; \ No newline at end of file diff --git a/src/ts/components/AsyncComponent.ts b/src/ts/components/AsyncComponent.ts new file mode 100644 --- /dev/null +++ b/src/ts/components/AsyncComponent.ts @@ -0,0 +1,47 @@ +import { ICancellation } from "../ICancellation"; +import { EmptyCancellation } from "../EmptyCancellation"; + +export class AsyncComponent { + _completion: Promise; + + _deferred: { + resolve(): void + reject(reason: any): void + }; + + getCompletion() { return this._completion }; + + startOperation(ct: ICancellation = EmptyCancellation.default) { + if (this._deferred) + throw new Error("The async operation is already pending"); + + this._completion = new Promise((resolve, reject) => { + this._deferred = { + resolve: resolve, + reject: reject + } + }); + return ct; + } + + completeSuccess() { + this._deferred.resolve(); + this._deferred = null; + } + + completeFail(reason: any) { + this._deferred.reject(reason); + this._deferred = null; + } + + async runOperation(cb: (ct: ICancellation) => Promise, ct: ICancellation = EmptyCancellation.default) { + //safe.argumentNotNull(cb, "cb") + ct = this.startOperation(ct); + try { + await cb(ct); + this.completeSuccess(); + } catch(e) { + this.completeFail(e); + } + } +} \ No newline at end of file diff --git a/src/ts/components/IActivatable.ts b/src/ts/components/IActivatable.ts new file mode 100644 --- /dev/null +++ b/src/ts/components/IActivatable.ts @@ -0,0 +1,39 @@ +import { IActivationController } from "./IActivationController"; +import { ICancellation } from "../ICancellation"; + +/** + * Интерфейс поддерживающий асинхронную активацию + */ +export interface IActivatable { + /** + * @returns Boolean indicates the current state + */ + isActive(): boolean; + + /** + * Starts the component activation + * @param ct cancellation token for this operation + */ + activate(ct?: ICancellation): Promise; + + /** + * Starts the component deactivation + * @param ct cancellation token for this operation + */ + deactivate(ct?: ICancellation): Promise; + + /** + * Sets the activation controller for this component + * @param controller The activation controller + * + * Activation controller checks whether this component + * can be activated and manages the active state of the + * component + */ + setActivationController(controller: IActivationController); + + /** + * Gets the current activation controller for this component + */ + getActivationController(): IActivationController; +} \ No newline at end of file diff --git a/src/ts/components/IActivationController.ts b/src/ts/components/IActivationController.ts new file mode 100644 --- /dev/null +++ b/src/ts/components/IActivationController.ts @@ -0,0 +1,19 @@ +import { IActivatable } from './IActivatable'; +import { ICancellation } from '../ICancellation'; +import { EmptyCancellation } from '../EmptyCancellation'; + +export interface IActivationController { + activating(component: IActivatable, ct?: ICancellation): Promise; + + activated(component: IActivatable, ct?: ICancellation): Promise; + + deactivating(component: IActivatable, ct?: ICancellation): Promise; + + deactivated(component: IActivatable, ct?: ICancellation): Promise; + + deactivate(ct?: ICancellation): Promise; + + activate(component: IActivatable, ct?: ICancellation): Promise; + + getActive(): IActivatable; +} \ No newline at end of file diff --git a/src/ts/log/TraceEventArgs.ts b/src/ts/log/TraceEventArgs.ts new file mode 100644 --- /dev/null +++ b/src/ts/log/TraceEventArgs.ts @@ -0,0 +1,20 @@ +import * as TraceSource from './TraceSource' + +class TraceEventArgs { + source : TraceSource + + message : string + + level : number + + constructor(source: TraceSource, message: string) { + this.source = source; + this.message = message; + } +} + +namespace TraceEventArgs { + +} + +export = TraceEventArgs \ No newline at end of file diff --git a/src/ts/log/TraceSource.ts b/src/ts/log/TraceSource.ts new file mode 100644 --- /dev/null +++ b/src/ts/log/TraceSource.ts @@ -0,0 +1,116 @@ +import * as TraceEventArgs from './TraceEventArgs' +import * as format from '../text/format' + +interface Handler { + (arg: TraceEventArgs): void; +} + +interface Destroyable { + destroy(); +} + +class HandlerDescriptor implements Destroyable { + private _target: TraceSource + + readonly handler: Handler; + + constructor(target: TraceSource, handler: Handler) { + this._target = target; + this.handler = handler; + } + + destroy() { + this._target.remove(this); + } +} + + +class TraceSource { + readonly id: any + + private _handlers: Array + + level: number + + constructor(id: any) { + this.id = id || new Object(); + this._handlers = new Array(); + } + + on(handler: Handler): Destroyable { + if (!handler) + throw new Error("A handler must be specified"); + + let d = new HandlerDescriptor(this, handler) + + this._handlers.push(d); + + return d; + } + + remove(cookie: any): void { + let i = this._handlers.indexOf(cookie); + if (i >= 0) + this._handlers.splice(i, 1); + } + + protected emit(level: number, msg: string, ...args: any[]) { + if (level <= this.level) { + let event = new TraceEventArgs(this, format(msg, args)); + + this._handlers.forEach(d => { + try { + d.handler.call(null, event); + } catch { + // suppress error in log handlers + } + }); + } + } + + isDebugEnabled() { + return this.level >= TraceSource.DebugLevel; + } + + debug(msg: string, ...args: any[]): void { + this.emit(TraceSource.DebugLevel, msg, args); + } + + isLogEnabled() { + return this.level >= TraceSource.LogLevel; + } + + log(msg: string, ...args: any[]): void { + this.emit(TraceSource.LogLevel, msg, args); + } + + isWarnEnabled() { + return this.level >= TraceSource.WarnLevel; + } + + warn(msg: string, ...args: any[]): void { + this.emit(TraceSource.WarnLevel, msg, args); + } + + isErrorEnabled() { + return this.level >= TraceSource.ErrorLevel; + } + + error(msg: string, ...args: any[]): void { + this.emit(TraceSource.ErrorLevel, msg, args); + } +} + +namespace TraceSource { + export const DebugLevel = 400; + + export const LogLevel = 300; + + export const WarnLevel = 200; + + export const ErrorLevel = 100; + + export const SilentLevel = 0; +} + +export = TraceSource; \ No newline at end of file diff --git a/src/ts/text/format.d.ts b/src/ts/text/format.d.ts new file mode 100644 --- /dev/null +++ b/src/ts/text/format.d.ts @@ -0,0 +1,7 @@ +declare function format(format: string, ...args: any[]): string; + +declare namespace format { + +} + +export = format; \ No newline at end of file diff --git a/test/js/plan.js b/test/js/plan.js --- a/test/js/plan.js +++ b/test/js/plan.js @@ -1,1 +1,1 @@ -define(["./dummy", "./example"]); \ No newline at end of file +define(["./dummy", "./example", "./ActivatableTests"]); \ No newline at end of file diff --git a/test/ts/ActivatableTests.ts b/test/ts/ActivatableTests.ts new file mode 100644 --- /dev/null +++ b/test/ts/ActivatableTests.ts @@ -0,0 +1,110 @@ +import * as tape from 'tape'; +import * as ActivatableMixin from '@implab/core/components/ActivatableMixin'; +import { AsyncComponent } from '@implab/core/components/AsyncComponent'; +import { IActivationController } from '@implab/core/components/IActivationController'; +import { IActivatable } from '@implab/core/components/IActivatable'; +import { ICancellation } from '@implab/core/ICancellation'; +import { EmptyCancellation } from '@implab/core/EmptyCancellation'; + +class SimpleActivatable extends ActivatableMixin(AsyncComponent) { + +} + +class MockActivationController implements IActivationController { + + _active: IActivatable = null; + + + getActive() : IActivatable { + return this._active; + } + + async deactivate() { + if (this._active) + await this._active.deactivate(); + this._active = null; + } + + async activate(component: IActivatable) { + if (!component || component.isActive()) + return; + component.setActivationController(this); + + await component.activate(); + } + + async activating(component: IActivatable, ct: ICancellation = EmptyCancellation.default) { + if (component != this._active) + await this.deactivate(); + } + + async activated(component: IActivatable, ct: ICancellation = EmptyCancellation.default) { + this._active = component; + } + + async deactivating(component: IActivatable, ct: ICancellation = EmptyCancellation.default) { + + } + + async deactivated(component: IActivatable, ct: ICancellation = EmptyCancellation.default) { + if (this._active == component) + this._active = null; + } +} + +tape('simple activation',async function(t){ + + let a = new SimpleActivatable(); + t.false(a.isActive()); + + await a.activate(); + t.true(a.isActive()); + + await a.deactivate(); + t.false(a.isActive()); + + t.end(); +}); + +tape('controller activation', async function(t) { + + let a = new SimpleActivatable(); + let c = new MockActivationController(); + + t.false(a.isActive(), "the component is not active by default"); + t.assert(c.getActive() == null, "the activation controller doesn't have an active component by default"); + t.assert(a.getActivationController() == null, "the component doesn't have an activation controller by default"); + + t.comment("Active the component through the controller"); + await c.activate(a); + t.true(a.isActive(), "The component should successfully activate"); + t.assert(c.getActive() == a, "The controller should point to the activated component"); + t.assert(a.getActivationController() == c, "The component should point to the controller"); + + t.comment("Deactive the component throug the controller"); + await c.deactivate(); + + t.false(a.isActive(), "The component should successfully deactivate"); + t.assert(c.getActive() == null, "The controller shouldn't point to any component"); + t.assert(a.getActivationController() == c, "The componet should point to it's controller"); + + t.end(); +}); + +tape('handle error in onActivating', async function(t) { + let a = new SimpleActivatable(); + + a.onActivating = async function() { + throw "Should fail"; + }; + + try { + await a.activate(); + t.fail("activation should fail"); + } catch { + } + + t.false(a.isActive(), "the component should remain inactive"); + + t.end(); +}); \ No newline at end of file diff --git a/tsc.json b/tsc.json --- a/tsc.json +++ b/tsc.json @@ -4,7 +4,10 @@ "module": "amd", "sourceMap": true, "outDir" : "build/dist", - "declaration": true + "declaration": true, + "lib": [ + "ES2015" + ] }, "include" : [ "src/ts/**/*.ts" diff --git a/tsc.test.json b/tsc.test.json --- a/tsc.test.json +++ b/tsc.test.json @@ -4,7 +4,10 @@ "module": "amd", "sourceMap": true, "outDir" : "build/test", - "moduleResolution": "node" + "moduleResolution": "node", + "lib": [ + "ES2015" + ] }, "include" : [ "test/ts/**/*.ts"