Repository: aiscript-dev/aiscript
Pull request #837 (2024-10-31 05:32:41)

feat: 一時停止機能
<!-- ℹ お読みください
PRありがとうございます！ PRを作成する前に、以下をご確認ください:
- 可能であればタイトルに、以下で示すようなPRの種類が分かるキーワードをプリフィクスしてください。
  - fix / refactor / feat / enhance / perf / chore
  - また、PRの粒度が適切であることを確認してください。ひとつのPRに複数の種類の変更や関心を含めることは避けてください。
- このPRによって解決されるIssueがある場合は、そのIssueへの参照を本文内に含めてください。
- unreleasedディレクトリ以下にファイルを新規に作成し、変更点を記してください。リファクタリングなど、利用者に影響を与えない変更についてはこの限りではありません。
- この変更により新たに作成、もしくは更新すべきドキュメントがないか確認してください。
- 機能追加やバグ修正をした場合は、可能であればテストケースを追加してください。
- テスト、Lintが通っていることを予め確認してください。
  - `npm run test`、`npm run lint`でぞれぞれ実施可能です
- `npm run api`を実行してAPIレポートを更新し、差分がある場合はコミットしてください。
ご協力ありがとうございます🤗
-->
<!-- ℹ README
Thank you for your PR! Before creating a PR, please check the following:
- If possible, prefix the title with a keyword that identifies the type of this PR, as shown below.
  - fix / refactor / feat / enhance / perf / chore
  - Also, make sure that the granularity of this PR is appropriate. Please do not include more than one type of change or interest in a single PR.
- If there is an Issue which will be resolved by this PR, please include a reference to the Issue in the text.
- Please create a new file under the `unreleased` directory and write down the summary of your changes. However, this is not necessary for changes that do not affect the users, such as refactoring.
- Check if there are any documents that need to be created or updated due to this change.
- If you have added a feature or fixed a bug, please add a test case if possible.
- Please make sure that tests and Lint are passed in advance.
  - You can run it with `npm run test` and `npm run lint`.
- Run `npm run api` to update the API report and commit it if there are any diffs.
Thanks for your cooperation 🤗
-->

# What
<!-- このPRで何をしたのか？ どう変わるのか？ -->
<!-- What did you do with this PR? How will it change things? -->
`interpreter.pause()`で実行の一時停止ができるようにします。
`interpreter.unpause()`で再開します。
Playgroundへのボタンの追加も行っています。

# Additional info (optional)
<!-- テスト観点など -->
<!-- Test perspective, etc -->
再開後に`Async:`系の待ち時間がリセットされる不具合がありますが、修正の目処は立っていません

