Repository: aiscript-dev/aiscript
Pull request #817 (2024-10-21 04:53:33)

IRQをカスタマイズ可能に
<!-- ℹ お読みください
PRありがとうございます！ PRを作成する前に、以下をご確認ください:
- 可能であればタイトルに、以下で示すようなPRの種類が分かるキーワードをプリフィクスしてください。
  - fix / refactor / feat / enhance / perf / chore
  - また、PRの粒度が適切であることを確認してください。ひとつのPRに複数の種類の変更や関心を含めることは避けてください。
- このPRによって解決されるIssueがある場合は、そのIssueへの参照を本文内に含めてください。
- CHANGELOG.mdに変更点を追記してください。リファクタリングなど、利用者に影響を与えない変更についてはこの限りではありません。
- この変更により新たに作成、もしくは更新すべきドキュメントがないか確認してください。
- 機能追加やバグ修正をした場合は、可能であればテストケースを追加してください。
- テスト、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 add the summary of the changes to CHANGELOG.md. 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のオプションに`irqRate`と`irqSleep`の項目を追加します。
  - `irqRate`はInterpreterの定期休止が何ステップに一回起こるかを指定する数値
  - `irqSleep`は休止時間をミリ秒で指定する数値、または休止ごとにawaitされるPromiseを返す関数

また、Playgroundにこれらの項目を設定できるUIを追加しています。
# Why
<!-- なぜそうするのか？ どういう意図なのか？ 何が困っているのか？ -->
<!-- Why do you do it? What are your intentions? What is the problem? -->
#665
# Additional info (optional)
<!-- テスト観点など -->
<!-- Test perspective, etc -->


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/817?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 `90.90909%` with `1 line` in your changes missing coverage. Please review.

| [Files with missing lines](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/817?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/index.ts](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/817?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) | 90.90% | [1 Missing :warning: ](https://app.codecov.io/gh/aiscript-dev/aiscript/pull/817?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/817?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/817?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) | `93.93% <90.90%> (+8.13%)` | :arrow_up: |

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

FineArchs: とりあえずvercelでデプロイしました
https://aiscript-git-irq-config-finearchs-projects.vercel.app/

Patch:
diff --git a/docs/std-math.md b/docs/std-math.md
index ffa2f84..987462f 100644
--- a/docs/std-math.md
+++ b/docs/std-math.md
@@ -95,7 +95,7 @@ _y_ /_x_ の正接を返しますが、_x_ が負値の場合はπだけずれ
 
 ## 指数・対数関数
 ### @Math:pow(_x_: num, _y_: num): num
-_x_ の _y_ 乗を計算します。結果がNaNとなることを許容する点、内部的にJavascriptの`**`演算子ではなく`Math.pow`関数を用いている点の２つを除き、ほぼ`Core:pow`と同じものです。
+_x_ の _y_ 乗を計算します。内部的にJavascriptの`**`演算子ではなく`Math.pow`関数を用いている点を除き、ほぼ`Core:pow`と同じものです。
 
 ### @Math:exp(_x_: num): num
 eの _x_ 乗を計算します。  
diff --git a/etc/aiscript.api.md b/etc/aiscript.api.md
index 34eb921..ad73857 100644
--- a/etc/aiscript.api.md
+++ b/etc/aiscript.api.md
@@ -34,6 +34,13 @@ abstract class AiScriptError extends Error {
     pos?: Pos;
 }
 
+// @public
+class AiScriptHostsideError extends AiScriptError {
+    constructor(message: string, info?: unknown);
+    // (undocumented)
+    name: string;
+}
+
 // @public
 class AiScriptIndexOutOfRangeError extends AiScriptRuntimeError {
     constructor(message: string, info?: unknown);
@@ -281,7 +288,8 @@ declare namespace errors {
         AiScriptNamespaceError,
         AiScriptRuntimeError,
         AiScriptIndexOutOfRangeError,
-        AiScriptUserError
+        AiScriptUserError,
+        AiScriptHostsideError
     }
 }
 export { errors }
@@ -391,6 +399,8 @@ export class Interpreter {
         log?(type: string, params: LogObject): void;
         maxStep?: number;
         abortOnError?: boolean;
+        irqRate?: number;
+        irqSleep?: number | (() => Promise<void>);
     });
     // (undocumented)
     abort(): void;
