Skip to content

Commit

Permalink
IRQをカスタマイズ可能に (#817)
Browse files Browse the repository at this point in the history
* irq configuration

* changelog

* type fix

* test

* IRQ rate value restriction

* fix and add test

* test wip

* fix test

* api report

* add comment

* unuse concurrent for time-related tests
  • Loading branch information
FineArchs authored Oct 24, 2024
1 parent af58f56 commit 8de1e5a
Show file tree
Hide file tree
Showing 7 changed files with 215 additions and 11 deletions.
14 changes: 12 additions & 2 deletions etc/aiscript.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -281,7 +288,8 @@ declare namespace errors {
AiScriptNamespaceError,
AiScriptRuntimeError,
AiScriptIndexOutOfRangeError,
AiScriptUserError
AiScriptUserError,
AiScriptHostsideError
}
}
export { errors }
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions playground/src/App.vue
Original file line number Diff line number Diff line change
@@ -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>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -119,7 +122,9 @@ const run = async () => {
}); break;
default: break;
}
}
},
irqRate: settings.value.irqRate,
irqSleep: settings.value.irqSleep,
});
try {
Expand Down Expand Up @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions playground/src/Settings.vue
Original file line number Diff line number Diff line change
@@ -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>
9 changes: 9 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
33 changes: 28 additions & 5 deletions src/interpreter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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>,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
94 changes: 92 additions & 2 deletions test/interpreter.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
});
3 changes: 3 additions & 0 deletions unreleased/irq-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- For Hosts: Interpreterのオプションに`irqRate``irqSleep`を追加
- `irqRate`はInterpreterの定期休止が何ステップに一回起こるかを指定する数値
- `irqSleep`は休止時間をミリ秒で指定する数値、または休止ごとにawaitされるPromiseを返す関数

0 comments on commit 8de1e5a

Please sign in to comment.