tsconnect: harden JS bridge callbacks
Contain synchronous and asynchronous JS failures, make Drive cancellation fence response callbacks, and keep netmap snapshots current across peer changes and watcher restarts. Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func TestMakePromiseRejectsJSError(t *testing.T) {
|
||||
errValue := js.Global().Get("Error").New("boom")
|
||||
promise := makePromise(func() (any, error) {
|
||||
panic(js.Error{Value: errValue})
|
||||
})
|
||||
rejection := awaitPromise(t, promise)
|
||||
if rejection.Type() != js.TypeObject || rejection.Get("message").String() != "boom" {
|
||||
t.Fatalf("rejection = %v, want Error(boom)", rejection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveHandlerThrowReturns500(t *testing.T) {
|
||||
fn := evalJS(t, `(function() { throw new Error("boom") })`)
|
||||
fs := new(jsFileSystemForRemote)
|
||||
fs.setHandler(fn)
|
||||
recorder := httptest.NewRecorder()
|
||||
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil))
|
||||
if recorder.Code != 500 {
|
||||
t.Fatalf("status = %d, want 500", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCancellationDisablesCallbacks(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
close(started)
|
||||
return nil
|
||||
})
|
||||
defer handler.Release()
|
||||
fs := new(jsFileSystemForRemote)
|
||||
fs.setHandler(handler.Value)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
recorder := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil).WithContext(ctx))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("drive handler did not start")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("drive handler did not stop after cancellation")
|
||||
}
|
||||
|
||||
writeStarted := make(chan struct{})
|
||||
allowWrite := make(chan struct{})
|
||||
response := &driveResponse{
|
||||
w: recorder,
|
||||
doneCh: make(chan error, 1),
|
||||
live: true,
|
||||
testBeforeWriteCheck: func() {
|
||||
close(writeStarted)
|
||||
<-allowWrite
|
||||
},
|
||||
}
|
||||
chunk := js.Global().Get("Uint8Array").New(1)
|
||||
js.CopyBytesToJS(chunk, []byte("x"))
|
||||
writeReturned := make(chan struct{})
|
||||
go func() {
|
||||
response.write([]js.Value{chunk})
|
||||
close(writeReturned)
|
||||
}()
|
||||
select {
|
||||
case <-writeStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("response write did not start")
|
||||
}
|
||||
response.finish(context.Canceled, context.Canceled)
|
||||
close(allowWrite)
|
||||
select {
|
||||
case <-writeReturned:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("response callback did not finish")
|
||||
}
|
||||
if recorder.Body.Len() != 0 {
|
||||
t.Fatalf("late write changed response body to %q", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSStreamReaderErrorsAreSticky(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
reader string
|
||||
}{
|
||||
{"rejected", `({read() { return Promise.reject("boom") }, cancel() { return Promise.resolve() }})`},
|
||||
{"throwing", `({read() { throw new Error("boom") }, cancel() { return Promise.resolve() }})`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := &jsStreamReader{reader: evalJS(t, test.reader)}
|
||||
_, first := r.Read(make([]byte, 1))
|
||||
_, second := r.Read(make([]byte, 1))
|
||||
if first == nil || second == nil || first.Error() != second.Error() || !strings.Contains(first.Error(), "boom") {
|
||||
t.Fatalf("read errors = %v, %v; want matching boom errors", first, second)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSStreamReaderCloseErrors(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
reader string
|
||||
}{
|
||||
{"rejected", `({cancel() { return Promise.reject("boom") }})`},
|
||||
{"throwing", `({cancel() { throw new Error("boom") }})`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := &jsStreamReader{reader: evalJS(t, test.reader)}
|
||||
if err := r.Close(); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("Close error = %v, want boom", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSStreamReaderIgnoresDuplicateSettlement(t *testing.T) {
|
||||
reader := evalJS(t, `({
|
||||
read() {
|
||||
return {then(resolve, reject) { resolve({done: true}); reject("late") }}
|
||||
},
|
||||
cancel() {
|
||||
return {then(resolve, reject) { resolve(); reject("late") }}
|
||||
},
|
||||
})`)
|
||||
r := &jsStreamReader{reader: reader}
|
||||
if _, err := r.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("Read error = %v, want EOF", err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatalf("Close error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyRefreshesNetMap(t *testing.T) {
|
||||
online := true
|
||||
lastSeen := time.Now()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
n ipn.Notify
|
||||
want bool
|
||||
}{
|
||||
{"self", ipn.Notify{SelfChange: new(tailcfg.Node)}, true},
|
||||
{"peer changed", ipn.Notify{PeersChanged: []*tailcfg.Node{{}}}, true},
|
||||
{"peer removed", ipn.Notify{PeersRemoved: []tailcfg.NodeID{1}}, true},
|
||||
{"online patch", ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{Online: &online}}}, true},
|
||||
{"last seen patch", ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{LastSeen: &lastSeen}}}, false},
|
||||
{"state only", ipn.Notify{State: new(ipn.State)}, false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := notifyRefreshesNetMap(test.n); got != test.want {
|
||||
t.Fatalf("notifyRefreshesNetMap() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyWatchMask(t *testing.T) {
|
||||
want := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyPeerChanges | ipn.NotifyPeerPatches
|
||||
if jsIPNNotifyWatchMask != want {
|
||||
t.Fatalf("jsIPNNotifyWatchMask = %v, want %v", jsIPNNotifyWatchMask, want)
|
||||
}
|
||||
if jsIPNNotifyWatchMask&ipn.NotifyInProcessNoDisconnect != 0 {
|
||||
t.Fatal("jsIPNNotifyWatchMask must allow a lagging watcher to disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func evalJS(t *testing.T, source string) js.Value {
|
||||
t.Helper()
|
||||
value, err := callJSFunction(js.Global().Get("eval"), source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func awaitPromise(t *testing.T, promise js.Value) js.Value {
|
||||
t.Helper()
|
||||
type result struct {
|
||||
value js.Value
|
||||
rejected bool
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
resolve := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
ch <- result{value: args[0]}
|
||||
return nil
|
||||
})
|
||||
reject := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
ch <- result{value: args[0], rejected: true}
|
||||
return nil
|
||||
})
|
||||
defer resolve.Release()
|
||||
defer reject.Release()
|
||||
if _, err := callJSMethod(promise, "then", resolve, reject); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case result := <-ch:
|
||||
if !result.rejected {
|
||||
t.Fatal(errors.New("promise resolved; want rejection"))
|
||||
}
|
||||
return result.value
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("promise did not settle")
|
||||
return js.Undefined()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user