@@ -854,7 +864,7 @@ type VUserFn = VFnBase & {
 
 // Warnings were encountered during analysis:
 //
-// src/interpreter/index.ts:39:4 - (ae-forgotten-export) The symbol "LogObject" needs to be exported by the entry point index.d.ts
+// src/interpreter/index.ts:38:4 - (ae-forgotten-export) The symbol "LogObject" needs to be exported by the entry point index.d.ts
 // src/interpreter/value.ts:46: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 8a870b1..9fb1351 100644
--- a/playground/src/App.vue
+++ b/playground/src/App.vue
@@ -1,6 +1,7 @@
 <template>
 <div id="root">
-	<h1>AiScript (v{{ AISCRIPT_VERSION }}) Playground</h1>
+	<Settings v-if="showSettings" @exit="showSettings = false" />
+	<h1>AiScript (v{{ AISCRIPT_VERSION }}) Playground<button id="show-settings-button" @click="showSettings = true">Settings</button></h1>
 	<div id="grid1">
 		<div id="editor" class="container">
 			<header>Input<div class="actions"><button @click="setCode">FizzBuzz</button></div></header>
@@ -57,12 +58,14 @@ import { highlight, languages } from 'prismjs/components/prism-core';
 import 'prismjs/components/prism-clike';
 import 'prismjs/components/prism-javascript';
 import 'prismjs/themes/prism-okaidia.css';
+import Settings, { settings } from './Settings.vue';
 
 const script = ref(window.localStorage.getItem('script') || '<: "Hello, AiScript!"');
 
 const ast = ref(null);
 const logs = ref([]);
 const syntaxErrorMessage = ref(null);
+const showSettings = ref(false);
 
 watch(script, () => {
 	window.localStorage.setItem('script', script.value);
@@ -119,7 +122,9 @@ const run = async () => {
 				}); break;
 				default: break;
 			}
-		}
+		},
+		irqRate: settings.value.irqRate,
+		irqSleep: settings.value.irqSleep,
 	});
 
 	try {
@@ -167,6 +172,11 @@ pre {
 #root > h1 {
 	font-size: 1.5em;
 	margin: 16px 16px 0 16px;
+	display: flex;
+}
+
+#show-settings-button {
+	margin-left: auto;
 }
 
 #grid1 {
diff --git a/playground/src/Settings.vue b/playground/src/Settings.vue
new file mode 100644
index 0000000..28f7f60
--- /dev/null
+++ b/playground/src/Settings.vue
@@ -0,0 +1,59 @@
+<template>
+<div ref="bg" id="settings-bg" @click="(e) => e.target === bg && $emit('exit')">
+	<div id="settings-body">
+		<div class="settings-item">
+			<header>IRQ Rate</header>
+			<input type="number" v-model="settings.irqRate" />
+		</div>
+		<div class="settings-item">
+			<header>IRQ Sleep Time</header>
+			<div>
+				<input type="radio" value="time" v-model="irqSleepMethod" />
+				time(milliseconds)
+				<input type="number" v-model="irqSleepTime" :disabled="irqSleepMethod !== 'time'" />
+			</div>
+			<div>
+				<input type="radio" value="requestIdleCallback" v-model="irqSleepMethod" />
+				requestIdleCallback
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script>
+import { ref, computed } from 'vue';
+const irqSleepTime = ref(5);
+const irqSleepMethod = ref('time');
+export const settings = ref({
+	irqRate: 300,
+	irqSleep: computed(() => ({
+		time: irqSleepTime.value,
+		requestIdleCallback: () => new Promise(cb => requestIdleCallback(cb)),
+	})[irqSleepMethod.value]),
+});
+</script>
+
+<script setup>
+const emits = defineEmits(['exit']);
+const bg = ref(null);
+</script>
+
+<style>
+#settings-bg {
+	position: fixed;
+	top: 0;
+	left: 0;
+	z-index: 10;
+	width: 100vw;
+	height: 100vh;
+	background: #0008;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+}
+#settings-body {
+	display: grid;
+	gap: 1em;
+}
+</style>
diff --git a/src/error.ts b/src/error.ts
index ba01d0b..e62c9b6 100644
--- a/src/error.ts
+++ b/src/error.ts
@@ -86,3 +86,12 @@ export class AiScriptUserError extends AiScriptRuntimeError {
 		super(message, info);
 	}
 }
