From 4ad79f9849f314ce71c90f3a62978bb7d247ddc3 Mon Sep 17 00:00:00 2001 From: nighca Date: Mon, 13 Jul 2026 20:32:19 +0800 Subject: [PATCH 1/6] feat: add browser XGo executor --- spx-gui/build-wasm.sh | 4 + .../xbuilder/pages/debug/xgo-executor.vue | 27 +++ spx-gui/src/apps/xbuilder/router.ts | 4 + spx-gui/src/utils/xgo-executor.ts | 40 ++++ tools/xgoexec/build.sh | 4 + tools/xgoexec/embedded_pkgs.go | 5 + tools/xgoexec/go.mod | 25 +++ tools/xgoexec/go.sum | 40 ++++ .../builder/tools/xgoexec/tutorial/export.go | 30 +++ tools/xgoexec/main.go | 174 ++++++++++++++++++ tools/xgoexec/tutorial/go.mod | 3 + tools/xgoexec/tutorial/tutorial.go | 30 +++ 12 files changed, 386 insertions(+) create mode 100644 spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue create mode 100644 spx-gui/src/utils/xgo-executor.ts create mode 100644 tools/xgoexec/build.sh create mode 100644 tools/xgoexec/embedded_pkgs.go create mode 100644 tools/xgoexec/go.mod create mode 100644 tools/xgoexec/go.sum create mode 100755 tools/xgoexec/internal/pkg/github.com/goplus/builder/tools/xgoexec/tutorial/export.go create mode 100644 tools/xgoexec/main.go create mode 100644 tools/xgoexec/tutorial/go.mod create mode 100644 tools/xgoexec/tutorial/tutorial.go diff --git a/spx-gui/build-wasm.sh b/spx-gui/build-wasm.sh index fc1a4b230e..bfbffec9f1 100755 --- a/spx-gui/build-wasm.sh +++ b/spx-gui/build-wasm.sh @@ -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 diff --git a/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue b/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue new file mode 100644 index 0000000000..8d4b06852d --- /dev/null +++ b/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue @@ -0,0 +1,27 @@ + + + diff --git a/spx-gui/src/apps/xbuilder/router.ts b/spx-gui/src/apps/xbuilder/router.ts index fbe4b86872..ae69fa2744 100644 --- a/spx-gui/src/apps/xbuilder/router.ts +++ b/spx-gui/src/apps/xbuilder/router.ts @@ -118,6 +118,10 @@ const routes: Array = [ 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'), diff --git a/spx-gui/src/utils/xgo-executor.ts b/spx-gui/src/utils/xgo-executor.ts new file mode 100644 index 0000000000..11febb6914 --- /dev/null +++ b/spx-gui/src/utils/xgo-executor.ts @@ -0,0 +1,40 @@ +import '@/assets/wasm/wasm_exec.js' + +const wasmUrl = new URL('@/assets/wasm/xgoexec.wasm', import.meta.url).href + +declare class Go { importObject: WebAssembly.Imports; run(instance: WebAssembly.Instance): Promise } + +type XGoExecutorGlobal = Window & { + xbuilder_xgoexec_configure: (framework: string) => void + xbuilder_xgoexec_build: (files: Record) => void + xbuilder_xgoexec_run: () => void + xbuilder_xgoexec_stop: () => void + xbuilder_xgoexec_error: (phase: string, message: string) => void + xbuilder_xgoexec_capability: (name: string, content: string) => void +} + +export class XGoExecutor { + private readonly target = window as XGoExecutorGlobal + + constructor(private framework: 'none' | 'tutorial', private onMessage: (content: string) => void) {} + + async run(files: Record) { + const go = new Go() + const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), go.importObject) + const errors: string[] = [] + this.target.xbuilder_xgoexec_error = (phase, message) => errors.push(`${phase}: ${message}`) + this.target.xbuilder_xgoexec_capability = (name, content) => { + if (name !== 'showMessage') throw new Error(`unsupported capability: ${name}`) + this.onMessage(content) + } + void go.run(instance) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + this.target.xbuilder_xgoexec_configure(this.framework) + const encoded = Object.fromEntries(Object.entries(files).map(([path, content]) => [path, new TextEncoder().encode(content)])) + this.target.xbuilder_xgoexec_build(encoded) + if (errors.length > 0) throw new Error(errors.join('\n')) + this.target.xbuilder_xgoexec_run() + } + + stop() { this.target.xbuilder_xgoexec_stop() } +} diff --git a/tools/xgoexec/build.sh b/tools/xgoexec/build.sh new file mode 100644 index 0000000000..c79aa87036 --- /dev/null +++ b/tools/xgoexec/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +go generate ./... +GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" -o xgoexec.wasm diff --git a/tools/xgoexec/embedded_pkgs.go b/tools/xgoexec/embedded_pkgs.go new file mode 100644 index 0000000000..b2f7f19316 --- /dev/null +++ b/tools/xgoexec/embedded_pkgs.go @@ -0,0 +1,5 @@ +package main + +//go:generate go tool qexp -outdir internal/pkg github.com/goplus/builder/tools/xgoexec/tutorial + +import _ "github.com/goplus/builder/tools/xgoexec/internal/pkg/github.com/goplus/builder/tools/xgoexec/tutorial" diff --git a/tools/xgoexec/go.mod b/tools/xgoexec/go.mod new file mode 100644 index 0000000000..29c490e6b8 --- /dev/null +++ b/tools/xgoexec/go.mod @@ -0,0 +1,25 @@ +module github.com/goplus/builder/tools/xgoexec + +go 1.25.0 + +tool github.com/goplus/ixgo/cmd/qexp + +require ( + github.com/goplus/builder/tools/xgoexec/tutorial v0.0.0 + github.com/goplus/ixgo v1.1.0 + github.com/goplus/mod v0.20.2 +) + +require ( + github.com/goplus/gogen v1.23.0-pre.3.0.20260414234848-6641c10c9d6f // indirect + github.com/goplus/reflectx v1.7.0 // indirect + github.com/goplus/xgo v1.7.2-0.20260414235301-df19f4a1b7c2 // indirect + github.com/qiniu/x v1.17.0 // indirect + github.com/timandy/routine v1.1.5 // indirect + github.com/visualfc/funcval v0.1.4 // indirect + github.com/visualfc/xtype v0.3.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/tools v0.38.0 // indirect +) + +replace github.com/goplus/builder/tools/xgoexec/tutorial => ./tutorial diff --git a/tools/xgoexec/go.sum b/tools/xgoexec/go.sum new file mode 100644 index 0000000000..61cf3d4b0b --- /dev/null +++ b/tools/xgoexec/go.sum @@ -0,0 +1,40 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/goplus/gogen v1.23.0-pre.3.0.20260414234848-6641c10c9d6f h1:qb3VcmljqvrIgEVbs83cbZkckszvtHl3OnO/ums8Zoc= +github.com/goplus/gogen v1.23.0-pre.3.0.20260414234848-6641c10c9d6f/go.mod h1:Y7ulYW3wonQ3d9er00b0uGFEV/IUZa6okWJZh892ACQ= +github.com/goplus/ixgo v1.1.0 h1:Lrp3xTzN+xUsrb7H3bIsGMHna1GTmoCrljDhFBF8OOs= +github.com/goplus/ixgo v1.1.0/go.mod h1:fhd/bwm497c66k7hZJJ2WmmvPiKEAJUQR3f2oQiAANQ= +github.com/goplus/mod v0.20.2 h1:YX72E6AhhRLvlkVnI9cBK6PZvUwtge2hwROh7w9N6Yk= +github.com/goplus/mod v0.20.2/go.mod h1:lWW62tH7L3Vm42Lr6wlUMYHvsm5w3TkEpE2ulKTgmU8= +github.com/goplus/reflectx v1.7.0 h1:52l0Si+vDc6CvT7Y+h47h2QUs5HQp6qxPlmvLsMzo38= +github.com/goplus/reflectx v1.7.0/go.mod h1:EXX8KSPTmnb2W1PfyVkDC0Qwet0lYX2h2/S06Vhwrhw= +github.com/goplus/xgo v1.7.2-0.20260414235301-df19f4a1b7c2 h1:kXIGYlJUTii+7nv1XTKR0ctMZSeTcgYMoQuEexZPr7A= +github.com/goplus/xgo v1.7.2-0.20260414235301-df19f4a1b7c2/go.mod h1:fX3+ZaYEzBE6qkgFcJx83D6DbUo/vMO8Y1Allydy2ws= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/qiniu/x v1.17.0 h1:OsyDKXzYp5vw9Hc7VAe4Cso1Sp50fLKGsBuDteyTevE= +github.com/qiniu/x v1.17.0/go.mod h1:AiovSOCaRijaf3fj+0CBOpR1457pn24b0Vdb1JpwhII= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/timandy/routine v1.1.5 h1:LSpm7Iijwb9imIPlucl4krpr2EeCeAUvifiQ9Uf5X+M= +github.com/timandy/routine v1.1.5/go.mod h1:kXslgIosdY8LW0byTyPnenDgn4/azt2euufAq9rK51w= +github.com/visualfc/funcval v0.1.4 h1:lAI88zQYfRzmC7mKF4+swXeCZvb8wb1f3lMSDRAY2mQ= +github.com/visualfc/funcval v0.1.4/go.mod h1:3Izv+irhArmrTvy+lmL6pIq16gSOzx73CIka51J9eR0= +github.com/visualfc/goembed v0.3.4 h1:foORJ0KT/pKwA23dStPFOtxaW7F96PnNSiLXHVXrfVk= +github.com/visualfc/goembed v0.3.4/go.mod h1:jCVCz/yTJGyslo6Hta+pYxWWBuq9ADCcIVZBTQ0/iVI= +github.com/visualfc/xtype v0.3.0 h1:K0Oo5XMcSjv+ohqn8L6RjgOZ9dNwfzPv3/AIV1SXRfA= +github.com/visualfc/xtype v0.3.0/go.mod h1:VYIH9S2bmdWKlBb7c725ES6yKF9+pyHBU2SFNqGVMGM= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/xgoexec/internal/pkg/github.com/goplus/builder/tools/xgoexec/tutorial/export.go b/tools/xgoexec/internal/pkg/github.com/goplus/builder/tools/xgoexec/tutorial/export.go new file mode 100755 index 0000000000..a6d11f8922 --- /dev/null +++ b/tools/xgoexec/internal/pkg/github.com/goplus/builder/tools/xgoexec/tutorial/export.go @@ -0,0 +1,30 @@ +// export by github.com/goplus/ixgo/cmd/qexp + +package tutorial + +import ( + q "github.com/goplus/builder/tools/xgoexec/tutorial" + + "reflect" + + "github.com/goplus/ixgo" +) + +func init() { + ixgo.RegisterPackage(&ixgo.Package{ + Name: "tutorial", + Path: "github.com/goplus/builder/tools/xgoexec/tutorial", + Deps: map[string]string{}, + Interfaces: map[string]reflect.Type{}, + NamedTypes: map[string]reflect.Type{ + "Course": reflect.TypeOf((*q.Course)(nil)).Elem(), + }, + AliasTypes: map[string]reflect.Type{}, + Vars: map[string]reflect.Value{}, + Funcs: map[string]reflect.Value{ + "SetShowMessage": reflect.ValueOf(q.SetShowMessage), + }, + TypedConsts: map[string]ixgo.TypedConst{}, + UntypedConsts: map[string]ixgo.UntypedConst{}, + }) +} diff --git a/tools/xgoexec/main.go b/tools/xgoexec/main.go new file mode 100644 index 0000000000..07d9c9be9a --- /dev/null +++ b/tools/xgoexec/main.go @@ -0,0 +1,174 @@ +//go:build js && wasm + +package main + +import ( + "context" + "fmt" + "io/fs" + "path" + "strings" + "sync" + "syscall/js" + + "github.com/goplus/builder/tools/xgoexec/tutorial" + "github.com/goplus/ixgo" + "github.com/goplus/ixgo/xgobuild" + "github.com/goplus/mod/modfile" +) + +var state struct { + sync.Mutex + ctx *ixgo.Context + interp *ixgo.Interp + cancel context.CancelFunc + framework string +} + +func init() { + js.Global().Set("xbuilder_xgoexec_configure", js.FuncOf(configure)) + js.Global().Set("xbuilder_xgoexec_build", js.FuncOf(build)) + js.Global().Set("xbuilder_xgoexec_run", js.FuncOf(run)) + js.Global().Set("xbuilder_xgoexec_stop", js.FuncOf(stop)) +} + +func configure(this js.Value, args []js.Value) any { + state.Lock() + defer state.Unlock() + if state.ctx != nil { + return jsError("already configured") + } + ctx := ixgo.NewContext(ixgo.SupportMultipleInterp | xgobuild.StaticLoad) + ctx.SetPanic(func(info *ixgo.PanicInfo) { js.Global().Call("xbuilder_xgoexec_error", "runtime", info.Error.Error()) }) + ctx.Loader.Import("fmt") + if len(args) > 0 && args[0].String() == "tutorial" { + xgobuild.RegisterProject(&modfile.Project{Ext: ".course", Class: "Course", PkgPaths: []string{"github.com/goplus/builder/tools/xgoexec/tutorial"}}) + tutorial.SetShowMessage(func(content string) { js.Global().Call("xbuilder_xgoexec_capability", "showMessage", content) }) + } + if len(args) > 0 { + state.framework = args[0].String() + } + state.ctx = ctx + return nil +} + +func build(this js.Value, args []js.Value) any { + if len(args) == 0 { + return jsError("missing files") + } + files := map[string][]byte{} + keys := js.Global().Get("Object").Call("keys", args[0]) + for i := 0; i < keys.Length(); i++ { + name := keys.Index(i).String() + value := args[0].Get(name) + data := make([]byte, value.Length()) + js.CopyBytesToGo(data, value) + files[name] = data + } + state.Lock() + defer state.Unlock() + if state.ctx == nil { + return jsError("not configured") + } + source, err := xgobuild.BuildFSDir(state.ctx, mapFS(files), ".") + if err != nil { + return jsError(err.Error()) + } + if state.framework == "tutorial" { + source = []byte(strings.Replace(string(source), "new(Course).Main()", "new(Course).MainEntry()", 1)) + } + pkg, err := state.ctx.LoadFile("main.go", source) + if err != nil { + return jsError(err.Error()) + } + interp, err := state.ctx.NewInterp(pkg) + if err != nil { + return jsError(err.Error()) + } + state.interp = interp + return nil +} + +func run(this js.Value, args []js.Value) any { + state.Lock() + if state.ctx == nil || state.interp == nil { + state.Unlock() + return jsError("not built") + } + ctx, interp := state.ctx, state.interp + runCtx, cancel := context.WithCancel(context.Background()) + state.cancel = cancel + state.Unlock() + go func() { + ctx.RunContext = runCtx + _, err := ctx.RunInterp(interp, "main.go", nil) + if err != nil && runCtx.Err() == nil { + js.Global().Call("xbuilder_xgoexec_error", "runtime", err.Error()) + } + }() + return nil +} + +func stop(this js.Value, args []js.Value) any { + state.Lock() + defer state.Unlock() + if state.cancel != nil { + state.cancel() + state.cancel = nil + } + return nil +} +func jsError(message string) any { return js.Global().Get("Error").New(message) } + +type mapFS map[string][]byte + +func (p mapFS) ReadFile(name string) ([]byte, error) { + value, ok := p[name] + if !ok { + return nil, fs.ErrNotExist + } + return value, nil +} +func (p mapFS) ReadDir(dirname string) ([]fs.DirEntry, error) { + prefix := "" + if dirname != "." { + prefix = dirname + "/" + } + entries := map[string]bool{} + for filename := range p { + if !strings.HasPrefix(filename, prefix) { + continue + } + name := strings.TrimPrefix(filename, prefix) + if i := strings.IndexByte(name, '/'); i >= 0 { + entries[name[:i]] = true + } else { + entries[name] = false + } + } + result := make([]fs.DirEntry, 0, len(entries)) + for name, isDir := range entries { + result = append(result, mapDirEntry{name: name, dir: isDir}) + } + return result, nil +} +func (p mapFS) Join(elem ...string) string { return path.Join(elem...) } +func (p mapFS) Base(filename string) string { return path.Base(filename) } +func (p mapFS) Abs(filename string) (string, error) { return path.Join("/", filename), nil } + +type mapDirEntry struct { + name string + dir bool +} + +func (p mapDirEntry) Name() string { return p.name } +func (p mapDirEntry) IsDir() bool { return p.dir } +func (p mapDirEntry) Type() fs.FileMode { + if p.dir { + return fs.ModeDir + } + return 0 +} +func (p mapDirEntry) Info() (fs.FileInfo, error) { return nil, fmt.Errorf("not implemented") } + +func main() { select {} } diff --git a/tools/xgoexec/tutorial/go.mod b/tools/xgoexec/tutorial/go.mod new file mode 100644 index 0000000000..5fa404f84d --- /dev/null +++ b/tools/xgoexec/tutorial/go.mod @@ -0,0 +1,3 @@ +module github.com/goplus/builder/tools/xgoexec/tutorial + +go 1.25.0 diff --git a/tools/xgoexec/tutorial/tutorial.go b/tools/xgoexec/tutorial/tutorial.go new file mode 100644 index 0000000000..75c8c18cb4 --- /dev/null +++ b/tools/xgoexec/tutorial/tutorial.go @@ -0,0 +1,30 @@ +package tutorial + +type Course struct { + onStart func() +} + +func (p *Course) OnStart(handler func()) { + p.onStart = handler + p.Start() +} + +func (p *Course) Start() { + if p.onStart != nil { + p.onStart() + } +} + +func (p *Course) Main() { + p.Start() +} + +var showMessage = func(string) {} + +func SetShowMessage(handler func(string)) { + showMessage = handler +} + +func (p *Course) ShowMessage(content string) { + showMessage(content) +} From 4430df4a8b72a94fa3fce2c4f7bce5d2494c0a1f Mon Sep 17 00:00:00 2001 From: nighca Date: Tue, 14 Jul 2026 16:34:58 +0800 Subject: [PATCH 2/6] refactor: decouple XGo frameworks --- .../xbuilder/pages/debug/xgo-executor.vue | 8 ++++--- spx-gui/src/utils/xgo-executor.ts | 16 ++++++++----- tools/xgoexec/framework.go | 12 ++++++++++ tools/xgoexec/main.go | 22 +++++++++-------- tools/xgoexec/tutorial_framework.go | 24 +++++++++++++++++++ 5 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 tools/xgoexec/framework.go create mode 100644 tools/xgoexec/tutorial_framework.go diff --git a/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue b/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue index 8d4b06852d..1e76e4c0b9 100644 --- a/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue +++ b/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue @@ -5,22 +5,24 @@ import { UIButton, UICard, useMessage } from '@/components/ui' const message = useMessage() const result = ref('') +let executor: XGoExecutor | null = null async function runPlain() { result.value = '' - try { await new XGoExecutor('none', message.info).run({ 'main.xgo': 'echo "plain XGo is running"' }); result.value = 'plain XGo ran successfully' } catch (error) { result.value = String(error) } + try { executor = new XGoExecutor(null); await executor.run({ 'main.xgo': 'import "time"\necho "plain XGo is running"\ntime.Sleep(time.Minute)' }); result.value = 'plain XGo is running' } catch (error) { result.value = String(error) } } async function runTutorial() { result.value = '' - try { await new XGoExecutor('tutorial', message.info).run({ 'main.course': 'onStart => {\n showMessage "Tutorial course started"\n}' }); result.value = 'tutorial class framework ran successfully' } catch (error) { result.value = String(error) } + try { executor = new XGoExecutor({ name: 'tutorial', capabilities: { showMessage: message.info } }); await executor.run({ 'main_course.gox': 'onStart => {\n showMessage "Tutorial course started"\n}' }); result.value = 'tutorial class framework is running' } catch (error) { result.value = String(error) } } +function stop() { executor?.stop(); result.value = 'stopped' }