Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions spx-gui/build-wasm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ cp ../tools/spxls/spxls-pkgdata.zip src/assets/wasm/spxls-pkgdata.zip
# Build and copy ispx.wasm
( cd ../tools/ispx && ./build.sh )
cp ../tools/ispx/ispx.wasm src/assets/wasm/ispx.wasm

# Build and copy the generic XGo executor.
( cd ../tools/xgoexec && bash build.sh )
cp ../tools/xgoexec/xgoexec.wasm src/assets/wasm/xgoexec.wasm
176 changes: 176 additions & 0 deletions spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref } from 'vue'
import {
createTutorialFramework,
dispatchTutorialSubmit,
XGoExecutor,
type TutorialProgress
} from '@/utils/xgo-executor'
import { UIButton, UICard, UITextInput, useMessage } from '@/components/ui'

const PLAIN_XGO_SOURCE = `
import (
"sort"
"strconv"
"strings"
"time"
)

values := []int{3, 1, 2}
sort.Ints(values)
for i := 1; i <= 60; i++ {
echo strings.ToUpper("plain XGo tick") + " " + strconv.Itoa(i), values
time.Sleep(time.Second)
}
`

const QUICK_XGO_SOURCE = `
echo "quick XGo completed"
`

const TUTORIAL_XGO_SOURCE = `
onStart => {
code := readCode()
showMessage "Current code: " + code
setProgress 1, 2
}

onSubmit submission => {
showMessage "Submitted: " + submission
setProgress 2, 2
}
`

const message = useMessage()
const userCode = ref('onStart => {\n showMessage "Hello"\n}')
const submission = ref('My course answer')
const plainStatus = ref('idle')
const tutorialStatus = ref('idle')
const progress = ref<TutorialProgress>({ completed: 0, total: 2 })
const events = ref<string[]>([])
let plainExecutor: XGoExecutor | null = null
let tutorialExecutor: XGoExecutor | null = null

async function runPlain(source = PLAIN_XGO_SOURCE) {
plainStatus.value = 'starting'
plainExecutor = new XGoExecutor({
framework: null,
onError: (phase, content) => {
plainStatus.value = `${phase}: ${content}`
},
onOutput: (content) => events.value.push(`plain output: ${content}`),
onExit: (reason) => {
plainStatus.value = `exited: ${reason}`
events.value.push(`plain exited: ${reason}`)
}
})
try {
await plainExecutor.run({ 'main.xgo': source })
plainStatus.value = 'running'
} catch (error) {
plainStatus.value = String(error)
}
}

async function runTutorial() {
tutorialStatus.value = 'starting'
progress.value = { completed: 0, total: 2 }
const framework = createTutorialFramework({
showMessage: (content) => {
events.value.push(`message: ${content}`)
message.info(content)
},
readCode: async () => {
await new Promise((resolve) => window.setTimeout(resolve, 100))
events.value.push('code read')
return userCode.value
},
setProgress: (value) => {
progress.value = value
events.value.push(`progress: ${value.completed}/${value.total}`)
}
})
tutorialExecutor = new XGoExecutor({
framework,
onError: (phase, content) => {
tutorialStatus.value = `${phase}: ${content}`
},
onExit: (reason) => {
tutorialStatus.value = `exited: ${reason}`
events.value.push(`tutorial exited: ${reason}`)
}
})
try {
await tutorialExecutor.run({ 'main_course.gox': TUTORIAL_XGO_SOURCE })
if (tutorialStatus.value === 'starting') tutorialStatus.value = 'running'
} catch (error) {
tutorialStatus.value = String(error)
}
}

async function submit() {
if (tutorialExecutor == null) return
try {
await dispatchTutorialSubmit(tutorialExecutor, submission.value)
events.value.push(`submission dispatched: ${submission.value}`)
} catch (error) {
tutorialStatus.value = String(error)
}
}

async function stopAll() {
await Promise.all([plainExecutor?.stop(), tutorialExecutor?.stop()])
}
</script>

