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.
This commit is contained in:
Claude
2026-05-17 11:38:32 +00:00
parent b717d51bd9
commit 90435860b4
2 changed files with 29 additions and 9 deletions

View File

@@ -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

View File

@@ -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()
}
}