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

$ go version
go version go1.23.6 linux/amd64

Does this issue reproduce with the latest release?

Yes (1.24.0)

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

go env Output
$ go env
GO111MODULE=''
GOARCH='amd64'
GOBIN=''
GOCACHE='/home/dadanhrn/.cache/go-build'
GOENV='/home/dadanhrn/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFLAGS=''
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMODCACHE='/home/dadanhrn/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/dadanhrn/go'
GOPRIVATE=''
GOPROXY='direct'
GOROOT='/usr/lib/golang'
GOSUMDB='off'
GOTMPDIR=''
GOTOOLCHAIN='local'
GOTOOLDIR='/usr/lib/golang/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.23.6'
GODEBUG=''
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/dadanhrn/.config/go/telemetry'
GCCGO='gccgo'
GOAMD64='v1'
AR='ar'
CC='gcc'
CXX='g++'
CGO_ENABLED='1'
GOMOD='/home/dadanhrn/Documents/Kuliah/trapeze-go_debug/go.mod'
GOWORK=''
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
PKG_CONFIG='pkg-config'
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build3041393783=/tmp/go-build -gno-record-gcc-switches'
GOROOT/bin/go version: go version go1.23.6 linux/amd64
GOROOT/bin/go tool compile -V: compile version go1.23.6
uname -sr: Linux 6.13.4-200.fc41.x86_64
/lib64/libc.so.6: GNU C Library (GNU libc) stable release version 2.40.
gdb --version: GNU gdb (Fedora Linux) 16.2-1.fc41

What did you do?

Example code: https://go.dev/play/p/j5I16KsARN_i The HTTP request can be replaced with a query to an SQL database or just any operation that takes context.Context. Set the timeout long enough so the operation can finish before the context timeouts. Notice the defer cancel() statement.

I compiled the program with GOOS=js GOARCH=wasm go build -o main.wasm and run the wasm binary on Node.js v22.13.0 with the provided wasm_exec.js. I added several print statements around these lines to help with debugging https://pastebin.com/ZTUnDYwn

Here's the Node.js script to run the wasm binary https://pastebin.com/CDNMS5Au

What did you expect to see?

###### SCHEDULE TIMEOUT EVENT 8 2951
Get "https://www.google.com": dial tcp: lookup www.google.com on [::1]:53: write udp 127.0.0.1:8->[::1]:53: write: Connection reset by peer
###### CLEAR TIMEOUT EVENT 8

I suppose the connection reset error is expected due to this https://cs.opensource.google/go/go/+/master:src/net/http/roundtrip_js.go;l=49-57?q=jsFetchDisabled&ss=go%2Fgo

What did you see instead?

###### SCHEDULE TIMEOUT EVENT 8 2951
Get "https://www.google.com": dial tcp: lookup www.google.com on [::1]:53: write udp 127.0.0.1:8->[::1]:53: write: Connection reset by peer
###### TRIGGER TIMEOUT EVENT 8
/home/dadanhrn/Documents/Kuliah/debug_timer/wasm_exec.js:560
                                throw new Error("Go program has already exited");
                                ^

Error: Go program has already exited
    at globalThis.Go._resume (/home/dadanhrn/Documents/Kuliah/debug_timer/wasm_exec.js:560:11)
    at Timeout._onTimeout (/home/dadanhrn/Documents/Kuliah/debug_timer/wasm_exec.js:286:14)
    at listOnTimeout (node:internal/timers:594:17)
    at process.processTimers (node:internal/timers:529:7)

Node.js v22.13.0

I suppose it makes sense that calling the cancel() function (through defer in this case) should also clear the timeout in the host Node.js runtime.

Comment From: gabyhelp

Related Issues

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

Comment From: dr2chase

@golang/wasm

Comment From: johanbrandhorst

Looks like maybe we're not cleaning up timers when the program exits? These are created by https://cs.opensource.google/go/go/+/master:src/runtime/lock_js.go;l=267;bpv=1;bpt=0?q=scheduleTimeoutEvent&ss=go%2Fgo.

Comment From: dadanhrn

Adding something like this in the scheduleTimeoutEvent implementation eliminates the crash, but it still leaves a dangling timeout which prevents the Node.js runtime from terminating immediately after the wasm call

"runtime.scheduleTimeoutEvent": (sp) => {
    sp >>>= 0;
    const id = this._nextCallbackTimeoutID;
    this._nextCallbackTimeoutID++;
    this._scheduledTimeouts.set(id, setTimeout(
        () => {
            if (this.exited) { // <== this
                return
            }
            this._resume();
            while (this._scheduledTimeouts.has(id)) {
                // for some reason Go failed to register the timeout event, log and try>
                // (temporary workaround for https://github.com/golang/go/issues/28975)
                console.warn("scheduleTimeoutEvent: missed timeout event");
                this._resume();
            }
        },
        getInt64(sp + 8),
    ));
    this.mem.setInt32(sp + 16, id, true);
},

Comment From: Zxilly

maybe we can cancel all _scheduledTimeouts in the "runtime.wasmExit"?

Comment From: Zxilly

diff --git a/lib/wasm/wasm_exec.js b/lib/wasm/wasm_exec.js
--- a/lib/wasm/wasm_exec.js (revision 6fb7bdc96d0398fab313586fba6fdc89cc14c679)
+++ b/lib/wasm/wasm_exec.js (date 1739646571863)
@@ -243,6 +243,10 @@
                        delete this._goRefCounts;
                        delete this._ids;
                        delete this._idPool;
+                       for (const id of this._scheduledTimeouts) {
+                           clearTimeout(id[1]);
+                       }
+                       delete this._scheduledTimeouts;
                        this.exit(code);
                    },

Comment From: dadanhrn

maybe we can cancel all _scheduledTimeouts in the "runtime.wasmExit"?

I confirm that this works. However, - are there any specific cases where the wasm program exits without calling runtime.wasmExit? (Other than when the host Node.js runtime gets interrupted) - I suppose this works as a garbage collection measure, which only suppresses the symptom rather than solving the root cause

Comment From: Zxilly

  1. runtime.wasmExit was called from runtime.exit, I think this will always happen.

  2. I agree that it's just a workaround, but since wasm is single-threaded, we rely on js to do the swapping of contexts and scheduling. I think a meaningful fix should wait until WASM Thread is implemented.

Comment From: gopherbot

Change https://go.dev/cl/685535 mentions this issue: wasm: clear remaining timeouts on exit