Adding Go to a browser code runner
Adding Go to a browser code runner
dailyprog (the daily coding puzzle site, previously) started with JavaScript. Then Python, then C. Each new language was a different kind of project. Python needed Pyodide. C needed a PicoC WASM binary and a custom harness for printf output. Go was supposed to be the fourth. Every Go-to-WASM tutorial points at the same path, so I took it. It was the wrong one.
dailyprog(之前的每日编程谜题网站)最初使用的是 JavaScript,随后加入了 Python 和 C。每种新语言都是一个不同类型的项目:Python 需要 Pyodide,C 需要 PicoC 的 WASM 二进制文件以及用于 printf 输出的自定义适配器。Go 本应是第四个,但每一个关于“Go 转 WASM”的教程都指向了同一条路径,我照做了,结果却选错了。
The path everyone takes: GOOS=js
每个人都在走的路径:GOOS=js
Compiling Go to WASM for the browser means setting GOOS=js GOARCH=wasm. Go ships two pieces for this: a compiler target that produces WASM with a JavaScript syscall bridge, and wasm_exec.js, a support file that implements the Go runtime’s expectations on the JS side. You load wasm_exec.js, you call WebAssembly.instantiate(), the Go runtime boots, and your program runs.
将 Go 编译为浏览器可用的 WASM 意味着设置 GOOS=js GOARCH=wasm。Go 为此提供了两个组件:一个生成带有 JavaScript 系统调用桥接(syscall bridge)的 WASM 编译目标,以及一个名为 wasm_exec.js 的支持文件,用于在 JS 端实现 Go 运行时的预期行为。你加载 wasm_exec.js,调用 WebAssembly.instantiate(),Go 运行时就会启动,程序随之运行。
This is how most in-browser Go playgrounds work (LiveCodes, for one; the official playground sidesteps the problem entirely by running your code on a server). It works, and there are years of Stack Overflow answers behind it.
大多数浏览器内的 Go 演练场(例如 LiveCodes;官方演练场则通过在服务器上运行代码完全规避了这个问题)都是这样工作的。这种方法行之有效,并且背后有多年积累的 Stack Overflow 答案支持。
The problem is that dailyprog runs user-submitted code inside isolated-vm, not in the main browser thread. isolated-vm is a V8 isolate with no DOM, no event loop, and a restricted API surface. And the standard boot recipe uses async WebAssembly.instantiate(), which returns a Promise. Inside isolated-vm, that Promise never settles, because nothing in the isolate drives it. The program hangs until the call times out.
问题在于 dailyprog 是在 isolated-vm 中运行用户提交的代码,而不是在浏览器主线程中。isolated-vm 是一个没有 DOM、没有事件循环且 API 接口受限的 V8 隔离环境。标准的启动方案使用异步的 WebAssembly.instantiate(),它会返回一个 Promise。在 isolated-vm 内部,该 Promise 永远不会完成(settle),因为隔离环境内没有任何机制来驱动它。程序会一直挂起,直到调用超时。
I spent two days on variations of this. The error said Maximum call stack size exceeded, so I chased stack overflows and increased the V8 stack size. No effect (—stack-size doesn’t even reach isolates, and the overflow was a symptom of the hung boot anyway). I tried injecting setTimeout polyfills into the isolate. Nothing worked. Async initialization in a synchronous environment is a dead end.
我在这上面折腾了两天。错误提示显示“Maximum call stack size exceeded”(调用栈溢出),所以我去排查栈溢出并增加了 V8 的栈大小。但这毫无作用(--stack-size 参数甚至无法作用于隔离环境,而且溢出本身只是启动挂起的一个症状)。我尝试向隔离环境注入 setTimeout 的 polyfill,但什么都没用。在同步环境中使用异步初始化是一条死胡同。
There’s a second problem with GOOS=js. Even if the async issue were solvable, the syscall/js bridge (the mechanism Go uses to call into JavaScript and vice versa) relies on setting properties on globalThis. In a browser, globalThis is the window. In isolated-vm, the globalThis the bridge captured at boot isn’t the one your code sees, so the callbacks fire and nothing receives them. You can’t patch it from the outside; the bridge is compiled into the WASM.
GOOS=js 还有第二个问题。即使异步问题可以解决,syscall/js 桥接(Go 用于与 JavaScript 相互调用的机制)也依赖于在 globalThis 上设置属性。在浏览器中,globalThis 就是 window。而在 isolated-vm 中,桥接在启动时捕获的 globalThis 并不是你的代码所看到的那个,因此回调触发后没有任何东西能接收到它们。你无法从外部修补它,因为桥接代码已经被编译进了 WASM 中。
GOOS=js assumes a full browser. Take that away and it fails in ways nobody has written down, because almost nobody runs Go WASM inside a V8 isolate.
GOOS=js 假设存在一个完整的浏览器环境。一旦剥离这个环境,它就会以一种无人记录的方式失败,因为几乎没有人会在 V8 隔离环境中运行 Go WASM。
The clean path: GOOS=wasip1
更简洁的路径:GOOS=wasip1
WASI is a system interface for WASM. It defines a set of imports that a WASM module can call (file I/O, environment variables, clock, random numbers) and the host provides implementations. It’s a much smaller contract than the JS bridge. No Promises, no event loop, no globalThis. Just a linear memory buffer and a handful of function imports that read from and write to it.
WASI 是 WASM 的系统接口。它定义了一组 WASM 模块可以调用的导入(如文件 I/O、环境变量、时钟、随机数),并由宿主提供实现。这比 JS 桥接的契约要小得多。没有 Promise,没有事件循环,没有 globalThis。只有一块线性内存缓冲区和少量用于读写的函数导入。
Go supports WASI as a build target since 1.21: GOOS=wasip1 GOARCH=wasm. When you compile with this, Go’s fmt.Println writes to stdout via WASI’s fd_write import. Your job as the host is to implement fd_write and capture those writes.
自 1.21 版本起,Go 开始支持 WASI 作为构建目标:GOOS=wasip1 GOARCH=wasm。当你以此编译时,Go 的 fmt.Println 会通过 WASI 的 fd_write 导入写入标准输出。作为宿主,你的任务就是实现 fd_write 并捕获这些写入内容。
(Code snippet omitted for brevity)
(代码片段略)
The whole host side is about fifty lines. The Go program compiles to WASM, boots in the isolate, runs the user’s code via Yaegi (a Go interpreter written in Go), and fmt.Println output shows up in stdout.
整个宿主端代码大约五十行。Go 程序被编译为 WASM,在隔离环境中启动,通过 Yaegi(一个用 Go 编写的 Go 解释器)运行用户代码,fmt.Println 的输出就会出现在标准输出中。
The runtime is a single WASM binary: Yaegi compiled to WASI. Build recipe:
运行时是一个单一的 WASM 二进制文件:编译为 WASI 的 Yaegi。构建配方如下:
go mod init yaegi-wasi
go get github.com/traefik/yaegi@v0.16.1
GOOS=wasip1 GOARCH=wasm go build -o yaegi.wasm yaegi_wasi.go
The wrapper is about 30 lines of Go: create an interpreter with interp.New, load stdlib.Symbols into it, read source from os.Args[1], Eval it. No special compilation tricks. The resulting WASM is 38 MB.
包装器大约 30 行 Go 代码:使用 interp.New 创建解释器,加载 stdlib.Symbols,从 os.Args[1] 读取源码并执行 Eval。没有特殊的编译技巧。生成的 WASM 文件大小为 38 MB。
The single-shot trap
“单次运行”陷阱
There’s one catch. WASI _start is single-shot. You call it once, the program runs to completion, and the runtime tears down. If you call _start again on the same WebAssembly.Instance, Go panics: fatal error: randinit twice Or: fatal error: self deadlock Or, the worst version: no error at all. Yaegi dies before fmt.Println fires and the runner reports “No output for this case.” Nothing in that message points at instance reuse.
这里有一个陷阱:WASI 的 _start 是单次运行的。你调用它一次,程序运行结束,运行时就会销毁。如果你在同一个 WebAssembly.Instance 上再次调用 _start,Go 会抛出 panic:fatal error: randinit twice,或者 fatal error: self deadlock,最糟糕的情况是没有任何错误提示。Yaegi 在 fmt.Println 执行前就挂了,运行器只会报告“此用例无输出”。错误信息中没有任何迹象指向实例重用问题。
The fix: fresh instance per run. And on the server side, fresh isolated-vm Isolate per run (about 4 seconds cold boot). In the browser, you cache the compiled WebAssembly.Module (compiling a 38 MB WASM binary on every run would be painful) but create a fresh Instance each time.
解决方法是:每次运行都创建一个全新的实例。在服务器端,每次运行都创建一个全新的 isolated-vm 隔离环境(冷启动大约 4 秒)。在浏览器中,你可以缓存编译后的 WebAssembly.Module(每次运行都编译 38 MB 的 WASM 二进制文件会非常痛苦),但每次都要创建一个新的 Instance。
(Code snippet omitted for brevity)
(代码片段略)