+/**
+ * Host side configuration errors.
+ */
+export class AiScriptHostsideError extends AiScriptError {
+	public name = 'Host';
+	constructor(message: string, info?: unknown) {
+		super(message, info);
+	}
+}
diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts
index 9f8c772..a9517de 100644
--- a/src/interpreter/index.ts
+++ b/src/interpreter/index.ts
@@ -3,7 +3,7 @@
  */
 
 import { autobind } from '../utils/mini-autobind.js';
-import { AiScriptError, NonAiScriptError, AiScriptNamespaceError, AiScriptIndexOutOfRangeError, AiScriptRuntimeError } from '../error.js';
+import { AiScriptError, NonAiScriptError, AiScriptNamespaceError, AiScriptIndexOutOfRangeError, AiScriptRuntimeError, AiScriptHostsideError } from '../error.js';
 import { Scope } from './scope.js';
 import { std } from './lib/std.js';
 import { assertNumber, assertString, assertFunction, assertBoolean, assertObject, assertArray, eq, isObject, isArray, expectAny, reprValue } from './util.js';
@@ -14,9 +14,6 @@ import type { JsValue } from './util.js';
 import type { Value, VFn } from './value.js';
 import type * as Ast from '../node.js';
 
-const IRQ_RATE = 300;
-const IRQ_AT = IRQ_RATE - 1;
-
 export type LogObject = {
 	scope?: string;
 	var?: string;
@@ -29,6 +26,8 @@ export class Interpreter {
 	public scope: Scope;
 	private abortHandlers: (() => void)[] = [];
 	private vars: Record<string, Variable> = {};
+	private irqRate: number;
+	private irqSleep: () => Promise<void>;
 
 	constructor(
 		consts: Record<string, Value>,
@@ -39,6 +38,8 @@ export class Interpreter {
 			log?(type: string, params: LogObject): void;
 			maxStep?: number;
 			abortOnError?: boolean;
+			irqRate?: number;
+			irqSleep?: number | (() => Promise<void>);
 		} = {},
 	) {
 		const io = {
@@ -70,6 +71,25 @@ export class Interpreter {
 				default: break;
 			}
 		};
+
+		if (!((this.opts.irqRate ?? 300) >= 0)) {
+			throw new AiScriptHostsideError(`Invalid IRQ rate (${this.opts.irqRate}): must be non-negative number`);
+		}
+		this.irqRate = this.opts.irqRate ?? 300;
+
+		const sleep = (time: number) => (
+			(): Promise<void> => new Promise(resolve => setTimeout(resolve, time))
+		);
+
+		if (typeof this.opts.irqSleep === 'function') {
+			this.irqSleep = this.opts.irqSleep;
+		} else if (this.opts.irqSleep === undefined) {
+			this.irqSleep = sleep(5);
+		} else if (this.opts.irqSleep >= 0) {
+			this.irqSleep = sleep(this.opts.irqSleep);
+		} else {
+			throw new AiScriptHostsideError('irqSleep must be a function or a positive number.');
+		}
 	}
 
 	@autobind
@@ -262,7 +282,10 @@ export class Interpreter {
 	@autobind
 	private async __eval(node: Ast.Node, scope: Scope): Promise<Value> {
 		if (this.stop) return NULL;
-		if (this.stepCount % IRQ_RATE === IRQ_AT) await new Promise(resolve => setTimeout(resolve, 5));
+		// irqRateが小数の場合は不等間隔になる
+		if (this.irqRate !== 0 && this.stepCount % this.irqRate >= this.irqRate - 1) {
+			await this.irqSleep();
+		}
 		this.stepCount++;
 		if (this.opts.maxStep && this.stepCount > this.opts.maxStep) {
 			throw new AiScriptRuntimeError('max step exceeded');
diff --git a/unreleased/irq-config.md b/unreleased/irq-config.md
new file mode 100644
index 0000000..5e3edf6
--- /dev/null
+++ b/unreleased/irq-config.md
@@ -0,0 +1,3 @@
+- For Hosts: Interpreterのオプションに`irqRate`と`irqSleep`を追加
+  - `irqRate`はInterpreterの定期休止が何ステップに一回起こるかを指定する数値
+  - `irqSleep`は休止時間をミリ秒で指定する数値、または休止ごとにawaitされるPromiseを返す関数


Test patch:
diff --git a/test/interpreter.ts b/test/interpreter.ts
index 70c4089..203b5a4 100644
--- a/test/interpreter.ts
+++ b/test/interpreter.ts
@@ -1,9 +1,9 @@
 import * as assert from 'assert';
-import { describe, expect, test } from 'vitest';
+import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
 import { Parser, Interpreter, values, errors, utils, Ast } from '../src';
 
 let { FN_NATIVE } = values;
-let { AiScriptRuntimeError, AiScriptIndexOutOfRangeError } = errors;
+let { AiScriptRuntimeError, AiScriptIndexOutOfRangeError, AiScriptHostsideError } = errors;
 
 describe('Scope', () => {
 	test.concurrent('getAll', async () => {
@@ -114,3 +114,93 @@ describe('error location', () => {
 		`)).resolves.toEqual({ line: 2, column: 6});
 	});
 });
+
+describe('IRQ', () => {
+	describe('irqSleep is function', () => {
+		async function countSleeps(irqRate: number): Promise<number> {
+			let count = 0;
+			const interpreter = new Interpreter({}, {
+				irqRate,
+				// It's safe only when no massive loop occurs
+				irqSleep: async () => count++,
+			});
+			await interpreter.exec(Parser.parse(`
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'`));
+			return count;
+		}
+
+		test.concurrent.each([
+			[0, 0],
+			[1, 10],
+			[2, 5],
+			[10, 1],
+			[Infinity, 0],
+		])('rate = %d', async (rate, count) => {
+			return expect(countSleeps(rate)).resolves.toEqual(count);
+		});
+
+		test.concurrent.each(
+			[-1, NaN],
+		)('rate = %d', async (rate, count) => {
+			return expect(countSleeps(rate)).rejects.toThrow(AiScriptHostsideError);
+		});
+	});
+
+	describe('irqSleep is number', () => {
+		// This function does IRQ 10 times so takes 10 * irqSleep milliseconds in sum when executed.
+		async function countSleeps(irqSleep: number): Promise<void> {
+			const interpreter = new Interpreter({}, {
+				irqRate: 1,
+				irqSleep,
+			});
+			await interpreter.exec(Parser.parse(`
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'
+			'Ai-chan kawaii'`));
+		}
+
+		beforeEach(() => {
+			vi.useFakeTimers();
+		})
+
+		afterEach(() => {
+			vi.restoreAllMocks();
+		})
+
+		test('It ends', async () => {
+			const countSleepsSpy = vi.fn(countSleeps);
+			countSleepsSpy(100);
+			await vi.advanceTimersByTimeAsync(1000);
+			return expect(countSleepsSpy).toHaveResolved();
+		});
+
+		test('It takes time', async () => {
+			const countSleepsSpy = vi.fn(countSleeps);
+			countSleepsSpy(100);
+			await vi.advanceTimersByTimeAsync(999);
+			return expect(countSleepsSpy).not.toHaveResolved();
+		});
+
+		test.each(
+			[-1, NaN]
+		)('Invalid number: %d', (time) => {
+			return expect(countSleeps(time)).rejects.toThrow(AiScriptHostsideError);
+		});
+	});
+});