diff --git a/packages/headless-driver/src/__tests__/index.ts b/packages/headless-driver/src/__tests__/index.ts index 24eee956a..ddeb6f07b 100644 --- a/packages/headless-driver/src/__tests__/index.ts +++ b/packages/headless-driver/src/__tests__/index.ts @@ -6,6 +6,7 @@ import * as url from "url"; import fetch from "node-fetch"; import { Permission, StartPoint, GetStartPointOptions } from "@akashic/amflow"; +import { Event } from "@akashic/playlog"; import { RunnerV1, RunnerV1Game } from "@akashic/headless-driver-runner-v1"; import { RunnerV2, RunnerV2Game } from "@akashic/headless-driver-runner-v2"; @@ -248,6 +249,7 @@ describe("run-test", () => { if (err) { assert.equal(err instanceof Error, true); assert.equal(permission == null, true); + assert.strictEqual(err.name, "InvalidStatus"); resolve(); return; } @@ -297,10 +299,6 @@ describe("run-test", () => { }); }); }) - .then(() => { - activeAMFlow.open(playId); - passiveAMFlow.open(playId); - }) .then(() => { return new Promise((resolve, reject) => { // Tick を送信できる @@ -333,6 +331,13 @@ describe("run-test", () => { passiveAMFlow.sendEvent([0, 5, "dummy-player-id"]); }); }) + .then(() => playManager.stopPlay(playId)) + .then(() => { + return new Promise((resolve, reject) => { + // すでに stop したプレーの AMFlowClient に対して close() を呼び出しても問題ない + passiveAMFlow.close(err => (err ? reject(err) : resolve())); + }); + }) .then(done) .catch(e => done(e)); }); @@ -422,6 +427,91 @@ describe("AMFlow の動作テスト", () => { }); }); }); + + it("AMFlow#onEvent が登録されるより以前の Event を正しく取得できる", done => { + const playManager = new PlayManager(); + let activeAMFlow: AMFlowClient; + let passiveAMFlow: AMFlowClient; + const playId = "0"; + const events: Event[] = []; + + Promise.resolve() + .then(() => { + return new Promise((resolve, reject) => { + activeAMFlow = playManager.createAMFlow(playId); + activeAMFlow.open(playId, err => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }) + .then(() => { + return new Promise((resolve, reject) => { + passiveAMFlow = playManager.createAMFlow(playId); + passiveAMFlow.open(playId, err => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }) + .then(() => { + return new Promise((resolve, reject) => { + // 認証できる + const playToken = playManager.createPlayToken(playId, passivePermission); + passiveAMFlow.authenticate(playToken, err => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }) + .then(() => { + // active の AMFlow#authenticate(), AMFlow#onEvent() 呼び出し前にイベントを送信 + passiveAMFlow.sendEvent([0x20, 0, null, { ordinal: 1, hoge: "fuga" }]); + passiveAMFlow.sendEvent([0x20, 0, null, { ordinal: 2, foo: "bar" }]); + }) + .then(() => { + return new Promise((resolve, reject) => { + // Active の認証 + const playToken = playManager.createPlayToken(playId, activePermission); + activeAMFlow.authenticate(playToken, err => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }) + .then(() => { + activeAMFlow.onEvent(event => { + events.push(event); + }); + }) + .then(() => { + // active の AMFlow#onEvent() 呼び出し後にイベントを送信 + passiveAMFlow.sendEvent([0x20, 0, null, { ordinal: 3 }]); + passiveAMFlow.sendEvent([0x20, 0, null, { ordinal: 4 }]); + }) + .then(() => { + assert.deepStrictEqual(events, [ + [0x20, 0, null, { ordinal: 1, hoge: "fuga" }], + [0x20, 0, null, { ordinal: 2, foo: "bar" }], + [0x20, 0, null, { ordinal: 3 }], + [0x20, 0, null, { ordinal: 4 }] + ]); + }) + .then(done) + .catch(e => done(e)); + }); }); describe("コンテンツ動作テスト", () => { diff --git a/packages/headless-driver/src/play/amflow/AMFlowClient.ts b/packages/headless-driver/src/play/amflow/AMFlowClient.ts index 008f0440d..ea5bf5a38 100644 --- a/packages/headless-driver/src/play/amflow/AMFlowClient.ts +++ b/packages/headless-driver/src/play/amflow/AMFlowClient.ts @@ -2,6 +2,7 @@ import { Permission, StartPoint, AMFlow, GetStartPointOptions } from "@akashic/a import { Tick, TickList, Event, StorageData, StorageKey, StorageValue, StorageReadKey } from "@akashic/playlog"; import { getSystemLogger } from "../../Logger"; import { AMFlowStore } from "./AMFlowStore"; +import { createError } from "./ErrorFactory"; export type AMFlowState = "connecting" | "open" | "closing" | "closed"; @@ -17,6 +18,7 @@ export class AMFlowClient implements AMFlow { private permission: Permission = null; private tickHandlers: ((tick: Tick) => void)[] = []; private eventHandlers: ((event: Event) => void)[] = []; + private unconsumedEvents: Event[] = []; constructor(playId: string, store: AMFlowStore) { this.playId = playId; @@ -33,7 +35,7 @@ export class AMFlowClient implements AMFlow { if (callback) { setImmediate(() => { if (this.playId !== playId) { - callback(new Error("Invalid PlayID")); + callback(createError("runtime_error", "Invalid PlayID")); } else { callback(); } @@ -44,7 +46,7 @@ export class AMFlowClient implements AMFlow { close(callback?: (error?: Error) => void): void { getSystemLogger().info("AMFlowClient#close()"); if (this.state !== "open") { - callback(new Error("Client is not open")); + callback(createError("invalid_status", "Client is not open")); return; } @@ -59,7 +61,7 @@ export class AMFlowClient implements AMFlow { authenticate(token: string, callback: (error: Error, permission: Permission) => void): void { setImmediate(() => { if (this.state !== "open") { - callback(new Error("Client is not open"), null); + callback(createError("invalid_status", "Client is not open"), null); return; } const permission = this.store.authenticate(token); @@ -69,56 +71,56 @@ export class AMFlowClient implements AMFlow { if (permission) { callback(null, permission); } else { - callback(new Error("Invalid playToken"), null); + callback(createError("invalid_status", "Invalid playToken"), null); } }); } sendTick(tick: Tick): void { if (this.state !== "open") { - throw new Error("Client is not open"); + throw createError("invalid_status", "Client is not open"); } if (this.permission == null) { - throw new Error("Not authenticated"); + throw createError("invalid_status", "Not authenticated"); } if (!this.permission.writeTick) { - throw new Error("Permission denied"); + throw createError("permission_error", "Permission denied"); } this.store.sendTick(tick); } onTick(handler: (tick: Tick) => void): void { if (this.state !== "open") { - throw new Error("Client is not open"); + throw createError("invalid_status", "Client is not open"); } if (this.permission == null) { - throw new Error("Not authenticated"); + throw createError("invalid_status", "Not authenticated"); } if (!this.permission.subscribeTick) { - throw new Error("Permission denied"); + throw createError("permission_error", "Permission denied"); } this.tickHandlers.push(handler); } offTick(handler: (tick: Tick) => void): void { if (this.state !== "open") { - throw new Error("Client is not open"); + throw createError("invalid_status", "Client is not open"); } if (this.permission == null) { - throw new Error("Not authenticated"); + throw createError("invalid_status", "Not authenticated"); } this.tickHandlers = this.tickHandlers.filter(h => h !== handler); } sendEvent(event: Event): void { if (this.state !== "open") { - throw new Error("Client is not open"); + throw createError("invalid_status", "Client is not open"); } if (this.permission == null) { - throw new Error("Not authenticated"); + throw createError("invalid_status", "Not authenticated"); } if (!this.permission.sendEvent) { - throw new Error("Permission denied"); + throw createError("permission_error", "Permission denied"); } // Max Priority event[1] = Math.min(event[1], this.permission.maxEventPriority); @@ -127,23 +129,28 @@ export class AMFlowClient implements AMFlow { onEvent(handler: (event: Event) => void): void { if (this.state !== "open") { - throw new Error("Client is not open"); + throw createError("invalid_status", "Client is not open"); } if (this.permission == null) { - throw new Error("Not authenticated"); + throw createError("invalid_status", "Not authenticated"); } if (!this.permission.subscribeEvent) { - throw new Error("Permission denied"); + throw createError("permission_error", "Permission denied"); } this.eventHandlers.push(handler); + + if (0 < this.unconsumedEvents.length) { + this.eventHandlers.forEach(h => this.unconsumedEvents.forEach(ev => h(ev))); + this.unconsumedEvents = []; + } } offEvent(handler: (event: Event) => void): void { if (this.state !== "open") { - throw new Error("Client is not open"); + throw createError("invalid_status", "Client is not open"); } if (this.permission == null) { - throw new Error("Not authenticated"); + throw createError("invalid_status", "Not authenticated"); } this.eventHandlers = this.eventHandlers.filter(h => h !== handler); } @@ -151,22 +158,22 @@ export class AMFlowClient implements AMFlow { getTickList(from: number, to: number, callback: (error: Error, tickList: TickList) => void): void { setImmediate(() => { if (this.state !== "open") { - callback(new Error("Client is not open"), null); + callback(createError("invalid_status", "Client is not open"), null); return; } if (this.permission == null) { - callback(new Error("Not authenticated"), null); + callback(createError("invalid_status", "Not authenticated"), null); return; } if (!this.permission.readTick) { - callback(new Error("Permission denied"), null); + callback(createError("permission_error", "Permission denied"), null); return; } const tickList = this.store.getTickList(from, to); if (tickList) { callback(null, tickList); } else { - callback(new Error("No tick list"), null); + callback(createError("runtime_error", "No tick list"), null); } }); } @@ -174,15 +181,15 @@ export class AMFlowClient implements AMFlow { putStartPoint(startPoint: StartPoint, callback: (err: Error) => void): void { setImmediate(() => { if (this.state !== "open") { - callback(new Error("Client is not open")); + callback(createError("invalid_status", "Client is not open")); return; } if (this.permission == null) { - callback(new Error("Not authenticated")); + callback(createError("invalid_status", "Not authenticated")); return; } if (!this.permission.writeTick) { - callback(new Error("Permission denied")); + callback(createError("permission_error", "Permission denied")); return; } this.store.putStartPoint(startPoint); @@ -193,35 +200,35 @@ export class AMFlowClient implements AMFlow { getStartPoint(opts: GetStartPointOptions, callback: (error: Error, startPoint: StartPoint) => void): void { setImmediate(() => { if (this.state !== "open") { - callback(new Error("Client is not open"), null); + callback(createError("invalid_status", "Client is not open"), null); return; } if (this.permission == null) { - callback(new Error("Not authenticated"), null); + callback(createError("invalid_status", "Not authenticated"), null); return; } if (!this.permission.readTick) { - callback(new Error("Permission denied"), null); + callback(createError("permission_error", "Permission denied"), null); return; } const startPoint = this.store.getStartPoint(opts); if (startPoint) { callback(null, startPoint); } else { - callback(new Error("No start point"), null); + callback(createError("runtime_error", "No start point"), null); } }); } putStorageData(key: StorageKey, value: StorageValue, options: any, callback: (err: Error) => void): void { setImmediate(() => { - callback(new Error("Not implemented")); + callback(createError("not_implemented", "Not implemented")); }); } getStorageData(keys: StorageReadKey[], callback: (error: Error, values: StorageData[]) => void): void { setImmediate(() => { - callback(new Error("Not implemented"), null); + callback(createError("not_implemented", "Not implemented"), null); }); } @@ -230,12 +237,22 @@ export class AMFlowClient implements AMFlow { } destroy(): void { - this.store.sendEventTrigger.remove(this.onEventSended, this); - this.store.sendTickTrigger.remove(this.onTickSended, this); + if (this.isDestroyed()) { + return; + } + if (!this.store.isDestroyed()) { + this.store.sendEventTrigger.remove(this.onEventSended, this); + this.store.sendTickTrigger.remove(this.onTickSended, this); + } this.store = null; this.permission = null; this.tickHandlers = null; this.eventHandlers = null; + this.unconsumedEvents = null; + } + + isDestroyed(): boolean { + return this.store == null; } private onTickSended(tick: Tick): void { @@ -243,6 +260,10 @@ export class AMFlowClient implements AMFlow { } private onEventSended(event: Event): void { + if (this.eventHandlers.length <= 0) { + this.unconsumedEvents.push(event); + return; + } this.eventHandlers.forEach(h => h(event)); } } diff --git a/packages/headless-driver/src/play/amflow/AMFlowStore.ts b/packages/headless-driver/src/play/amflow/AMFlowStore.ts index fdacd8ff7..43d9a8217 100644 --- a/packages/headless-driver/src/play/amflow/AMFlowStore.ts +++ b/packages/headless-driver/src/play/amflow/AMFlowStore.ts @@ -30,7 +30,8 @@ export class AMFlowStore { sendTick(tick: Tick): void { if (this.tickList) { if (this.tickList[0] <= tick[0] && tick[0] <= this.tickList[1]) { - throw new Error("illegal age tick"); + // illegal age tick + return; } this.tickList[1] = tick[0]; } else { @@ -44,7 +45,6 @@ export class AMFlowStore { } sendEvent(event: Event): void { - // TODO: イベントのスタック化 this.sendEventTrigger.fire(this.cloneDeep(event)); } @@ -99,6 +99,9 @@ export class AMFlowStore { } destroy(): void { + if (this.isDestroyed()) { + return; + } this.sendEventTrigger.destroy(); this.sendTickTrigger.destroy(); this.sendEventTrigger = null; @@ -107,6 +110,10 @@ export class AMFlowStore { this.startPoints = null; } + isDestroyed(): boolean { + return this.amflowClientManager == null; + } + private cloneDeep(target: T): T { return cloneDeep(target); } diff --git a/packages/headless-driver/src/play/amflow/ErrorFactory.ts b/packages/headless-driver/src/play/amflow/ErrorFactory.ts new file mode 100644 index 000000000..8925ff7a6 --- /dev/null +++ b/packages/headless-driver/src/play/amflow/ErrorFactory.ts @@ -0,0 +1,84 @@ +export class AMFlowError extends Error { + name: string; + message: any; + + constructor(message?: any) { + super(message); + this.message = message; + } +} + +export type AMFlowErrorName = + | "invalid_status" + | "permission_error" + | "not_implemented" + | "timeout" + | "bad_reequest" + | "runtime_error" + | "token_revoked"; + +/** + * 不正な状態 + */ +export class InvalidStatusError extends AMFlowError { + name: string = "InvalidStatus"; +} + +/** + * 必要な権限が無い + */ +export class PermissionError extends AMFlowError { + name: string = "PermissionError"; +} + +/** + * 未実装 + */ +export class NotImplementedError extends AMFlowError { + name: string = "NotImplemented"; +} + +/** + * タイムアウト + */ +export class TimeoutError extends AMFlowError { + name: string = "Timeout"; +} + +/** + * 不正な要求 + */ +export class BadRequestError extends AMFlowError { + name: string = "BadRequest"; +} + +/** + * 実行時エラー + */ +export class RuntimeError extends AMFlowError { + name: string = "RuntimeError"; +} + +/** + * トークンが失効した + */ +export class TokenRevokedError extends AMFlowError { + name: string = "TokenRevoked"; +} + +export function createError(type: AMFlowErrorName, message?: any): Error { + if (type === "invalid_status") { + return new InvalidStatusError(message); + } else if (type === "permission_error") { + return new PermissionError(message); + } else if (type === "not_implemented") { + return new NotImplementedError(message); + } else if (type === "timeout") { + return new TimeoutError(message); + } else if (type === "bad_reequest") { + return new BadRequestError(message); + } else if (type === "token_revoked") { + return new TokenRevokedError(message); + } + return new RuntimeError(message); +}