diff --git a/etc/aiscript.api.md b/etc/aiscript.api.md index f997acf3..70e7f955 100644 --- a/etc/aiscript.api.md +++ b/etc/aiscript.api.md @@ -411,13 +411,25 @@ export class Interpreter { execFn(fn: VFn, args: Value[]): Promise; execFnSimple(fn: VFn, args: Value[]): Promise; // (undocumented) + pause(): void; + // (undocumented) registerAbortHandler(handler: () => void): void; // (undocumented) + registerPauseHandler(handler: () => void): void; + // (undocumented) + registerUnpauseHandler(handler: () => void): void; + // (undocumented) scope: Scope; // (undocumented) stepCount: number; // (undocumented) + unpause(): void; + // (undocumented) unregisterAbortHandler(handler: () => void): void; + // (undocumented) + unregisterPauseHandler(handler: () => void): void; + // (undocumented) + unregisterUnpauseHandler(handler: () => void): void; } // @public (undocumented) @@ -820,7 +832,11 @@ type VNativeFn = VFnBase & { call: (fn: VFn, args: Value[]) => Promise; topCall: (fn: VFn, args: Value[]) => Promise; registerAbortHandler: (handler: () => void) => void; + registerPauseHandler: (handler: () => void) => void; + registerUnpauseHandler: (handler: () => void) => void; unregisterAbortHandler: (handler: () => void) => void; + unregisterPauseHandler: (handler: () => void) => void; + unregisterUnpauseHandler: (handler: () => void) => void; }) => Value | Promise | void; }; @@ -866,7 +882,7 @@ type VUserFn = VFnBase & { // Warnings were encountered during analysis: // -// src/interpreter/index.ts:44:4 - (ae-forgotten-export) The symbol "LogObject" needs to be exported by the entry point index.d.ts +// src/interpreter/index.ts:47:4 - (ae-forgotten-export) The symbol "LogObject" needs to be exported by the entry point index.d.ts // src/interpreter/value.ts:47:2 - (ae-forgotten-export) The symbol "Type" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/playground/src/App.vue b/playground/src/App.vue index 9fb1351a..4c8df33c 100644 --- a/playground/src/App.vue +++ b/playground/src/App.vue @@ -14,7 +14,11 @@
-
Output
+
+ Output +
+
+
{{ log.type }} {{ log.text }}
@@ -66,6 +70,7 @@ const ast = ref(null); const logs = ref([]); const syntaxErrorMessage = ref(null); const showSettings = ref(false); +const paused = ref(false); watch(script, () => { window.localStorage.setItem('script', script.value); @@ -95,6 +100,7 @@ const run = async () => { logs.value = []; interpreter?.abort(); + paused.value = false; interpreter = new Interpreter({}, { in: (q) => { return new Promise(ok => { diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts index cc8b433b..86ee989e 100644 --- a/src/interpreter/index.ts +++ b/src/interpreter/index.ts @@ -29,8 +29,11 @@ type CallInfo = { export class Interpreter { public stepCount = 0; private stop = false; + private pausing: { promise: Promise, resolve: () => void } | null = null; public scope: Scope; private abortHandlers: (() => void)[] = []; + private pauseHandlers: (() => void)[] = []; + private unpauseHandlers: (() => void)[] = []; private vars: Record = {}; private irqRate: number; private irqSleep: () => Promise; @@ -265,7 +268,11 @@ export class Interpreter { call: (fn, args) => this._fn(fn, args, [...callStack, info]), topCall: this.execFn, registerAbortHandler: this.registerAbortHandler, + registerPauseHandler: this.registerPauseHandler, + registerUnpauseHandler: this.registerUnpauseHandler, unregisterAbortHandler: this.unregisterAbortHandler, + unregisterPauseHandler: this.unregisterPauseHandler, + unregisterUnpauseHandler: this.unregisterUnpauseHandler, }); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return result ?? NULL; @@ -311,6 +318,7 @@ export class Interpreter { @autobind private async __eval(node: Ast.Node, scope: Scope, callStack: readonly CallInfo[]): Promise { if (this.stop) return NULL; + if (this.pausing) await this.pausing.promise; // irqRateが小数の場合は不等間隔になる if (this.irqRate !== 0 && this.stepCount % this.irqRate >= this.irqRate - 1) { await this.irqSleep(); @@ -764,11 +772,27 @@ export class Interpreter { public registerAbortHandler(handler: () => void): void { this.abortHandlers.push(handler); } + @autobind + public registerPauseHandler(handler: () => void): void { + this.pauseHandlers.push(handler); + } + @autobind + public registerUnpauseHandler(handler: () => void): void { + this.unpauseHandlers.push(handler); + } @autobind public unregisterAbortHandler(handler: () => void): void { this.abortHandlers = this.abortHandlers.filter(h => h !== handler); } + @autobind + public unregisterPauseHandler(handler: () => void): void { + this.pauseHandlers = this.pauseHandlers.filter(h => h !== handler); + } + @autobind + public unregisterUnpauseHandler(handler: () => void): void { + this.unpauseHandlers = this.unpauseHandlers.filter(h => h !== handler); + } @autobind public abort(): void { @@ -779,6 +803,29 @@ export class Interpreter { this.abortHandlers = []; } + @autobind + public pause(): void { + if (this.pausing) return; + let resolve: () => void; + const promise = new Promise(r => { resolve = () => r(); }); + this.pausing = { promise, resolve: resolve! }; + for (const handler of this.pauseHandlers) { + handler(); + } + this.pauseHandlers = []; + } + + @autobind + public unpause(): void { + if (!this.pausing) return; + this.pausing.resolve(); + this.pausing = null; + for (const handler of this.unpauseHandlers) { + handler(); + } + this.unpauseHandlers = []; + } + @autobind private async define(scope: Scope, dest: Ast.Expression, value: Value, isMutable: boolean): Promise { switch (dest.type) { diff --git a/src/interpreter/lib/std.ts b/src/interpreter/lib/std.ts index e4aa6574..178bc7fb 100644 --- a/src/interpreter/lib/std.ts +++ b/src/interpreter/lib/std.ts @@ -638,20 +638,29 @@ export const std: Record = { if (immediate.value) opts.call(callback, []); } - const id = setInterval(() => { - opts.topCall(callback, []); - }, interval.value); - - const abortHandler = (): void => { + let id: ReturnType; + + const start = (): void => { + id = setInterval(() => { + opts.topCall(callback, []); + }, interval.value); + opts.registerAbortHandler(stop); + opts.registerPauseHandler(stop); + opts.unregisterUnpauseHandler(start); + }; + const stop = (): void => { clearInterval(id); + opts.unregisterAbortHandler(stop); + opts.unregisterPauseHandler(stop); + opts.registerUnpauseHandler(start); }; - opts.registerAbortHandler(abortHandler); + start(); // stopper return FN_NATIVE(([], opts) => { - clearInterval(id); - opts.unregisterAbortHandler(abortHandler); + stop(); + opts.unregisterUnpauseHandler(start); }); }), @@ -659,20 +668,31 @@ export const std: Record = { assertNumber(delay); assertFunction(callback); - const id = setTimeout(() => { - opts.topCall(callback, []); - }, delay.value); - - const abortHandler = (): void => { + let id: ReturnType; + + const start = (): void => { + id = setTimeout(() => { + opts.topCall(callback, []); + opts.unregisterAbortHandler(stop); + opts.unregisterPauseHandler(stop); + }, delay.value); + opts.registerAbortHandler(stop); + opts.registerPauseHandler(stop); + opts.unregisterUnpauseHandler(start); + }; + const stop = (): void => { clearTimeout(id); + opts.unregisterAbortHandler(stop); + opts.unregisterPauseHandler(stop); + opts.registerUnpauseHandler(start); }; - opts.registerAbortHandler(abortHandler); + start(); // stopper return FN_NATIVE(([], opts) => { - clearTimeout(id); - opts.unregisterAbortHandler(abortHandler); + stop(); + opts.unregisterUnpauseHandler(start); }); }), //#endregion diff --git a/src/interpreter/value.ts b/src/interpreter/value.ts index 6e038822..8fc5d4ab 100644 --- a/src/interpreter/value.ts +++ b/src/interpreter/value.ts @@ -55,7 +55,11 @@ export type VNativeFn = VFnBase & { call: (fn: VFn, args: Value[]) => Promise; topCall: (fn: VFn, args: Value[]) => Promise; registerAbortHandler: (handler: () => void) => void; + registerPauseHandler: (handler: () => void) => void; + registerUnpauseHandler: (handler: () => void) => void; unregisterAbortHandler: (handler: () => void) => void; + unregisterPauseHandler: (handler: () => void) => void; + unregisterUnpauseHandler: (handler: () => void) => void; }) => Value | Promise | void; }; diff --git a/test/interpreter.ts b/test/interpreter.ts index 09cb5452..bcf9e64d 100644 --- a/test/interpreter.ts +++ b/test/interpreter.ts @@ -254,3 +254,73 @@ describe('IRQ', () => { }); }); }); + +describe('pause', () => { + async function exePausable() { + let count = 0; + + const interpreter = new Interpreter({ + count: values.FN_NATIVE(() => { count++; }), + }, {}); + + // await to catch errors + await interpreter.exec(Parser.parse( + `Async:interval(100, @() { count() })` + )); + + return { + pause: interpreter.pause, + unpause: interpreter.unpause, + getCount: () => count, + resetCount: () => count = 0, + }; + } + + beforeEach(() => { + vi.useFakeTimers(); + }) + + afterEach(() => { + vi.restoreAllMocks(); + }) + + test('basic', async () => { + const p = await exePausable(); + await vi.advanceTimersByTimeAsync(500); + p.pause(); + await vi.advanceTimersByTimeAsync(400); + return expect(p.getCount()).toEqual(5); + }); + + test('unpause', async () => { + const p = await exePausable(); + await vi.advanceTimersByTimeAsync(500); + p.pause(); + await vi.advanceTimersByTimeAsync(400); + p.unpause(); + await vi.advanceTimersByTimeAsync(300); + return expect(p.getCount()).toEqual(8); + }); + + describe('randomly scheduled pausing', () => { + function rnd(min: number, max: number): number { + return Math.floor(min + (Math.random() * (max - min + 1))); + } + const schedule = Array(rnd(2, 10)).fill(0).map(() => rnd(1, 10) * 100); + const title = schedule.map((v, i) => `${i % 2 ? 'un' : ''}pause ${v}`).join(', '); + + test(title, async () => { + const p = await exePausable(); + let answer = 0; + for (const [i, v] of schedule.entries()) { + if (i % 2) { + p.unpause(); + answer += v / 100; + } + else p.pause(); + await vi.advanceTimersByTimeAsync(v); + } + return expect(p.getCount()).toEqual(answer); + }); + }); +}); diff --git a/unreleased/pause.md b/unreleased/pause.md new file mode 100644 index 00000000..9693e2c8 --- /dev/null +++ b/unreleased/pause.md @@ -0,0 +1,3 @@ +- For Hosts: `interpreter.pause()`で実行の一時停止ができるように + - `interpreter.unpause()`で再開 + - 再開後に`Async:`系の待ち時間がリセットされる不具合がありますが、修正の目処は立っていません