codecov-commenter: :warning: Please install the !['codecov app svg image'](https://github.com/codecov/engineering-team/assets/152432831/e90313f4-9d3a-4b63-8b54-cfe14e7ec20d) to ensure uploads and comments are reliably processed by Codecov.

## [Codecov](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/837?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
Attention: Patch coverage is `76.62338%` with `18 lines` in your changes missing coverage. Please review.

| [Files with missing lines](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/837?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Patch % | Lines |
|---|---|---|
| [src/interpreter/lib/std.ts](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/837?src=pr&el=tree&filepath=src%2Finterpreter%2Flib%2Fstd.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2ludGVycHJldGVyL2xpYi9zdGQudHM=) | 43.75% | [18 Missing :warning: ](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/837?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) |

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

| [Files with missing lines](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/837?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [src/interpreter/index.ts](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/837?src=pr&el=tree&filepath=src%2Finterpreter%2Findex.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2ludGVycHJldGVyL2luZGV4LnRz) | `95.03% <100.00%> (+9.22%)` | :arrow_up: |
| [src/interpreter/value.ts](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/837?src=pr&el=tree&filepath=src%2Finterpreter%2Fvalue.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2ludGVycHJldGVyL3ZhbHVlLnRz) | `100.00% <ø> (ø)` | |
| [src/interpreter/lib/std.ts](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/837?src=pr&el=tree&filepath=src%2Finterpreter%2Flib%2Fstd.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2ludGVycHJldGVyL2xpYi9zdGQudHM=) | `77.10% <43.75%> (-2.14%)` | :arrow_down: |

... and [27 files with indirect coverage changes](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/837/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

takejohn: 用語としてpause/unpauseよりsuspend/resumeが自然な気がするんですけどどうでしょう？
FineArchs: 個人的には「リソース節約状態になる一時停止」がsuspend、「いつでも再開できる一時停止」がpauseというイメージがあります。
あと、pauseとunpauseはパッと見ただけで対応関係にあることが分かるという嬉しさがあると思います。

Patch:
diff --git a/etc/aiscript.api.md b/etc/aiscript.api.md
index f997acf..70e7f95 100644
--- a/etc/aiscript.api.md
+++ b/etc/aiscript.api.md
@@ -411,13 +411,25 @@ export class Interpreter {
     execFn(fn: VFn, args: Value[]): Promise<Value>;
     execFnSimple(fn: VFn, args: Value[]): Promise<Value>;
     // (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<Value>;
         topCall: (fn: VFn, args: Value[]) => Promise<Value>;
         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<Value> | 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 9fb1351..4c8df33 100644
--- a/playground/src/App.vue
+++ b/playground/src/App.vue
@@ -14,7 +14,11 @@
 			</footer>
 		</div>
 		<div id="logs" class="container">
-			<header>Output</header>
+			<header>
+				Output
+				<div v-if="paused" class="actions"><button @click="interpreter.unpause(), paused = false">Unpause</button></div>
+				<div v-else class="actions"><button @click="interpreter.pause(), paused = true">Pause</button></div>
+			</header>
 			<div>
 				<div v-for="log in logs" class="log" :key="log.id" :class="[{ print: log.print }, log.type]"><span class="type">{{ log.type }}</span> {{ log.text }}</div>
 			</div>
@@ -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 cc8b433..86ee989 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<void>, resolve: () => void } | null = null;
 	public scope: Scope;
 	private abortHandlers: (() => void)[] = [];
+	private pauseHandlers: (() => void)[] = [];
+	private unpauseHandlers: (() => void)[] = [];
 	private vars: Record<string, Variable> = {};
 	private irqRate: number;
 	private irqSleep: () => Promise<void>;
@@ -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<Value> {
 		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<void>(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<void> {
 		switch (dest.type) {
diff --git a/src/interpreter/lib/std.ts b/src/interpreter/lib/std.ts
index e4aa657..178bc7f 100644
--- a/src/interpreter/lib/std.ts
+++ b/src/interpreter/lib/std.ts
@@ -638,20 +638,29 @@ export const std: Record<string, Value> = {
 			if (immediate.value) opts.call(callback, []);
 		}
 
-		const id = setInterval(() => {
-			opts.topCall(callback, []);
-		}, interval.value);
-
-		const abortHandler = (): void => {
+		let id: ReturnType<typeof setInterval>;
+
+		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<string, Value> = {
 		assertNumber(delay);
 		assertFunction(callback);
 
-		const id = setTimeout(() => {
-			opts.topCall(callback, []);
-		}, delay.value);
-
-		const abortHandler = (): void => {
+		let id: ReturnType<typeof setInterval>;
+
+		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 6e03882..8fc5d4a 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<Value>;
 		topCall: (fn: VFn, args: Value[]) => Promise<Value>;
 		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<Value> | void;
 };
 
diff --git a/unreleased/pause.md b/unreleased/pause.md
new file mode 100644
index 0000000..9693e2c
--- /dev/null
+++ b/unreleased/pause.md
@@ -0,0 +1,3 @@
+- For Hosts: `interpreter.pause()`で実行の一時停止ができるように
+  - `interpreter.unpause()`で再開
+  - 再開後に`Async:`系の待ち時間がリセットされる不具合がありますが、修正の目処は立っていません


Test patch:
diff --git a/test/interpreter.ts b/test/interpreter.ts
index 09cb545..bcf9e64 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);
+		});
+	});
+});