What version of Go are you using (go version)?

$ go version
go version go1.18.1 linux/amd64

Does this issue reproduce with the latest release? Yes

What operating system and processor architecture are you using (go env)?

go env Output
$ go env
GO111MODULE=""
GOARCH="amd64"
GOBIN=""
GOCACHE="/home/anitschk/.cache/go-build"
GOENV="/home/anitschk/.config/go/env"
GOEXE=""
GOEXPERIMENT=""
GOFLAGS=""
GOHOSTARCH="amd64"
GOHOSTOS="linux"
GOINSECURE=""
GOMODCACHE="/local-ssd/anitschk/go/pkg/mod"
GONOPROXY="github.mathworks.com"
GONOSUMDB="github.mathworks.com,golang.dhcp"
GOOS="linux"
GOPATH="/local-ssd/anitschk/go/"
GOPRIVATE="github.mathworks.com,golang.dhcp"
GOPROXY="http://iat-go-proxy-prod-01:7000/go-proxy"
GOROOT="/mathworks/hub/3rdparty/R2022b/8461447/glnxa64/golang"
GOSUMDB="sum.golang.org"
GOTMPDIR=""
GOTOOLDIR="/mathworks/hub/3rdparty/R2022b/8461447/glnxa64/golang/pkg/tool/linux_amd64"
GOVCS=""
GOVERSION="go1.18.1"
GCCGO="gccgo"
GOAMD64="v1"
AR="ar"
CC="gcc"
CXX="g++"
CGO_ENABLED="1"
GOMOD="/dev/null"
GOWORK=""
CGO_CFLAGS="-g -O2"
CGO_CPPFLAGS=""
CGO_CXXFLAGS="-g -O2"
CGO_FFLAGS="-g -O2"
CGO_LDFLAGS="-g -O2"
PKG_CONFIG="pkg-config"
GOGCCFLAGS="-fPIC -m64 -pthread -fmessage-length=0 -fdebug-prefix-map=/tmp/go-build3452488547=/tmp/go-build -gno-record-gcc-switches"

What did you do?

See the following sample reproduction program

This sample program consists of two binaries: * cmd/wasm/main.go a simple binary that sends a request to GET http://localhost:9091, logging the result or the error and then exits. The key thing here is the GET has a context that times out after a second. * cmd/server/main.go which is simple web server that serves two ports: * :9090 serves up a simple index.html file that uses the above wasm binary * :9091 is "slow" and takes 10 seconds before responding with "ok" (so cmd/wasm/main.go will always timeout)

It is also worth noting that the index.html will console.log('WASM Done') once cmd/wasm/main.go has exited.

What did you expect to see?

When I run ./server and then visit http://localhost:9090 I expect the wasm binary to reach out to :9091 but timeout after 1 second. So it should show the following in the debugger console:

wasm_exec.js:22 Go Web Assembly
wasm_exec.js:22 Error Get "http://localhost:9091": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
(index):8 WASM Done

What did you see instead?

Instead I see the following error get thrown to the debugger console after the WASM program exits:

wasm_exec.js:22 Go Web Assembly
wasm_exec.js:22 Error Get "http://localhost:9091": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
(index):8 WASM Done
wasm_exec.js:536 Uncaught (in promise) Error: Go program has already exited
    at globalThis.Go._resume (wasm_exec.js:536:11)
    at wasm_exec.js:549:8

I can reproduce this when loading the webpage from both firefox 102.5.0esr and chrome 106.0.5249.119

Problem

The problem is when we do ac.Call("abort") it results in the fetch() promise being rejected.

See: https://developer.mozilla.org/en-US/docs/Web/API/AbortController

Note: When abort() is called, the fetch() promise rejects with a DOMException named AbortError.

So ac.Call("abort") aborts the fetch() and then Transport.RoundTrip exits imediatly. In the sample program the WASM main.go also exits right away. After it exits the fetch() promise gets a chance to be rejected which attempts to call back into the Go WASM to call the failure function. But the Go WASM has already exited, so we see the "Error: Go program has already exited"

Fix

One possible fix would be to replace ac.Call("abort") with

ac.Call("abort")
select {
    case <- respCh :
        resp.Body.Close()
    case <- errCh :
}

This way we wait for the fetch() promise to be rejected prior to exiting out of Transport.RoundTrip.

But why the case <- respCh part too? I am not entirely sure if this is necessary, however I could see there being a race where if ac.Call("abort") is called right when fetch() also successfully resolves the promise. If this is possible then respCh might be written to instead of errCh and if we don't also have case <- respCh then we would deadlock because errCh would never be written to.

I tried this fix out and it seems to resolve the issue for me in both firefox and chrome, not sure if there might be any other fallout though.

Comment From: seankhliao

cc @golang/wasm @neild

Comment From: anitschke

@golang/wasm @neild This is an issue that I reported two years ago and we still run into on occasion. I ran the reproduction steps in go 1.23.0 and it seems like this is still an issue. Would you all be ok with me opening a PR as per the contribution guide with the fixed I proposed? Or is there some further investigation that needs to be done by the Go team first?

Comment From: ianlancetaylor

I don't know anything about this issue, but in general it's always OK to contribute a patch. Thanks.

Comment From: kevinbarabash

I patched my project's local copy of wasm_exec.js:

Before

_resume() {
    if (this.exited) {
        throw new Error("Go program has already exited");
    }
    this._inst.exports.resume();
    if (this.exited) {
        this._resolveExitPromise();
    }
}

After

_resume() {
    if (!this.exited) {
        this._inst.exports.resume();
    }
    if (this.exited) {
        this._resolveExitPromise();
    }
}

Not sure if this this reasonable in all situations or not.

Comment From: gopherbot

Change https://go.dev/cl/680937 mentions this issue: net/http: fix RoundTrip context cancellation for js/wasm