Skip to content

Commit

Permalink
feat: 一時停止機能 (aiscript-dev#837)
Browse files Browse the repository at this point in the history
* pause

* Async and tests

* api / lint

* Update test/interpreter.ts

Co-authored-by: Take-John <[email protected]>

* test fix

* Apply suggestions from code review

Co-authored-by: salano_ym <[email protected]>
Co-authored-by: uzmoi <[email protected]>

---------

Co-authored-by: Take-John <[email protected]>
Co-authored-by: salano_ym <[email protected]>
Co-authored-by: uzmoi <[email protected]>
  • Loading branch information
4 people authored Nov 10, 2024
1 parent 2a8abf6 commit 95f5f1f
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 18 deletions.
18 changes: 17 additions & 1 deletion etc/aiscript.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion playground/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -95,6 +100,7 @@ const run = async () => {
logs.value = [];
interpreter?.abort();
paused.value = false;
interpreter = new Interpreter({}, {
in: (q) => {
return new Promise(ok => {
Expand Down
47 changes: 47 additions & 0 deletions src/interpreter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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(); });

Check warning on line 810 in src/interpreter/index.ts

View workflow job for this annotation

GitHub Actions / lint

Missing return type on function
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) {
Expand Down
52 changes: 36 additions & 16 deletions src/interpreter/lib/std.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,41 +638,61 @@ 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);
});
}),

'Async:timeout': FN_NATIVE(async ([delay, callback], opts) => {
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
Expand Down
4 changes: 4 additions & 0 deletions src/interpreter/value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
70 changes: 70 additions & 0 deletions test/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
3 changes: 3 additions & 0 deletions unreleased/pause.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- For Hosts: `interpreter.pause()`で実行の一時停止ができるように
- `interpreter.unpause()`で再開
- 再開後に`Async:`系の待ち時間がリセットされる不具合がありますが、修正の目処は立っていません

0 comments on commit 95f5f1f

Please sign in to comment.