Go
Introduction
When GOARCH=wasm GOOS=js
, Go’s compiler targets WebAssembly Binary format
(%.wasm).
Here’s a typical compilation command:
$ GOARCH=wasm GOOS=js go build -o my.wasm .
The operating system is “js”, but more specifically it is wasm_exec.js.
This package runs the %.wasm
just like wasm_exec.js
would.
Experimental
It is important to note that while there are some interesting features, such as HTTP client support, the ABI (host functions imported by Wasm) used is complicated and custom to Go. For this reason, there are few implementations outside the web browser.
Moreover, Go defines js “EXPERIMENTAL… exempt from the Go compatibility promise.” While WebAssembly signatures haven’t broken between 1.18 and 1.19, then have in the past and can in the future.
For this reason implementations such as wazero’s gojs, cannot guarantee portability from release to release, or that the code will work well in production.
Due to lack of adoption, support and relatively high implementation overhead, most choose TinyGo to compile source code, even if it supports less features.
WebAssembly Features
GOARCH=wasm GOOS=js
uses instructions in [WebAssembly Core Specification 1.0]
15 unless GOWASM
includes features added afterwards.
Here are the valid GOWASM values:
satconv
- Non-trapping Float-to-int Conversionssignext
- Sign-extension operators
Note that both the above features are included working draft of WebAssembly Core Specification 2.0.
Constraints
Please read our overview of WebAssembly and constraints. In short, expect limitations in both language features and library choices when developing your software.
GOARCH=wasm GOOS=js
has a custom ABI which supports a subset of features in
the Go standard library. Notably, the host can implement time, crypto, file
system and HTTP client functions. Even where implemented, certain operations
will have no effect for reasons like ignoring HTTP request properties or fake
values returned (such as the pid). When not supported, many functions return
syscall.ENOSYS
errors, or the string form: “not implemented on js”.
Here are the more notable parts of Go which will not work when compiled via
GOARCH=wasm GOOS=js
, resulting in syscall.ENOSYS
errors:
- Raw network access. e.g.
net.Bind
- File descriptor control (
fnctl
). e.g.syscall.Pipe
- Arbitrary syscalls. Ex
syscall.Syscall
- Process control. e.g.
syscall.Kill
- Kernel parameters. e.g.
syscall.Sysctl
- Timezone-specific clock readings. e.g.
syscall.Gettimeofday
Memory
Memory layout begins with the “zero page” of size runtime.minLegalPointer
(4KB) which matches the ssa.minZeroPage
in the compiler. It is then followed
by 8KB reserved for args and environment variables. This means the data section
begins at ld.wasmMinDataAddr, offset 12288.
System Calls
Please read our overview of WebAssembly and System Calls. In short, WebAssembly is a stack-based virtual machine specification, so operates at a lower level than an operating system.
“syscall/js.*” are host functions for features the operating system would
otherwise provide. These also manage the JavaScript object graph, including
functions to make and finalize objects, arrays and numbers (js.Value
).
Each js.Value
has a js.ref
, which is either a numeric literal or an object
reference depending on its 64-bit bit pattern. When an object, the first 31
bits are its identifier.
There are several pre-defined values with constant js.ref
patterns. These are
either constants, globals or otherwise needed in initializers.
For example, the “global” value includes properties like “fs” and “process”
which implement system calls needed for functions like os.Getuid
.
Notably, not all system calls are implemented as some are stubbed by the
compiler to return zero values or syscall.ENOSYS
. This means not all Go code
compiled to wasm will operate. For example, you cannot launch processes.
Details beyond this are best looking at the source code of js.go, or its unit tests.
Concurrency
Please read our overview of WebAssembly and concurrency. In short, the current WebAssembly specification does not support parallel processing.
Some internal code may seem strange knowing this. For example, Go’s function
wrapper used for GOOS=js
is implemented using locks. Seeing this, you may
feel the host side of this code (_makeFuncWrapper
) should lock its ID
namespace for parallel use as well.
Digging deeper, you’ll notice the atomics defined by GOARCH=wasm
are
not actually implemented with locks, rather it is awaiting the “Threads”
proposal.
In summary, while goroutines are supported in GOARCH=wasm GOOS=js
, they won’t
be able to run in parallel until the WebAssembly Specification includes atomics
and Go’s compiler is updated to use them.
Error handling
There are several js.Value
used to implement GOARCH=wasm GOOS=js
including
the global, file system, HTTP round tripping, processes, etc. All of these have
functions that may return an error on js.Value.Call
.
However, js.Value.Call
does not return an error result. Internally, this
dispatches to the wasm imported function valueCall
, and interprets its two
results: the real result and a boolean, represented by an integer.
When false, js.Value.Call
panics with a js.Error
constructed from the first
result. This result must be an object with one of the below properties:
- JavaScript (GOOS=js): the string property “message” can be anything.
- Syscall error (GOARCH=wasm): the string property “code” is constrained.
- The code must be like “EIO” in errnoByCode to avoid a panic.
Details beyond this are best looking at the source code of js.go, or its unit tests.
Identifying wasm compiled by Go
If you have a %.wasm
file compiled by Go (via asm.go), it has a custom
section named “go.buildid”.
You can verify this with wasm-objdump, a part of wabt:
$ wasm-objdump --section=go.buildid -x my.wasm
example3.wasm: file format wasm 0x1
Section Details:
Custom:
- name: "go.buildid"
Module Exports
Until wasmexport is implemented, the compiled WebAssembly exports are always the same:
- “mem” - (memory 265) 265 = data section plus 16MB
- “run” - (func (param $argc i32) (param $argv i32)) the entrypoint
- “resume” - (func) continues work after a timer delay
- “getsp” - (func (result i32)) returns the stack pointer
Module Imports
Go’s compiles all WebAssembly imports in the module “go”, and only functions are imported.
Except for the “debug” function, all function names are prefixed by their go package. Here are the defaults:
- “debug” - is always function index zero, but it has unknown use.
- “runtime.*” - supports system-call like functionality
GOARCH=wasm
- “syscall/js.*” - supports the JavaScript model
GOOS=js
PC_B calling conventions
The assembly CallImport
instruction doesn’t compile signatures to WebAssembly
function types, invoked by the call
instruction.
Instead, the compiler generates the same signature for all functions: a single
parameter of the stack pointer, and invokes them via call.indirect
.
Specifically, any function compiled with CallImport
has the same function
type: (func (param $sp i32))
. $sp
is the base memory offset to read and
write parameters to the stack (at 8 byte strides even if the value is 32-bit).
So, implementors need to read the actual parameters from memory. Similarly, if there are results, the implementation must write those to memory.
For example, func walltime() (sec int64, nsec int32)
writes its results to
memory at offsets sp+8
and sp+16
respectively.
Note: WebAssembly compatible calling conventions has been discussed and attempted in Go before.
Go-defined exported functions
Several functions differ in calling convention by using WebAssembly type signatures instead of the single SP parameter summarized above. Functions used by the host have a “wasm_export_” prefix, which is stripped. For example, “wasm_export_run” is exported as “run”, defined in rt0_js_wasm.s
Here is an overview of the Go-defined exported functions:
- “run” - Accepts “argc” and “argv” i32 params and begins the “wasm_pc_f_loop”
- “resume” - Nullary function that resumes execution until it needs an event.
- “getsp” - Returns the i32 stack pointer (SP)
User-defined Host Functions
Users can define their own “go” module function imports by defining a func
without a body in their source and a %_wasm.s
or %_js.s
file that uses the
CallImport
instruction.
For example, given func logString(msg string)
and the below assembly:
#include "textflag.h"
TEXT ·logString(SB), NOSPLIT, $0
CallImport
RET
If the package was main
, the WebAssembly function name would be
“main.logString”. If it was util
and your go.mod
module was
“github.com/user/me”, the WebAssembly function name would be
“github.com/user/me/util.logString”.
Regardless of whether the function import was built-in to Go, or defined by an
end user, all imports use CallImport
conventions. Since these compile to a
signature unrelated to the source, more care is needed implementing the host
side, to ensure the proper count of parameters are read and results written to
the Go stack.
Hacking
If you run into an issue where you need to change Go’s sourcecode, the first thing you should do is read the contributing guide, which details how to confirm an issue exists and a fix would be accepted. Assuming they say yes, the next step is to ensure you can build and test go.
Make a branch for your changes
First, clone upstream or your fork of golang/go and make a branch off master
for your work, as GitHub pull requests are against that branch.
$ git clone --depth=1 https://github.com/golang/go.git
$ cd go
$ git checkout -b my-fix
Build a branch-specific go
binary
While your change may not affect the go binary itself, there are checks inside go that require version matching. Build a go binary from source to avoid these:
$ cd src
$ GOARCH=wasm GOOS=js ./make.bash
Building Go cmd/dist using /usr/local/go. (go1.19 darwin/amd64)
Building Go toolchain1 using /usr/local/go.
--snip--
$ cd ..
$ bin/go version
go version devel go1.19-c5da4fb7ac Fri Jul 22 20:12:19 2022 +0000 darwin/amd64
Tips:
- The above
bin/go
was built with whatever go version you had in your path! GOARCH
here is what the resultinggo
binary can target. It isn’t the architecture of the current host (GOHOSTARCH
).
Setup ENV variables for your branch.
To test the Go you just built, you need to have GOROOT
set to your workspace,
and your PATH configured to find both bin/go
and misc/wasm/go_js_wasm_exec
.
$ export GOROOT=$PWD
$ export PATH=${GOROOT}/misc/wasm:${GOROOT}/bin:$PATH
Tip: go_js_wasm_exec
is used because Go doesn’t embed a WebAssembly runtime
like wazero. In other words, go can’t run the wasm it just built. Instead,
go test
uses Node.js which it assumes is installed on your host!
Iterate until ready to submit
Now, you should be all set and can iterate similar to normal Go development.
The main thing to keep in mind is where files are, and remember to set
GOARCH=wasm GOOS=js
when running go commands.
For example, if you fixed something in the syscall/js
package
(${GOROOT}/src/syscall/js
), test it like so:
$ GOARCH=wasm GOOS=js go test syscall/js
ok syscall/js 1.093s
Notes
Here are some notes about testing GOARCH=wasm GOOS=js
Skipped tests
You may find tests are skipped (e.g. when run with -v
arg).
=== RUN TestSeekError
os_test.go:1598: skipping test on js
The go test tree has functions to check if a platform is supported before proceeding. This allows incremental development of platforms, or avoids things like launching subprocesses on wasm, which won’t likely ever support that.
Filesystem access
TestStat
tries to read /etc/passwd
due to a runtime.GOOS default.
As GOARCH=wasm GOOS=js
is a virtualized operating system, this may not make
sense, because it has no files representing an operating system.
Moreover, as of Go 1.19, tests don’t pass through any configuration to hint at
the real OS underneath the VM. You might suspect running wasm tests on Windows
would fail, as that OS has no /etc/passwd
file. In fact, they would except
Windows tests don’t pass anyway because the script that invokes Node.JS,
wasm_exec_node.js, doesn’t actually work on Windows.
$ GOOS=js GOARCH=wasm go test os
fork/exec C:\Users\fernc\AppData\Local\Temp\go-build2236168911\b001\os.test: %1 is not a valid Win32 application.
FAIL os 0.034s
FAIL
Hosts like Darwin and Linux pass these tests because they include files like
/etc/passwd
and the test runner (wasm_exec_node.js
) is configured to pass
through any file system calls without filtering. Specifically,
globalThis.fs = require("fs")
allows code compiled to wasm any file access
the host operating system’s underlying controls permit.