From ad7e822712a316ff441c77aab2cbf7b6f550ca3d Mon Sep 17 00:00:00 2001 From: gjija Date: Tue, 13 Oct 2026 12:48:05 +0200 Subject: [PATCH] web: release each Promise executor so written data can be freed makePromise registered its executor with js.FuncOf and never released it, so syscall/js kept every executor, the function it runs, and whatever that function captured for the life of the page. For a conn's write, that is a copy of every chunk written. In headless Chrome 155 on macOS, sending 256 MiB from the page in its usual 64 KiB writes left its WebAssembly memory at about 450 MiB; with this change it stays under 30 MiB. The Promise constructor calls the executor once, before it returns, so release it right after. The new TestWriteFreesPayload builds only for js/wasm. It fails without this change and passes with it: GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./web/ --- web/main_js.go | 5 +++ web/main_js_test.go | 93 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 web/main_js_test.go diff --git a/web/main_js.go b/web/main_js.go index 525f40f19..58f2d27bd 100644 --- a/web/main_js.go +++ b/web/main_js.go @@ -296,6 +296,11 @@ func makePromise(f func() (any, error)) js.Value { }() return nil }) + // The Promise constructor calls handler before returning and + // never again, so release it. Otherwise it stays registered + // forever, along with f and everything f captures, such as the + // bytes of every write. + defer handler.Release() return js.Global().Get("Promise").New(handler) } diff --git a/web/main_js_test.go b/web/main_js_test.go new file mode 100644 index 000000000..0492dd4ee --- /dev/null +++ b/web/main_js_test.go @@ -0,0 +1,93 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "errors" + "net" + "runtime" + "syscall/js" + "testing" + "time" +) + +// payloadConn is a net.Conn whose Write blocks until unblock is +// closed, keeps nothing, and sends on freed once the buffer it was +// given has been garbage collected. +type payloadConn struct { + net.Conn // nil; only Write is used + err error + unblock chan struct{} + freed chan bool +} + +func (c *payloadConn) Write(b []byte) (int, error) { + runtime.AddCleanup(&b[0], func(freed chan bool) { freed <- true }, c.freed) + <-c.unblock + if c.err != nil { + return 0, c.err + } + return len(b), nil +} + +// TestWriteFreesPayload checks that the Go copy of the bytes passed +// to a conn's write can be garbage collected once its Promise has +// settled, whether the write succeeded or failed. Otherwise a page +// sending a large file keeps all of it in memory. +func TestWriteFreesPayload(t *testing.T) { + for _, tt := range []struct { + name string + err error + }{ + {"ok", nil}, + {"error", errors.New("write failed")}, + } { + t.Run(tt.name, func(t *testing.T) { + c := &payloadConn{err: tt.err, unblock: make(chan struct{}), freed: make(chan bool, 1)} + conn := makeJSConn(c, 1, nil) + p := conn.Call("write", js.Global().Get("Uint8Array").New(64<<10)) + // The write is still blocked here, so its Promise + // settles after makePromise has returned. + close(c.unblock) + if got, want := await(p), tt.err == nil; got != want { + t.Errorf("Promise fulfilled = %v; want %v", got, want) + } + if !collected(c.freed) { + t.Error("written bytes still reachable after the Promise settled") + } + }) + } +} + +// collected reports whether freed receives a value within a few +// garbage collections. +func collected(freed chan bool) bool { + for range 10 { + runtime.GC() + select { + case <-freed: + return true + case <-time.After(10 * time.Millisecond): + } + } + return false +} + +// await blocks until the JavaScript Promise p settles and reports +// whether it was fulfilled. +func await(p js.Value) (fulfilled bool) { + done := make(chan bool, 1) + ok := js.FuncOf(func(this js.Value, args []js.Value) any { + done <- true + return nil + }) + defer ok.Release() + bad := js.FuncOf(func(this js.Value, args []js.Value) any { + done <- false + return nil + }) + defer bad.Release() + p.Call("then", ok, bad) + return <-done +}