From e083dc6307b6593bdd44b219ffd21699d6f17fd7 Mon Sep 17 00:00:00 2001
From: Richard Musiol <mail@richard-musiol.de>
Date: Sun, 20 May 2018 00:56:36 +0200
Subject: [PATCH] runtime, sycall/js: add support for callbacks from JavaScript

This commit adds support for JavaScript callbacks back into
WebAssembly. This is experimental API, just like the rest of the
syscall/js package. The time package now also uses this mechanism
to properly support timers without resorting to a busy loop.

JavaScript code can call into the same entry point multiple times.
The new RUN register is used to keep track of the program's
run state. Possible values are: starting, running, paused and exited.
If no goroutine is ready any more, the scheduler can put the
program into the "paused" state and the WebAssembly code will
stop running. When a callback occurs, the JavaScript code puts
the callback data into a queue and then calls into WebAssembly
to allow the Go code to continue running.

Updates #18892
Updates #25506

Change-Id: Ib8701cfa0536d10d69bd541c85b0e2a754eb54fb
Reviewed-on: https://go-review.googlesource.com/114197
Reviewed-by: Austin Clements <austin@google.com>
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
---
 misc/wasm/wasm_exec.js               |  53 +++++++++-
 src/cmd/internal/obj/wasm/a.out.go   |   3 +
 src/cmd/internal/obj/wasm/anames.go  |   1 +
 src/cmd/internal/obj/wasm/wasmobj.go |  16 ++-
 src/cmd/link/internal/wasm/asm.go    |   1 +
 src/cmd/trace/annotations.go         |   4 +
 src/cmd/trace/annotations_test.go    |   6 ++
 src/cmd/trace/trace_test.go          |   2 +
 src/go/build/deps_test.go            |   2 +-
 src/runtime/lock_futex.go            |   6 ++
 src/runtime/lock_js.go               | 125 ++++++++++++++++++++---
 src/runtime/lock_sema.go             |   6 ++
 src/runtime/proc.go                  |  12 +++
 src/runtime/rt0_js_wasm.s            |  78 ++++++++++----
 src/runtime/sys_wasm.s               |  15 +--
 src/syscall/js/callback.go           | 145 +++++++++++++++++++++++++++
 src/syscall/js/js.go                 |   6 ++
 src/syscall/js/js_test.go            |  50 +++++++++
 18 files changed, 482 insertions(+), 49 deletions(-)
 create mode 100644 src/syscall/js/callback.go

diff --git a/misc/wasm/wasm_exec.js b/misc/wasm/wasm_exec.js
index de4cff7d2c0..ada6f0cd928 100755
--- a/misc/wasm/wasm_exec.js
+++ b/misc/wasm/wasm_exec.js
@@ -56,6 +56,8 @@
 					console.warn("exit code:", code);
 				}
 			};
