esolang/languages/execution-controller.ts

235 lines
6.8 KiB
TypeScript

import { DocumentRange, LanguageEngine, StepExecutionResult } from "./types";
import {
isParseError,
isRuntimeError,
serializeParseError,
serializeRuntimeError,
WorkerRuntimeError,
} from "./worker-errors";
const FORCE_SLEEP_INTERVAL_MS = 10;
type ExecuteAllArgs<RS> = {
/** Interval between two execution steps, in milliseconds */
interval: number;
/**
* Pass to run in streaming-response mode.
* Callback is called with exeuction result on every execution step.
*/
onResult: (result: StepExecutionResult<RS>) => void;
};
class ExecutionController<RS> {
private _engine: LanguageEngine<RS>;
private _breakpoints: number[] = [];
private _result: StepExecutionResult<RS> | null;
private _resolvePause: (() => void) | null = null;
private _isPaused: boolean = false;
/**
* Create a new ExecutionController.
* @param engine Language engine to use for execution
*/
constructor(engine: LanguageEngine<RS>) {
this._engine = engine;
this._engine.resetState();
this._result = null;
}
/**
* Reset execution state in controller and engine.
* Clears out state from the current execution cycle.
*/
resetState() {
this._engine.resetState();
this._result = null;
}
/**
* Load code and user input into the engine to prepare for execution.
* @param code Code content, lines separated by `\n`
* @param input User input, lines separated by '\n'
*/
prepare(code: string, input: string) {
this._engine.prepare(code, input);
}
/**
* Update debugging breakpoints
* @param points Array of line numbers having breakpoints
*/
updateBreakpoints(points: number[]) {
this._breakpoints = points;
}
/**
* Validate the syntax of the given code
* @param code Code content, lines separated by '\n'
*/
validateCode(code: string) {
try {
this._engine.validateCode(code);
} catch (error) {
if (isParseError(error)) return serializeParseError(error);
else throw error;
}
}
/**
* Pause the ongoing execution.
* - If already paused, returns immediately
* - Queues up with any existing pause calls
* @returns Promise that resolves when execution is paused
*/
async pauseExecution(): Promise<void> {
// If already paused, return immediately
if (this._isPaused) return;
// If there's another "pause" call waiting, chain up with older resolver.
// This kinda creates a linked list of resolvers, with latest resolver at head.
if (this._resolvePause) {
console.log("Chaining pause calls");
return new Promise((resolve) => {
// Keep a reference to the existing resolver
const oldResolve = this._resolvePause;
// Replace resolver with new chained resolver
this._resolvePause = () => {
oldResolve && oldResolve();
resolve();
};
});
}
// Else, create a callback to be called by the execution loop
// when it finishes current execution step.
return new Promise(
(resolve) =>
(this._resolvePause = () => {
this._resolvePause = null;
this._isPaused = true;
resolve();
})
);
}
/**
* Run a single step of execution
* @returns Result of execution
*/
executeStep(): {
result: StepExecutionResult<RS>;
error?: WorkerRuntimeError;
} {
try {
this._result = this._engine.executeStep();
this._result.signal = "paused";
return { result: this._result };
} catch (error) {
if (isRuntimeError(error))
return { result: this._result!, error: serializeRuntimeError(error) };
else throw error;
}
}
/**
* Execute the loaded program until stopped. Throws if an error other than RuntimeError is encountered.
* @param param0.interval Interval between two execution steps
* @param param0.onResult Callback called with result on each execution step
* @returns Promise that resolves with result of last execution step and RuntimeError if any
*/
executeAll({ interval, onResult }: ExecuteAllArgs<RS>): Promise<{
result: StepExecutionResult<RS>;
error?: WorkerRuntimeError;
}> {
// Clear paused state
this._isPaused = false;
return new Promise(async (resolve, reject) => {
let lastSleepTime = performance.now();
while (true) {
try {
const doBreak = this.runExecLoopIteration();
onResult(this._result!);
if (doBreak) break;
/**
* The event loop causes sleep to add quite a bit of overhead. But sleep is
* required for allowing other message handlers to be run during execution.
* So for interval=0, as a middle ground, we sleep after every X ms.
*/
const shouldSleep =
interval > 0 ||
performance.now() - lastSleepTime > FORCE_SLEEP_INTERVAL_MS;
if (shouldSleep) {
await this.sleep(interval);
lastSleepTime = performance.now();
}
} catch (error) {
if (isRuntimeError(error)) {
this._isPaused = true;
resolve({
result: { ...this._result!, output: undefined },
error: serializeRuntimeError(error),
});
} else reject(error);
break;
}
}
resolve({ result: this._result! });
});
}
/**
* Runs a single iteration of execution loop, and sets
* `this._result` to the execution result.
* @returns Boolean - if true, break execution loop.
*/
private runExecLoopIteration(): boolean {
// Run an execution step in the engine
this._result = this._engine.executeStep();
// Check end of program
if (!this._result.nextStepLocation) {
this._resolvePause && this._resolvePause(); // In case pause happens on same cycle
return true;
}
// Check if execution has been paused
if (this._resolvePause) {
this._result.signal = "paused";
this._resolvePause && this._resolvePause();
return true;
}
// Check if next line has breakpoint
if (this.checkBreakpoint(this._result.nextStepLocation!)) {
this._result.signal = "paused";
return true;
}
return false;
}
/** Sleep for `ms` milliseconds */
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Check if the given DocumentRange has a breakpoint */
private checkBreakpoint(location: DocumentRange): boolean {
if (location.endLine == null) {
// Single line - just check if line is breakpoint
return this._breakpoints.includes(location.startLine);
} else {
// Multiline - check if any line is breakpoint
for (let line = location.startLine; line <= location.endLine; line++)
if (this._breakpoints.includes(line)) return true;
return false;
}
}
}
export default ExecutionController;