<template>
<main class="mx-auto max-w-3xl p-8">
<h1 class="mb-6 text-2xl font-semibold">XGo executor debug</h1>
<UICard class="space-y-5 p-6">
<div class="flex gap-3">
<UIButton
type="primary"
:disabled="plainStatus === 'starting' || plainStatus === 'running'"
@click="runPlain()"
>
Run plain XGo
</UIButton>
<UIButton
type="secondary"
:disabled="plainStatus === 'starting' || plainStatus === 'running'"
@click="runPlain(QUICK_XGO_SOURCE)"
>
Run quick XGo
</UIButton>
<UIButton
type="secondary"
:disabled="tutorialStatus === 'starting' || tutorialStatus === 'running'"
@click="runTutorial"
>
Run tutorial course
</UIButton>
<UIButton type="neutral" @click="stopAll">Stop all</UIButton>
</div>

<div class="grid grid-cols-2 gap-4">
<label class="space-y-2">
<span class="text-sm font-medium">Code returned by readCode</span>
<UITextInput v-model:value="userCode" type="textarea" :rows="4" />
</label>
<label class="space-y-2">
<span class="text-sm font-medium">Submission dispatched to onSubmit</span>
<UITextInput v-model:value="submission" />
<UIButton type="secondary" :disabled="tutorialStatus !== 'running'" @click="submit">Submit</UIButton>
</label>
</div>

<div class="grid grid-cols-2 gap-4 text-sm">
<div>Plain: {{ plainStatus }}</div>
<div>Tutorial: {{ tutorialStatus }}</div>
<div>Progress: {{ progress.completed }}/{{ progress.total }}</div>
</div>

<pre v-if="events.length > 0" class="whitespace-pre-wrap">{{ events.join('\n') }}</pre>
</UICard>
</main>
</template>
4 changes: 4 additions & 0 deletions spx-gui/src/apps/xbuilder/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ const routes: Array<RouteRecordRaw> = [
path: '/tutorials',
component: () => import('./pages/tutorials/index.vue')
},
{
path: '/debug/xgo-executor',
component: () => import('./pages/debug/xgo-executor.vue')
},
{
path: '/course/:courseSeriesIdInput/:courseIdInput/start',
component: () => import('./pages/tutorials/course-start.vue'),
Expand Down
187 changes: 187 additions & 0 deletions spx-gui/src/utils/xgo-executor/core/executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import type { MainMessage, WorkerMessage, XGoErrorPhase, XGoExitReason } from './protocol'

/** A JSON-compatible request/response handler. Returning a Promise makes the XGo call wait asynchronously. */
export type XGoCapability = (request: unknown) => unknown | Promise<unknown>

/** A framework name and its explicitly allowed UI capabilities. */
export type XGoFramework = { name: string; capabilities: Record<string, XGoCapability> }

export type XGoExecutorOptions = {
/** `null` runs plain XGo; otherwise the name selects the Go-side binding and capabilities bind it to the UI. */
framework: XGoFramework | null
/** Receives failures using the phases documented by `XGoErrorPhase`. */
onError: (phase: XGoErrorPhase, message: string) => void
/** Receives lines written by the XGo program, including `echo` output. */
onOutput?: (message: string) => void
/** Fires exactly once when execution completes, is stopped, or exits because of an error. */
onExit: (reason: XGoExitReason) => void
}

interface WorkerHandler {
postMessage(message: MainMessage): void
addEventListener(type: 'message', listener: (event: MessageEvent<WorkerMessage>) => void): void
addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void
terminate(): void
}

type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
}