+			this._callbackTimeouts = new Map();
+			this._nextCallbackTimeoutID = 1;
 
 			const mem = () => {
 				// The buffer may change when requesting more memory.
@@ -119,6 +121,7 @@
 				go: {
 					// func wasmExit(code int32)
 					"runtime.wasmExit": (sp) => {
+						this.exited = true;
 						this.exit(mem().getInt32(sp + 8, true));
 					},
 
@@ -142,6 +145,24 @@
 						mem().setInt32(sp + 16, (msec % 1000) * 1000000, true);
 					},
 
+					// func scheduleCallback(delay int64) int32
+					"runtime.scheduleCallback": (sp) => {
+						const id = this._nextCallbackTimeoutID;
+						this._nextCallbackTimeoutID++;
+						this._callbackTimeouts.set(id, setTimeout(
+							() => { this._resolveCallbackPromise(); },
+							getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early
+						));
+						mem().setInt32(sp + 16, id, true);
+					},
+
+					// func clearScheduledCallback(id int32)
+					"runtime.clearScheduledCallback": (sp) => {
+						const id = mem().getInt32(sp + 8, true);
+						clearTimeout(this._callbackTimeouts.get(id));
+						this._callbackTimeouts.delete(id);
+					},
+
 					// func getRandomData(r []byte)
 					"runtime.getRandomData": (sp) => {
 						crypto.getRandomValues(loadSlice(sp + 8));
@@ -269,7 +290,19 @@
 
 		async run(instance) {
 			this._inst = instance;
-			this._values = [undefined, null, global, this._inst.exports.mem]; // TODO: garbage collection
+			this._values = [ // TODO: garbage collection
+				undefined,
+				null,
+				global,
+				this._inst.exports.mem,
+				() => { // resolveCallbackPromise
+					if (this.exited) {
+						throw new Error("bad callback: Go program has already exited");
+					}
+					setTimeout(this._resolveCallbackPromise, 0); // make sure it is asynchronous
+				},
+			];
+			this.exited = false;
 
 			const mem = new DataView(this._inst.exports.mem.buffer)
 
@@ -303,7 +336,16 @@
 				offset += 8;
 			});
 
-			this._inst.exports.run(argc, argv);
+			while (true) {
+				const callbackPromise = new Promise((resolve) => {
+					this._resolveCallbackPromise = resolve;
+				});
+				this._inst.exports.run(argc, argv);
+				if (this.exited) {
+					break;
+				}
+				await callbackPromise;
+			}
 		}
 	}
 
@@ -318,9 +360,16 @@
 		go.env = process.env;
 		go.exit = process.exit;
 		WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => {
+			process.on("exit", () => { // Node.js exits if no callback is pending
+				if (!go.exited) {
+					console.error("error: all goroutines asleep and no JavaScript callback pending - deadlock!");
+					process.exit(1);
+				}
+			});
 			return go.run(result.instance);
 		}).catch((err) => {
 			console.error(err);
+			go.exited = true;
 			process.exit(1);
 		});
 	}
