235 lines
6.8 KiB
TypeScript
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;
|