/**
* Runs one XGo project at a time in a dedicated Web Worker/WASM runtime.
* Call `run` again after `onExit` for sequential projects. Use a separate executor for a different framework or for
* concurrent execution; separate executors are isolated but each loads its own WASM runtime.
*
* The dedicated worker keeps XGo compilation and interpretation off the UI thread and isolates the Go bridge globals
* used by each WASM runtime. The tradeoff is one WASM instance per concurrent executor; a shared worker would require
* the Go bridge to address multiple runtimes and is a possible optimization if this cost becomes significant.
*/
export class XGoExecutor {
private worker: WorkerHandler | null = null
private state: 'idle' | 'starting' | 'running' = 'idle'
private exitDeferred: Deferred<void> | null = null
private nextEventCallId = 0
private pendingEventCalls = new Map<number, Deferred<void>>()

constructor(private options: XGoExecutorOptions) {}

/** Builds and starts a project. The promise resolves when execution starts; completion is reported through `onExit`. */
async run(files: Record<string, string>): Promise<void> {
if (this.state !== 'idle') throw new Error('XGo executor is already running')
this.state = 'starting'
const worker: WorkerHandler = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
const ready = createDeferred<void>()
const started = createDeferred<void>()
this.exitDeferred = createDeferred<void>()
this.worker = worker
worker.addEventListener('message', (event) => this.handleMessage(worker, event.data, ready, started))
worker.addEventListener('error', (event) => {
const error = new Error(event.message)
this.options.onError('initialization', error.message)
ready.reject(error)
started.reject(error)
this.finish(worker, 'error')
})
await ready.promise
worker.postMessage({
type: 'run',
framework: this.options.framework?.name ?? '',
files: encodeFiles(files)
})
await started.promise
}

/** Requests cancellation and resolves after `onExit('stopped')` has been emitted. */
async stop(): Promise<void> {
const worker = this.worker
const exitDeferred = this.exitDeferred
if (worker == null || exitDeferred == null) return
if (this.state === 'starting') {
this.finish(worker, 'stopped')
return
}
worker.postMessage({ type: 'stop' })
await exitDeferred.promise
}

/** Dispatches a framework event and resolves once the running program has accepted it. */
async dispatchEvent(name: string, payload: unknown): Promise<void> {
const worker = this.worker
if (worker == null || this.state !== 'running') throw new Error('XGo executor is not running')
const id = ++this.nextEventCallId
const deferred = createDeferred<void>()
this.pendingEventCalls.set(id, deferred)
try {
worker.postMessage({ type: 'event', id, name, payload })
} catch (error) {
this.pendingEventCalls.delete(id)
throw error
}
await deferred.promise
}

private handleMessage(worker: WorkerHandler, message: WorkerMessage, ready: Deferred<void>, started: Deferred<void>) {
if (worker !== this.worker) return
switch (message.type) {
case 'ready':
ready.resolve()
break
case 'started':
this.state = 'running'
started.resolve()
break
case 'error': {
const error = new Error(`${message.phase}: ${message.message}`)
this.options.onError(message.phase, message.message)
if (message.phase === 'initialization') ready.reject(error)
else if (this.state === 'starting') started.reject(error)
break
}
case 'exit':
this.finish(worker, message.reason)
break
case 'output':
this.options.onOutput?.(message.message)
break
case 'capabilityCall':
void this.handleCapabilityCall(worker, message.id, message.name, message.request)
break
case 'eventResult': {
const call = this.pendingEventCalls.get(message.id)
if (call == null) break
this.pendingEventCalls.delete(message.id)
if (message.error == null) call.resolve()
else call.reject(new Error(message.error))
break
}
}
}

private async handleCapabilityCall(worker: WorkerHandler, id: number, name: string, request: unknown) {
try {
const capability = this.options.framework?.capabilities[name]
if (capability == null) throw new Error(`unsupported capability: ${name}`)
const result = await capability(request)
if (worker !== this.worker) return
worker.postMessage({ type: 'capabilityCallResult', id, result, error: null })
} catch (error) {
if (worker !== this.worker) return
const message = toErrorMessage(error)
this.options.onError('capability', message)
worker.postMessage({ type: 'capabilityCallResult', id, result: null, error: message })
}
}

private finish(worker: WorkerHandler, reason: XGoExitReason) {
if (worker !== this.worker) return
worker.terminate()
this.worker = null
this.state = 'idle'
for (const call of this.pendingEventCalls.values()) call.reject(new Error(`XGo executor exited: ${reason}`))
this.pendingEventCalls.clear()
this.options.onExit(reason)
this.exitDeferred?.resolve()
this.exitDeferred = null
}
}

function createDeferred<T>(): Deferred<T> {
let resolvePromise: (value: T) => void = () => {}
let rejectPromise: (reason: unknown) => void = () => {}
const promise = new Promise<T>((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})
return { promise, resolve: resolvePromise, reject: rejectPromise }
}

function encodeFiles(files: Record<string, string>): Record<string, Uint8Array> {
return Object.fromEntries(Object.entries(files).map(([path, content]) => [path, new TextEncoder().encode(content)]))
}

function toErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
Loading