diff --git a/src/cmd/internal/obj/wasm/a.out.go b/src/cmd/internal/obj/wasm/a.out.go
index 9c04be2609a..6f882215ff4 100644
--- a/src/cmd/internal/obj/wasm/a.out.go
+++ b/src/cmd/internal/obj/wasm/a.out.go
@@ -219,6 +219,8 @@ const (
 	// However, it is not allowed to switch goroutines while inside of an ACALLNORESUME call.
 	ACALLNORESUME
 
+	ARETUNWIND
+
 	AMOVB
 	AMOVH
 	AMOVW
@@ -244,6 +246,7 @@ const (
 	REG_RET1
 	REG_RET2
 	REG_RET3
+	REG_RUN
 
 	// locals
 	REG_R0
diff --git a/src/cmd/internal/obj/wasm/anames.go b/src/cmd/internal/obj/wasm/anames.go
index 20d04446d08..745f0d773a9 100644
--- a/src/cmd/internal/obj/wasm/anames.go
+++ b/src/cmd/internal/obj/wasm/anames.go
@@ -180,6 +180,7 @@ var Anames = []string{
 	"F64ReinterpretI64",
 	"RESUMEPOINT",
 	"CALLNORESUME",
+	"RETUNWIND",
 	"MOVB",
 	"MOVH",
 	"MOVW",
diff --git a/src/cmd/internal/obj/wasm/wasmobj.go b/src/cmd/internal/obj/wasm/wasmobj.go
index ca09b3fa0b2..8498b407245 100644
--- a/src/cmd/internal/obj/wasm/wasmobj.go
+++ b/src/cmd/internal/obj/wasm/wasmobj.go
@@ -25,6 +25,7 @@ var Register = map[string]int16{
 	"RET1": REG_RET1,
 	"RET2": REG_RET2,
 	"RET3": REG_RET3,
+	"RUN":  REG_RUN,
 
 	"R0":  REG_R0,
 	"R1":  REG_R1,
@@ -487,7 +488,7 @@ func preprocess(ctxt *obj.Link, s *obj.LSym, newprog obj.ProgAlloc) {
 				p = appendp(p, AEnd) // end of Loop
 			}
 
-		case obj.ARET:
+		case obj.ARET, ARETUNWIND:
 			ret := *p
 			p.As = obj.ANOP
 
@@ -528,7 +529,14 @@ func preprocess(ctxt *obj.Link, s *obj.LSym, newprog obj.ProgAlloc) {
 			p = appendp(p, AI32Add)
 			p = appendp(p, ASet, regAddr(REG_SP))
 
-			// not switching goroutine, return 0
+			if ret.As == ARETUNWIND {
+				// function needs to unwind the WebAssembly stack, return 1
+				p = appendp(p, AI32Const, constAddr(1))
+				p = appendp(p, AReturn)
+				break
+			}
+
+			// not unwinding the WebAssembly stack, return 0
 			p = appendp(p, AI32Const, constAddr(0))
 			p = appendp(p, AReturn)
 		}
@@ -726,7 +734,7 @@ func assemble(ctxt *obj.Link, s *obj.LSym, newprog obj.ProgAlloc) {
 			}
 			reg := p.From.Reg
 			switch {
-			case reg >= REG_PC_F && reg <= REG_RET3:
+			case reg >= REG_PC_F && reg <= REG_RUN:
 				w.WriteByte(0x23) // get_global
 				writeUleb128(w, uint64(reg-REG_PC_F))
 			case reg >= REG_R0 && reg <= REG_F15:
@@ -743,7 +751,7 @@ func assemble(ctxt *obj.Link, s *obj.LSym, newprog obj.ProgAlloc) {
 			}
 			reg := p.To.Reg
 			switch {
-			case reg >= REG_PC_F && reg <= REG_RET3:
+			case reg >= REG_PC_F && reg <= REG_RUN:
 				w.WriteByte(0x24) // set_global
 				writeUleb128(w, uint64(reg-REG_PC_F))
 			case reg >= REG_R0 && reg <= REG_F15:
diff --git a/src/cmd/link/internal/wasm/asm.go b/src/cmd/link/internal/wasm/asm.go
index aadb0c3b6ef..b7beaa5d2fc 100644
--- a/src/cmd/link/internal/wasm/asm.go
+++ b/src/cmd/link/internal/wasm/asm.go
@@ -304,6 +304,7 @@ func writeGlobalSec(ctxt *ld.Link) {
 		I64, // 6: RET1
 		I64, // 7: RET2
 		I64, // 8: RET3
+		I32, // 9: RUN
 	}
 
 	writeUleb128(ctxt.Out, uint64(len(globalRegs))) // number of globals
diff --git a/src/cmd/trace/annotations.go b/src/cmd/trace/annotations.go
index c91f18ef6f2..96c109e0f25 100644
--- a/src/cmd/trace/annotations.go
+++ b/src/cmd/trace/annotations.go
@@ -1,3 +1,7 @@
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
 package main
 
 import (
diff --git a/src/cmd/trace/annotations_test.go b/src/cmd/trace/annotations_test.go
index 5d2b226b352..a9068d53c1b 100644
--- a/src/cmd/trace/annotations_test.go
+++ b/src/cmd/trace/annotations_test.go
@@ -1,3 +1,9 @@
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// +build !js
+
 package main
 
 import (
diff --git a/src/cmd/trace/trace_test.go b/src/cmd/trace/trace_test.go
index 852d745b290..9e90f50d4ba 100644
--- a/src/cmd/trace/trace_test.go
+++ b/src/cmd/trace/trace_test.go
@@ -2,6 +2,8 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+// +build !js
+
 package main
 
 import (
diff --git a/src/go/build/deps_test.go b/src/go/build/deps_test.go
index 9d667b61072..663d5246f89 100644
--- a/src/go/build/deps_test.go
+++ b/src/go/build/deps_test.go
@@ -139,7 +139,7 @@ var pkgDeps = map[string][]string{
 
 	// Operating system access.
 	"syscall":                           {"L0", "internal/race", "internal/syscall/windows/sysdll", "syscall/js", "unicode/utf16"},
-	"syscall/js":                        {"unsafe"},
+	"syscall/js":                        {"L0"},
 	"internal/syscall/unix":             {"L0", "syscall"},
 	"internal/syscall/windows":          {"L0", "syscall", "internal/syscall/windows/sysdll"},
 	"internal/syscall/windows/registry": {"L0", "syscall", "internal/syscall/windows/sysdll", "unicode/utf16"},
diff --git a/src/runtime/lock_futex.go b/src/runtime/lock_futex.go
index 18dd4629a0c..b590c4b92bd 100644
--- a/src/runtime/lock_futex.go
+++ b/src/runtime/lock_futex.go
@@ -229,3 +229,9 @@ func notetsleepg(n *note, ns int64) bool {
 	exitsyscall()
 	return ok
 }
+
+func pauseSchedulerUntilCallback() bool {
+	return false
+}
+
+func checkTimeouts() {}
diff --git a/src/runtime/lock_js.go b/src/runtime/lock_js.go
index 21e53d075e0..df321e51963 100644
--- a/src/runtime/lock_js.go
+++ b/src/runtime/lock_js.go
@@ -6,14 +6,22 @@
 
 package runtime
 
+import (
+	_ "unsafe"
+)
+
 // js/wasm has no support for threads yet. There is no preemption.
-// Waiting for a mutex or timeout is implemented as a busy loop
-// while allowing other goroutines to run.
+// Waiting for a mutex is implemented by allowing other goroutines
+// to run until the mutex gets unlocked.
 
 const (
 	mutex_unlocked = 0
 	mutex_locked   = 1
 
+	note_cleared = 0
+	note_woken   = 1
+	note_timeout = 2
+
 	active_spin     = 4
 	active_spin_cnt = 30
 	passive_spin    = 1
@@ -21,7 +29,7 @@ const (
 
 func lock(l *mutex) {
 	for l.key == mutex_locked {
-		Gosched()
+		mcall(gosched_m)
 	}
 	l.key = mutex_locked
 }
@@ -34,16 +42,31 @@ func unlock(l *mutex) {
 }
 
 // One-time notifications.
+
+type noteWithTimeout struct {
+	gp       *g
+	deadline int64
+}
+
+var (
+	notes            = make(map[*note]*g)
+	notesWithTimeout = make(map[*note]noteWithTimeout)
+)
+
 func noteclear(n *note) {
-	n.key = 0
+	n.key = note_cleared
 }
 
 func notewakeup(n *note) {
-	if n.key != 0 {
-		print("notewakeup - double wakeup (", n.key, ")\n")
+	// gp := getg()
+	if n.key == note_woken {
 		throw("notewakeup - double wakeup")
 	}
-	n.key = 1
+	cleared := n.key == note_cleared
+	n.key = note_woken
+	if cleared {
+		goready(notes[n], 1)
+	}
 }
 
 func notesleep(n *note) {
@@ -62,14 +85,88 @@ func notetsleepg(n *note, ns int64) bool {
 		throw("notetsleepg on g0")
 	}
 
-	deadline := nanotime() + ns
-	for {
-		if n.key != 0 {
-			return true
+	if ns >= 0 {
+		deadline := nanotime() + ns
+		delay := ns/1000000 + 1 // round up
+		if delay > 1<<31-1 {
+			delay = 1<<31 - 1 // cap to max int32
 		}
-		Gosched()
-		if ns >= 0 && nanotime() >= deadline {
-			return false
+
+		id := scheduleCallback(delay)
+		mp := acquirem()
+		notes[n] = gp
+		notesWithTimeout[n] = noteWithTimeout{gp: gp, deadline: deadline}
+		releasem(mp)
+
+		gopark(nil, nil, waitReasonSleep, traceEvNone, 1)
+
+		clearScheduledCallback(id) // note might have woken early, clear timeout
+		mp = acquirem()
+		delete(notes, n)
+		delete(notesWithTimeout, n)
+		releasem(mp)
+
+		return n.key == note_woken
+	}
+
+	for n.key != note_woken {
+		mp := acquirem()
+		notes[n] = gp
+		releasem(mp)
+
+		gopark(nil, nil, waitReasonZero, traceEvNone, 1)
+
+		mp = acquirem()
+		delete(notes, n)
+		releasem(mp)
+	}
+	return true
+}
+
+// checkTimeouts resumes goroutines that are waiting on a note which has reached its deadline.
+func checkTimeouts() {
+	now := nanotime()
+	for n, nt := range notesWithTimeout {
+		if n.key == note_cleared && now > nt.deadline {
+			n.key = note_timeout
+			goready(nt.gp, 1)
 		}
 	}
 }
+
+var waitingForCallback *g
+
+// sleepUntilCallback puts the current goroutine to sleep until a callback is triggered.
+// It is currently only used by the callback routine of the syscall/js package.
+//go:linkname sleepUntilCallback syscall/js.sleepUntilCallback
+func sleepUntilCallback() {
+	waitingForCallback = getg()
+	gopark(nil, nil, waitReasonZero, traceEvNone, 1)
+	waitingForCallback = nil
+}
+
+// pauseSchedulerUntilCallback gets called from the scheduler and pauses the execution
+// of Go's WebAssembly code until a callback is triggered. Then it checks for note timeouts
+// and resumes goroutines that are waiting for a callback.
+func pauseSchedulerUntilCallback() bool {
+	if waitingForCallback == nil && len(notesWithTimeout) == 0 {
+		return false
+	}
+
+	pause()
+	checkTimeouts()
+	if waitingForCallback != nil {
+		goready(waitingForCallback, 1)
+	}
+	return true
+}
+
+// pause pauses the execution of Go's WebAssembly code until a callback is triggered.
+func pause()
+
+// scheduleCallback tells the WebAssembly environment to trigger a callback after ms milliseconds.
+// It returns a timer id that can be used with clearScheduledCallback.
+func scheduleCallback(ms int64) int32
+
+// clearScheduledCallback clears a callback scheduled by scheduleCallback.
+func clearScheduledCallback(id int32)
diff --git a/src/runtime/lock_sema.go b/src/runtime/lock_sema.go
index 4cb0e84db38..6e01d70f757 100644
--- a/src/runtime/lock_sema.go
+++ b/src/runtime/lock_sema.go
@@ -282,3 +282,9 @@ func notetsleepg(n *note, ns int64) bool {
 	exitsyscall()
 	return ok
 }
+
+func pauseSchedulerUntilCallback() bool {
+	return false
+}
+
+func checkTimeouts() {}
diff --git a/src/runtime/proc.go b/src/runtime/proc.go
index e3549d367a9..36c74a1e8c8 100644
--- a/src/runtime/proc.go
+++ b/src/runtime/proc.go
@@ -263,6 +263,7 @@ func forcegchelper() {
 // Gosched yields the processor, allowing other goroutines to run. It does not
 // suspend the current goroutine, so execution resumes automatically.
 func Gosched() {
+	checkTimeouts()
 	mcall(gosched_m)
 }
 
@@ -282,6 +283,9 @@ func goschedguarded() {
 // Reasons should be unique and descriptive.
 // Do not re-use reasons, add new ones.
 func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
+	if reason != waitReasonSleep {
+		checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
+	}
 	mp := acquirem()
 	gp := mp.curg
 	status := readgstatus(gp)
@@ -2361,6 +2365,14 @@ stop:
 		return gp, false
 	}
 
+	// wasm only:
+	// Check if a goroutine is waiting for a callback from the WebAssembly host.
+	// If yes, pause the execution until a callback was triggered.
+	if pauseSchedulerUntilCallback() {
+		// A callback was triggered and caused at least one goroutine to wake up.
+		goto top
+	}
+
 	// Before we drop our P, make a snapshot of the allp slice,
 	// which can change underfoot once we no longer block
 	// safe-points. We don't need to snapshot the contents because
diff --git a/src/runtime/rt0_js_wasm.s b/src/runtime/rt0_js_wasm.s
index 2a878d990c5..e20f6236105 100644
--- a/src/runtime/rt0_js_wasm.s
+++ b/src/runtime/rt0_js_wasm.s
@@ -5,45 +5,81 @@
 #include "go_asm.h"
 #include "textflag.h"
 
+// The register RUN indicates the current run state of the program.
+// Possible values are:
+#define RUN_STARTING 0
+#define RUN_RUNNING 1
+#define RUN_PAUSED 2
+#define RUN_EXITED 3
+
 // _rt0_wasm_js does NOT follow the Go ABI. It has two WebAssembly parameters:
 // R0: argc (i32)
 // R1: argv (i32)
 TEXT _rt0_wasm_js(SB),NOSPLIT,$0
-	MOVD $runtime·wasmStack+m0Stack__size(SB), SP
+	Get RUN
+	I32Const $RUN_STARTING
+	I32Eq
+	If
+		MOVD $runtime·wasmStack+m0Stack__size(SB), SP
+
+		Get SP
+		Get R0 // argc
+		I64ExtendUI32
+		I64Store $0
 
-	Get SP
-	Get R0 // argc
-	I64ExtendUI32
-	I64Store $0
+		Get SP
+		Get R1 // argv
+		I64ExtendUI32
+		I64Store $8
 
-	Get SP
-	Get R1 // argv
-	I64ExtendUI32
-	I64Store $8
+		I32Const $runtime·rt0_go(SB)
+		I32Const $16
+		I32ShrU
+		Set PC_F
 
-	I32Const $runtime·rt0_go(SB)
-	I32Const $16
-	I32ShrU
-	Set PC_F
+		I32Const $RUN_RUNNING
+		Set RUN
+	Else
+		Get RUN
+		I32Const $RUN_PAUSED
+		I32Eq
+		If
+			I32Const $RUN_RUNNING
+			Set RUN
+		Else
+			Unreachable
+		End
+	End
 
-// Call the function for the current PC_F. Repeat until SP=0 indicates program end.
+// Call the function for the current PC_F. Repeat until RUN != 0 indicates pause or exit.
 // The WebAssembly stack may unwind, e.g. when switching goroutines.
 // The Go stack on the linear memory is then used to jump to the correct functions
 // with this loop, without having to restore the full WebAssembly stack.
 loop:
 	Loop
-		Get SP
-		I32Eqz
-		If
-			Return
-		End
-
 		Get PC_F
 		CallIndirect $0
 		Drop
 
-		Br loop
+		Get RUN
+		I32Const $RUN_RUNNING
+		I32Eq
+		BrIf loop
 	End
 
+	Return
+
+TEXT runtime·pause(SB), NOSPLIT, $0
+	I32Const $RUN_PAUSED
+	Set RUN
+	RETUNWIND
+
+TEXT runtime·exit(SB), NOSPLIT, $0-8
+	Call runtime·wasmExit(SB)
+	Drop
+	I32Const $RUN_EXITED
+	Set RUN
+	RETUNWIND
+
 TEXT _rt0_wasm_js_lib(SB),NOSPLIT,$0
 	UNDEF
diff --git a/src/runtime/sys_wasm.s b/src/runtime/sys_wasm.s
index 9a67ceec63e..3ca844a4c73 100644
--- a/src/runtime/sys_wasm.s
+++ b/src/runtime/sys_wasm.s
@@ -149,13 +149,6 @@ TEXT runtime·wasmTruncU(SB), NOSPLIT, $0-0
 	I64TruncUF64
 	Return
 
-TEXT runtime·exit(SB), NOSPLIT, $0-8
-	Call runtime·wasmExit(SB)
-	Drop
-	I32Const $0
-	Set SP
-	I32Const $1
-
 TEXT runtime·exitThread(SB), NOSPLIT, $0-0
 	UNDEF
 
@@ -194,6 +187,14 @@ TEXT ·walltime(SB), NOSPLIT, $0
 	CallImport
 	RET
 
+TEXT ·scheduleCallback(SB), NOSPLIT, $0
+	CallImport
+	RET
+
+TEXT ·clearScheduledCallback(SB), NOSPLIT, $0
+	CallImport
+	RET
+
 TEXT ·getRandomData(SB), NOSPLIT, $0
 	CallImport
 	RET
diff --git a/src/syscall/js/callback.go b/src/syscall/js/callback.go
new file mode 100644
index 00000000000..2c693240fad
--- /dev/null
+++ b/src/syscall/js/callback.go
@@ -0,0 +1,145 @@
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// +build js,wasm
+
+package js
+
+import "sync"
+
+var pendingCallbacks = Global.Get("Array").New()
+
+var makeCallbackHelper = Global.Call("eval", `
+	(function(id, pendingCallbacks, resolveCallbackPromise) {
+		return function() {
+			pendingCallbacks.push({ id: id, args: arguments });
+			resolveCallbackPromise();
+		};
+	})
+`)
+
+var makeEventCallbackHelper = Global.Call("eval", `
+	(function(preventDefault, stopPropagation, stopImmediatePropagation, fn) {
+		return function(event) {
+			if (preventDefault) {
+				event.preventDefault();
+			}
+			if (stopPropagation) {
+				event.stopPropagation();
+			}
+			if (stopImmediatePropagation) {
+				event.stopImmediatePropagation();
+			}
+			fn(event);
+		};
+	})
+`)
+
+var (
+	callbacksMu    sync.Mutex
+	callbacks             = make(map[uint32]func([]Value))
+	nextCallbackID uint32 = 1
+)
+
+// Callback is a Go function that got wrapped for use as a JavaScript callback.
+// A Callback can be passed to functions of this package that accept interface{},
+// for example Value.Set and Value.Call.
+type Callback struct {
+	id        uint32
+	enqueueFn Value // the JavaScript function that queues the callback for execution
+}
+
+// NewCallback returns a wrapped callback function. It can be passed to functions of this package
+// that accept interface{}, for example Value.Set and Value.Call.
+//
+// Invoking the callback in JavaScript will queue the Go function fn for execution.
+// This execution happens asynchronously on a special goroutine that handles all callbacks and preserves
+// the order in which the callbacks got called.
+// As a consequence, if one callback blocks this goroutine, other callbacks will not be processed.
+// A blocking callback should therefore explicitly start a new goroutine.
+//
+// Callback.Close must be called to free up resources when the callback will not be used any more.
+func NewCallback(fn func(args []Value)) Callback {
+	callbackLoopOnce.Do(func() {
+		go callbackLoop()
+	})
+
+	callbacksMu.Lock()
+	id := nextCallbackID
+	nextCallbackID++
+	callbacks[id] = fn
+	callbacksMu.Unlock()
+	return Callback{
+		id:        id,
+		enqueueFn: makeCallbackHelper.Invoke(id, pendingCallbacks, resolveCallbackPromise),
+	}
+}
+
+type EventCallbackFlag int
+
+const (
+	// PreventDefault can be used with NewEventCallback to call event.preventDefault synchronously.
+	PreventDefault EventCallbackFlag = 1 << iota
+	// StopPropagation can be used with NewEventCallback to call event.stopPropagation synchronously.
+	StopPropagation
+	// StopImmediatePropagation can be used with NewEventCallback to call event.stopImmediatePropagation synchronously.
+	StopImmediatePropagation
+)
+
+// NewEventCallback returns a wrapped callback function, just like NewCallback, but the callback expects to have
+// exactly one argument, the event. Depending on flags, it will synchronously call event.preventDefault,
+// event.stopPropagation and/or event.stopImmediatePropagation before queuing the Go function fn for execution.
+func NewEventCallback(flags EventCallbackFlag, fn func(event Value)) Callback {
+	c := NewCallback(func(args []Value) {
+		fn(args[0])
+	})
+	return Callback{
+		id: c.id,
+		enqueueFn: makeEventCallbackHelper.Invoke(
+			flags&PreventDefault != 0,
+			flags&StopPropagation != 0,
+			flags&StopImmediatePropagation != 0,
+			c,
+		),
+	}
+}
+
+func (c Callback) Close() {
+	callbacksMu.Lock()
+	delete(callbacks, c.id)
+	callbacksMu.Unlock()
+}
+
+var callbackLoopOnce sync.Once
+
+func callbackLoop() {
+	for {
+		sleepUntilCallback()
+		for {
+			cb := pendingCallbacks.Call("shift")
+			if cb == Undefined {
+				break
+			}
+
+			id := uint32(cb.Get("id").Int())
+			callbacksMu.Lock()
+			f, ok := callbacks[id]
+			callbacksMu.Unlock()
+			if !ok {
+				Global.Get("console").Call("error", "call to closed callback")
+				continue
+			}
+
+			argsObj := cb.Get("args")
+			args := make([]Value, argsObj.Length())
+			for i := range args {
+				args[i] = argsObj.Index(i)
+			}
+			f(args)
+		}
+	}
+}
+
+// sleepUntilCallback is defined in the runtime package
+func sleepUntilCallback()
diff --git a/src/syscall/js/js.go b/src/syscall/js/js.go
index 9332a262546..fdb58b2efa0 100644
--- a/src/syscall/js/js.go
+++ b/src/syscall/js/js.go
@@ -39,7 +39,11 @@ var (
 	// Global is the JavaScript global object, usually "window" or "global".
 	Global = Value{2}
 
+	// memory is the WebAssembly linear memory.
 	memory = Value{3}
+
+	// resolveCallbackPromise is a function that the callback helper uses to resume the execution of Go's WebAssembly code.
+	resolveCallbackPromise = Value{4}
 )
 
 var uint8Array = Global.Get("Uint8Array")
@@ -49,6 +53,8 @@ func ValueOf(x interface{}) Value {
 	switch x := x.(type) {
 	case Value:
 		return x
+	case Callback:
+		return x.enqueueFn
 	case nil:
 		return Null
 	case bool:
diff --git a/src/syscall/js/js_test.go b/src/syscall/js/js_test.go
index ca065e321d3..7d5b1a238ae 100644
--- a/src/syscall/js/js_test.go
+++ b/src/syscall/js/js_test.go
@@ -7,6 +7,7 @@
 package js_test
 
 import (
+	"fmt"
 	"syscall/js"
 	"testing"
 )
@@ -144,3 +145,52 @@ func TestNew(t *testing.T) {
 		t.Errorf("got %#v, want %#v", got, 42)
 	}
 }
+
+func TestCallback(t *testing.T) {
+	c := make(chan struct{})
+	cb := js.NewCallback(func(args []js.Value) {
+		if got := args[0].Int(); got != 42 {
+			t.Errorf("got %#v, want %#v", got, 42)
+		}
+		c <- struct{}{}
+	})
+	defer cb.Close()
+	js.Global.Call("setTimeout", cb, 0, 42)
+	<-c
+}
+
+func TestEventCallback(t *testing.T) {
+	for _, name := range []string{"preventDefault", "stopPropagation", "stopImmediatePropagation"} {
+		c := make(chan struct{})
+		var flags js.EventCallbackFlag
+		switch name {
+		case "preventDefault":
+			flags = js.PreventDefault
+		case "stopPropagation":
+			flags = js.StopPropagation
+		case "stopImmediatePropagation":
+			flags = js.StopImmediatePropagation
+		}
+		cb := js.NewEventCallback(flags, func(event js.Value) {
+			c <- struct{}{}
+		})
+		defer cb.Close()
+
+		event := js.Global.Call("eval", fmt.Sprintf("({ called: false, %s: function() { this.called = true; } })", name))
+		js.ValueOf(cb).Invoke(event)
+		if !event.Get("called").Bool() {
+			t.Errorf("%s not called", name)
+		}
+
+		<-c
+	}
+}
+
+func ExampleNewCallback() {
+	var cb js.Callback
+	cb = js.NewCallback(func(args []js.Value) {
+		fmt.Println("button clicked")
+		cb.Close() // close the callback if the button will not be clicked again
+	})
+	js.Global.Get("document").Call("getElementById", "myButton").Call("addEventListener", "click", cb)
+}
-- 
GitLab