From 90435860b4fb2a04705fff7a51d625e8a30be351 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 11:38:32 +0000 Subject: [PATCH] wasm relay: harden WebTransport conn lifetime Two real bugs in the WASM WebTransport conn that would only show up under conn teardown: - awaitPromise released its js.Func callbacks on ctx cancellation, so a pending datagram read/write promise that later settled (closing the WebTransport rejects every in-flight promise) tried to invoke a released Go function and crashed the WASM module. The callbacks now release themselves exactly once from inside the settlement path; ctx cancellation only releases the Go-side waiter. - Read and Write returned ErrClosedByServer on transport errors but didn't mark the conn closed, so the relay client's next Read could block on a fresh reader.read() promise instead of short-circuiting. Both paths now call markClosed before returning the error. --- shared/relay/client/dialer/wt/conn_js.go | 10 +++++++-- shared/relay/client/dialer/wt/wt_js.go | 28 ++++++++++++++++++------ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/shared/relay/client/dialer/wt/conn_js.go b/shared/relay/client/dialer/wt/conn_js.go index 960dd31e8..aa34a099c 100644 --- a/shared/relay/client/dialer/wt/conn_js.go +++ b/shared/relay/client/dialer/wt/conn_js.go @@ -80,6 +80,11 @@ func (c *conn) Read(b []byte) (int, error) { if errors.Is(err, context.Canceled) { return 0, net.ErrClosed } + // Any other error from the datagram reader is terminal — the + // browser closes the underlying read stream on session failure. + // Mark the conn closed so the relay client's read loop sees a + // consistent net.ErrClosed on its next call. + c.markClosed() return 0, netErr.ErrClosedByServer } if v.Get("done").Bool() { @@ -92,8 +97,8 @@ func (c *conn) Read(b []byte) (int, error) { } n := val.Get("byteLength").Int() if n > len(b) { - // Datagrams shouldn't exceed the relay's MaxMessageSize (8 KB) so - // this branch is defensive — truncate rather than fail hard. + // Datagrams shouldn't exceed the relay's MaxMessageSize (8 KB) + // so this branch is defensive — truncate rather than fail hard. n = len(b) } js.CopyBytesToGo(b[:n], val) @@ -114,6 +119,7 @@ func (c *conn) Write(b []byte) (int, error) { if errors.Is(err, context.Canceled) { return 0, net.ErrClosed } + c.markClosed() return 0, netErr.ErrClosedByServer } return len(b), nil diff --git a/shared/relay/client/dialer/wt/wt_js.go b/shared/relay/client/dialer/wt/wt_js.go index beb780e48..c224b7abd 100644 --- a/shared/relay/client/dialer/wt/wt_js.go +++ b/shared/relay/client/dialer/wt/wt_js.go @@ -20,6 +20,7 @@ import ( "fmt" "net" "net/url" + "sync" "syscall/js" log "github.com/sirupsen/logrus" @@ -87,18 +88,31 @@ func prepareURL(address string) (string, error) { return parsed.String(), nil } -// awaitPromise bridges a JS Promise to a Go return. It respects ctx -// cancellation and releases its js.Func callbacks on the resolve/reject path. +// awaitPromise bridges a JS Promise to a Go return. +// +// js.Func release timing is the subtle part: if we released the callbacks on +// ctx cancellation, the JS engine would later try to invoke a dead Go +// function when the promise eventually settled (closing the WebTransport +// rejects every pending read/write promise) and crash the WASM module. So +// the callbacks always release themselves from inside the settlement path, +// regardless of whether the Go side is still listening. Ctx cancellation +// only releases the Go-side goroutine; the JS-side callbacks live until JS +// runs them once. func awaitPromise(ctx context.Context, p js.Value) (js.Value, error) { type res struct { val js.Value err error } ch := make(chan res, 1) - var thenFn, catchFn js.Func + var ( + thenFn, catchFn js.Func + releaseOnce sync.Once + ) release := func() { - thenFn.Release() - catchFn.Release() + releaseOnce.Do(func() { + thenFn.Release() + catchFn.Release() + }) } thenFn = js.FuncOf(func(_ js.Value, args []js.Value) interface{} { var v js.Value @@ -109,6 +123,7 @@ func awaitPromise(ctx context.Context, p js.Value) (js.Value, error) { case ch <- res{val: v}: default: } + release() return nil }) catchFn = js.FuncOf(func(_ js.Value, args []js.Value) interface{} { @@ -120,16 +135,15 @@ func awaitPromise(ctx context.Context, p js.Value) (js.Value, error) { case ch <- res{err: errors.New(msg)}: default: } + release() return nil }) p.Call("then", thenFn).Call("catch", catchFn) select { case r := <-ch: - release() return r.val, r.err case <-ctx.Done(): - release() return js.Value{}, ctx.Err() } }