Author SHA1 Message Date
codingetandCodex 705ba3e00b fix(tsconnect): address bridge review findings
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
2026-08-30 23:32:56 +00:00
codingetandCodex d3e8c23686 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>
2026-08-30 22:23:06 +00:00
codingetandClaude 0a6e85834a fix(tsconnect): keep racing the runtime while the IPN is built
Two lifecycle gaps in startIPN, both found in review.

The runtime was only raced until the readiness callback fired. Building
the backend happens after that, in a Go goroutine, and if the runtime
dies partway through, that goroutine dies with it and the promise it
would have settled never settles. startIPN hung forever instead of
rejecting. Race the factory too.

createIPN also handed callers the bridge's own shutdown, whose promise
races the runtime tearing itself down and may never settle, and which
the public type did not declare at all. Replace it in place with one that
resolves when the runtime has actually exited, and declare it. Replacing
rather than wrapping keeps the object the bridge built, instead of a copy
that only looks like it.

That also gives the exit handler the distinction it was missing: only an
exit the caller did not ask for is a panic now, so a deliberate shutdown
is no longer reported as one.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 15:30:28 +00:00
codingetandClaude 738fea52f8 fix(tsconnect): stop reporting a deliberate shutdown as a panic
The exit handler called onExit("Unexpected shutdown") whenever the Go
runtime exited. That was upstream's wording from when nothing could stop
the runtime, so every exit really was a panic. This fork added shutdown(),
so a clean teardown now reports itself as a crash to the panic handler
createIPN() hands to its callers.

Split the two cases. Before the IPN reaches the caller an exit is a
startup failure, and rejecting hands it back as an error rather than as a
side-channel callback. After that the caller holds the only shutdown
path, so report the exit without claiming it was unexpected.

Found in review of webnet/webnet#188.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 01:27:21 +00:00
codingetandClaude 3c63a95446 fix(tsconnect/wasm): let the loader exit a runtime whose IPN failed
A rejected newIPN left the runtime blocked in main with nothing able to
release it: the IPN that owns shutdown was never built. The runtime, its
goroutines, and its scheduler work stayed live for a startup that failed.

Hand the loader a terminate function alongside the factory. Closing the
channel inside newIPN would not work, because main would return and the
runtime exit before makePromise delivered the rejection; leaving it to
the loader keeps the rejection first and the exit second.

jsIPN now holds that function instead of the channel, so shutdown and
startup failure release the runtime through one path.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 01:06:16 +00:00
codingetandClaude 7ca658b028 refactor(tsconnect): move the fork's own TS onto the init callback
build-pkg runs tsc and dts-bundle-generator over src/, so the demo app
and the package entry point have to follow the runtime off the global
factory or the wasm build stops working.

Both now start the runtime through a shared startIPN helper, which
installs the callback, passes its name in through go.env, and races
readiness against the runtime exiting so a startup crash rejects instead
of hanging. The panic handler is wired to that exit rather than being
attached to a floating go.run() promise.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 01:01:41 +00:00
codingetandClaude c98a03dfa5 docs(tsconnect/wasm): record why the shutdown promise cannot be awaited
Closing shutdownCh lets main return and the runtime exit, which races
with makePromise invoking resolve, so the promise shutdown() hands back
may never settle. The JS loader already ignores it and awaits the
runtime's exit instead; say so here so the next reader does not take the
unsettled promise for a bug and rewire the callers.

Also note that the once and the channel now belong to the same instance.
webnet/webnet#206 left the shared-channel race to this branch, and the
one-IPN-per-runtime guard dissolves it.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 01:00:32 +00:00
codingetandClaude 00d16d6ae2 refactor(tsconnect/wasm): drop the unreachable LocalAPI socket
jsIPN.localAPI serves localapi.Handler in-process through an
httptest.ResponseRecorder, so nothing ever dialled the safesocket
listener that run() opened. The other in-tree callers of
safesocket.ConnectContext do not apply either: driveimpl's
FileSystemForRemote is replaced by jsFileSystemForRemote in this build,
and logpolicy's fallback is behind version.IsWindowsGUI.

Remove the listener, and with it ipnserver, whose only remaining use was
serving that listener. ipnserver.New builds a struct and SetLocalBackend
stores a pointer, so dropping both leaves LocalBackend untouched. Two
behaviours go with it: srv.Run's deferred lb.Shutdown, which made
shutdown call lb.Shutdown twice, and its localapi.Shutdown bus
subscription, which nothing in this build emits.

safesocket's generated per-listener name existed so several IPNs could
share one runtime. Each runtime has its own Go heap and its own memconn
registry, so the fixed name never conflicted between runtimes, and there
is now at most one IPN in each. Restoring the fixed name returns
safesocket_js.go to its upstream contents.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 00:59:47 +00:00
codingetandClaude 24ee15e524 feat(tsconnect/wasm): hand the bridge to JS through an init callback
The runtime published its bridge by setting globalThis.newIPN and the
loader read it back immediately after go.run(). That works only because
main() happens to reach the Set call before it blocks, so any package
init that waits on a channel or makes an async JS call would leave the
loader reading a global that is not there yet.

Take the name of a JS callback from go.env instead, and invoke it once
the bridge is built. Readiness is now the call itself, the runtime writes
nothing to the shared global scope, and two runtimes in one realm cannot
collide on a name.

The callback receives a factory that may be used once. shutdown() exits
the whole Go runtime, so a second IPN here would be torn down by the
first one's shutdown; an atomic guard rejects it rather than handing back
an instance that dies unpredictably.

The factory returns a promise, so failures building the engine, netstack,
or LocalBackend reject instead of calling log.Fatal and taking the
runtime down with no explanation for the caller.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 00:57:59 +00:00
14 changed files with 1016 additions and 694 deletions
-183
View File
@@ -1,183 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package driveprobe reports whether a peer currently exposes Taildrive
// shares, by asking its peerAPI rather than by inspecting ACL capabilities.
//
// PeerCapabilityTaildriveSharer only says a peer is allowed to share with us.
// The share list itself is only visible over WebDAV, so this package issues a
// Depth-1 PROPFIND against the peer's Taildrive root and looks for children.
// The peer applies our permissions before listing, so a child is a share we
// can actually reach.
//
// This lives outside the wasm package so it can be tested without syscall/js.
package driveprobe
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
"tailscale.com/types/logger"
)
const (
// drivePath is the peerAPI prefix taildrive is served under.
drivePath = "/v0/drive/"
// maxProbes bounds how many probes are in flight at once. Go under wasm
// runs on a single thread, so a high limit buys little and costs memory.
maxProbes = 8
// maxResponseBytes bounds the listing we are willing to read. A peer with
// a plausible number of shares is far below this.
maxResponseBytes = 1 << 20
// probeTimeout bounds a single probe in HasSharesMulti, so one peer that
// accepts the connection and then stalls cannot hold up the listing.
probeTimeout = 5 * time.Second
)
// propfindBody asks only for resourcetype: we care whether children exist,
// not what they are.
const propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
// HasShares reports whether the peer at peerAPIURL exposes at least one
// Taildrive share to us.
//
// A false result means the peer answered and listed nothing. An error means we
// could not find out — callers must not read it as "no shares".
func HasShares(ctx context.Context, c *http.Client, peerAPIURL string) (bool, error) {
u := strings.TrimSuffix(peerAPIURL, "/") + drivePath
req, err := http.NewRequestWithContext(ctx, "PROPFIND", u, strings.NewReader(propfindBody))
if err != nil {
return false, err
}
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.Do(req)
if err != nil {
return false, err
}
defer func() {
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes))
resp.Body.Close()
}()
// Anything other than 207 is the peer declining to list: taildrive off
// (404), no cap for us (403), or a handler that does not speak WebDAV.
if resp.StatusCode != http.StatusMultiStatus {
return false, fmt.Errorf("PROPFIND %s: %s", u, resp.Status)
}
return hasChild(io.LimitReader(resp.Body, maxResponseBytes), drivePath)
}
// hasChild reports whether a multistatus body lists anything besides the
// collection we asked about. It decodes as a stream and stops at the first
// child, so a peer with many shares costs no more than a peer with one.
//
// RFC 4918 §9.1 says a Depth-1 PROPFIND answers with the collection itself
// followed by its members, so anything after the first href is a share. The
// first href counts only if it is itself below root, which catches a peer that
// answers about a subtree rather than the collection we asked for.
//
// A peer that omits the collection entirely is not understood: ipnlocal strips
// the taildrive prefix before handing the request to the share server, so a
// real peer's members are named "/docs" rather than "/v0/drive/docs" and a lone
// member is indistinguishable from the collection. Such a peer is reported as
// having no shares, which is the safe direction for a positive filter.
func hasChild(body io.Reader, root string) (bool, error) {
dec := xml.NewDecoder(body)
var href string
inHref, first := false, true
for {
tok, err := dec.Token()
if err == io.EOF {
return false, nil
}
if err != nil {
return false, fmt.Errorf("parse multistatus: %w", err)
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Space == "DAV:" && t.Name.Local == "href" {
inHref, href = true, ""
}
case xml.CharData:
// Character data can arrive in several tokens for one element.
if inHref {
href += string(t)
}
case xml.EndElement:
if !inHref {
continue
}
inHref = false
if !first {
return true, nil
}
first = false
if isBelow(href, root) {
return true, nil
}
}
}
}
// isBelow reports whether href points below root. Peers may answer with an
// absolute URL or a path, percent-encoded and with or without a trailing
// slash, so compare cleaned paths rather than strings.
func isBelow(href, root string) bool {
u, err := url.Parse(strings.TrimSpace(href))
if err != nil {
return false
}
p := path.Clean("/" + strings.Trim(u.Path, "/"))
r := path.Clean("/" + strings.Trim(root, "/"))
if p == r || p == "/" {
return false
}
return r == "/" || strings.HasPrefix(p, r+"/")
}
// HasSharesMulti probes every URL and returns one result per input, in input
// order. A probe that fails is reported as false and logged: the caller is
// filtering to peers we positively confirmed, and one unreachable peer must
// not sink the rest.
func HasSharesMulti(ctx context.Context, c *http.Client, urls []string, logf logger.Logf) []bool {
out := make([]bool, len(urls))
var mu sync.Mutex
// Deliberately not errgroup.WithContext: a failing probe must not cancel
// its siblings.
var g errgroup.Group
g.SetLimit(maxProbes)
for i, u := range urls {
g.Go(func() error {
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
ok, err := HasShares(ctx, c, u)
if err != nil {
logf("driveprobe: %s: %v", u, err)
return nil
}
mu.Lock()
out[i] = ok
mu.Unlock()
return nil
})
}
g.Wait()
return out
}
-263
View File
@@ -1,263 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package driveprobe
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func multistatus(hrefs ...string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:">`)
for _, h := range hrefs {
fmt.Fprintf(&b, `<D:response><D:href>%s</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`, h)
}
b.WriteString(`</D:multistatus>`)
return b.String()
}
func TestHasChild(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
{"root only, prefix stripped", multistatus("/"), false},
{"root only, prefix kept", multistatus("/v0/drive/"), false},
{"root only, no trailing slash", multistatus("/v0/drive"), false},
{"one share, prefix stripped", multistatus("/", "/docs"), true},
{"one share, prefix kept", multistatus("/v0/drive/", "/v0/drive/docs"), true},
{"absolute urls", multistatus("http://100.1.2.3:1234/v0/drive/", "http://100.1.2.3:1234/v0/drive/docs"), true},
{"percent-encoded share name", multistatus("/v0/drive/", "/v0/drive/my%20share"), true},
{"unicode share name", multistatus("/v0/drive/", "/v0/drive/%E6%97%A5%E6%9C%AC"), true},
{"empty multistatus", multistatus(), false},
// The collection comes first per RFC 4918 §9.1, so anything after it
// is a share whatever the peer names it.
{"unrelated collection href, no members", multistatus("/somewhere/else/"), false},
{"unrelated collection href with a member", multistatus("/somewhere/else/", "/somewhere/else/docs"), true},
// A peer that omits the collection from a Depth-1 listing violates
// RFC 4918 §9.1, and once the taildrive prefix is stripped there is
// nothing left to tell its lone member apart from the collection. It
// loses the benefit of the doubt: hasShares excludes what it cannot
// confirm.
{"single member, collection omitted", multistatus("/docs"), false},
{"href split by an entity reference", multistatus("/v0/drive/", "/v0/drive/a&amp;b"), true},
{"empty href", multistatus("/v0/drive/", ""), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := hasChild(strings.NewReader(tt.body), drivePath)
if err != nil {
t.Fatalf("hasChild: %v", err)
}
if got != tt.want {
t.Errorf("hasChild = %v, want %v", got, tt.want)
}
})
}
}
func TestHasChildMalformed(t *testing.T) {
body := strings.TrimSuffix(multistatus("/v0/drive/", "/v0/drive/docs"), "</D:multistatus>")
// Truncation after a child href still answers the question.
got, err := hasChild(strings.NewReader(body), drivePath)
if err != nil {
t.Fatalf("hasChild: %v", err)
}
if !got {
t.Error("hasChild = false on a truncated body that already listed a share")
}
if _, err := hasChild(strings.NewReader("<D:multistatus"), drivePath); err == nil {
t.Error("hasChild on malformed XML: want error, got nil")
}
}
// serveDrive returns a server answering PROPFIND at the taildrive root with the
// given body and status, and records the requests it saw.
func serveDrive(t *testing.T, status int, body string) (*httptest.Server, *[]*http.Request) {
t.Helper()
var mu sync.Mutex
var reqs []*http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
reqs = append(reqs, r)
mu.Unlock()
w.WriteHeader(status)
io := []byte(body)
w.Write(io)
}))
t.Cleanup(srv.Close)
return srv, &reqs
}
func TestHasShares(t *testing.T) {
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("HasShares: %v", err)
}
if !got {
t.Error("HasShares = false, want true")
}
if len(*reqs) != 1 {
t.Fatalf("got %d requests, want 1", len(*reqs))
}
r := (*reqs)[0]
if r.Method != "PROPFIND" {
t.Errorf("method = %q, want PROPFIND", r.Method)
}
if r.URL.Path != drivePath {
t.Errorf("path = %q, want %q", r.URL.Path, drivePath)
}
if d := r.Header.Get("Depth"); d != "1" {
t.Errorf("Depth = %q, want 1", d)
}
}
func TestHasSharesTrailingSlashInPeerURL(t *testing.T) {
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/"))
if _, err := HasShares(context.Background(), srv.Client(), srv.URL+"/"); err != nil {
t.Fatalf("HasShares: %v", err)
}
if p := (*reqs)[0].URL.Path; p != drivePath {
t.Errorf("path = %q, want %q", p, drivePath)
}
}
func TestHasSharesNonMultiStatus(t *testing.T) {
// Taildrive disabled, or we hold no cap on that peer.
for _, status := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusOK} {
srv, _ := serveDrive(t, status, "")
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
if err == nil {
t.Errorf("status %d: want error, got nil", status)
}
if got {
t.Errorf("status %d: HasShares = true", status)
}
}
}
func TestHasSharesCancelled(t *testing.T) {
// The handler must also unblock on release: a client-side cancel does not
// reliably reach the server's request context, and Close waits for it.
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-release:
}
}))
defer srv.Close()
defer close(release)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := HasShares(ctx, srv.Client(), srv.URL); err == nil {
t.Error("want error on cancelled probe, got nil")
}
}
func TestHasSharesMultiOrderAndFailures(t *testing.T) {
withShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
noShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/"))
refused, _ := serveDrive(t, http.StatusNotFound, "")
urls := []string{noShares.URL, withShares.URL, refused.URL, "http://127.0.0.1:1/dead", withShares.URL}
want := []bool{false, true, false, false, true}
var logs atomic.Int32
got := HasSharesMulti(context.Background(), withShares.Client(), urls, func(string, ...any) {
logs.Add(1)
})
for i := range want {
if got[i] != want[i] {
t.Errorf("result[%d] = %v, want %v (%s)", i, got[i], want[i], urls[i])
}
}
// The 404 peer and the dead address are both reported, not swallowed.
if n := logs.Load(); n != 2 {
t.Errorf("logged %d probe failures, want 2", n)
}
}
func TestHasSharesMultiRunsInParallel(t *testing.T) {
const delay = 100 * time.Millisecond
var inFlight, peak atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inFlight.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(delay)
inFlight.Add(-1)
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(multistatus("/v0/drive/", "/v0/drive/docs")))
}))
defer srv.Close()
urls := make([]string, maxProbes)
for i := range urls {
urls[i] = srv.URL
}
start := time.Now()
got := HasSharesMulti(context.Background(), srv.Client(), urls, func(string, ...any) {})
elapsed := time.Since(start)
for i, ok := range got {
if !ok {
t.Errorf("result[%d] = false, want true", i)
}
}
if elapsed >= delay*time.Duration(len(urls)) {
t.Errorf("probes serialized: %v for %d probes of %v each", elapsed, len(urls), delay)
}
if peak.Load() < 2 {
t.Errorf("peak concurrency = %d, want >= 2", peak.Load())
}
}
func TestHasSharesMultiLimitsConcurrency(t *testing.T) {
var inFlight, peak atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inFlight.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(20 * time.Millisecond)
inFlight.Add(-1)
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(multistatus("/v0/drive/")))
}))
defer srv.Close()
urls := make([]string, maxProbes*3)
for i := range urls {
urls[i] = srv.URL
}
client := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 0}}
HasSharesMulti(context.Background(), client, urls, func(string, ...any) {})
if peak.Load() > maxProbes {
t.Errorf("peak concurrency = %d, want <= %d", peak.Load(), maxProbes)
}
}
+16 -13
View File
@@ -5,6 +5,7 @@ import "../wasm_exec"
import wasmUrl from "./main.wasm"
import { sessionStateStorage } from "../lib/js-state-store"
import { renderApp } from "./app"
import { startIPN } from "../lib/start-ipn"
async function main() {
const app = await renderApp()
@@ -13,23 +14,25 @@ async function main() {
fetch(`./dist/${wasmUrl}`),
go.importObject
)
// The Go process should never exit, if it does then it's an unhandled panic.
go.run(wasmInstance.instance).then(() =>
app.handleGoPanic("Unexpected shutdown")
)
const params = new URLSearchParams(window.location.search)
const authKey = params.get("authkey") ?? undefined
const ipn = newIPN({
// Persist IPN state in sessionStorage in development, so that we don't need
// to re-authorize every time we reload the page.
stateStorage: DEBUG ? sessionStateStorage : undefined,
// authKey allows for an auth key to be
// specified as a url param which automatically
// authorizes the client for use.
authKey: DEBUG ? authKey : undefined,
})
// The Go process should never exit, if it does then it's an unhandled panic.
const ipn = await startIPN(
go,
wasmInstance.instance,
{
// Persist IPN state in sessionStorage in development, so that we don't
// need to re-authorize every time we reload the page.
stateStorage: DEBUG ? sessionStateStorage : undefined,
// authKey allows for an auth key to be
// specified as a url param which automatically
// authorizes the client for use.
authKey: DEBUG ? authKey : undefined,
},
(reason) => app.handleGoPanic(reason)
)
app.runWithIPN(ipn)
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
/**
* Starts a Go runtime and returns the single IPN it owns.
*
* The runtime does not publish its bridge on a global. It reads the name of a
* callback from its environment and invokes it once the bridge is ready, so
* this resolves on an explicit signal rather than on the Go scheduler having
* run far enough. The name is generated per runtime, so several runtimes can
* start in one page without racing each other.
*
* The returned IPN replaces the bridge's shutdown() with one that resolves when
* the runtime has actually exited. The raw promise cannot be awaited: it races
* with the runtime tearing itself down, so it may never settle.
*/
export async function startIPN(
go: Go,
instance: WebAssembly.Instance,
config: IPNConfig,
onExit: (reason: string) => void
): Promise<IPN> {
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
const globals = globalThis as Record<string, unknown>
const ready = new Promise<[NewIPN, Terminate]>((resolve) => {
globals[name] = (newIPN: NewIPN, terminate: Terminate) => {
delete globals[name]
resolve([newIPN, terminate])
}
})
go.env[INIT_CALLBACK_ENV] = name
// Only an exit the caller did not ask for is worth reporting. Before the
// handover every exit is a startup failure and rejecting hands it back as an
// error; afterwards, only an exit that shutdown() did not cause is a panic.
let stopping = false
const exited: Promise<void> = go.run(instance).then(() => {
delete globals[name]
if (!stopping) onExit("Unexpected shutdown")
})
// Reject alongside it, so an exit during startup fails the awaits below
// instead of leaving them pending forever.
const failed: Promise<never> = exited.then(() => {
throw new Error("Go runtime exited before the IPN was ready")
})
const [newIPN, terminate] = await Promise.race([ready, failed])
let ipn: IPN
try {
// Keep racing the runtime: building the backend runs in a Go goroutine, and
// if the runtime dies partway that goroutine dies with it and its promise
// never settles.
ipn = await Promise.race([newIPN(config), failed])
} catch (err) {
// Nothing was built, so nothing can shut the runtime down. Exit it here and
// wait for it, or the page keeps a blocked runtime for a failed startup.
// Calling terminate on an already-exited runtime does nothing.
stopping = true
terminate()
await exited
throw err
}
// Replace shutdown in place rather than wrapping the object: the bridge hands
// back a plain map of Go-backed functions, and copying it would leave the
// caller with something that only looks like the IPN.
const rawShutdown = ipn.shutdown.bind(ipn)
ipn.shutdown = async () => {
stopping = true
try {
void rawShutdown()
} catch {
// The runtime may already be gone, in which case there is nothing to ask
// and the await below returns immediately.
}
await exited
}
return ipn
}
type NewIPN = (config: IPNConfig) => Promise<IPN>
type Terminate = () => void
/** Must match initCallbackEnv in wasm_js.go. */
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
+2 -5
View File
@@ -7,6 +7,7 @@
/// <reference path="../types/wasm_js.d.ts" />
import "../wasm_exec"
import { startIPN } from "../lib/start-ipn"
import wasmURL from "./main.wasm"
/**
@@ -30,11 +31,7 @@ export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
go.importObject
)
// The Go process should never exit, if it does then it's an unhandled panic.
go.run(wasmInstance.instance).then(() =>
config.panicHandler("Unexpected shutdown")
)
return newIPN(config)
return startIPN(go, wasmInstance.instance, config, config.panicHandler)
}
export { runSSHSession } from "../lib/ssh"
+8 -2
View File
@@ -7,12 +7,18 @@
*/
declare global {
function newIPN(config: IPNConfig): IPN
interface IPN {
run(callbacks: IPNCallbacks): void
login(): void
logout(): void
/**
* Tears down the backend and exits the Go runtime that owns this IPN.
*
* The promise the bridge returns races with the runtime exiting and may
* never settle; startIPN replaces it with one that resolves when the
* runtime has actually gone.
*/
shutdown(): Promise<void>
ssh(
host: string,
username: string,
+326
View File
@@ -0,0 +1,326 @@
// 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) {
type handlerArgs struct {
req js.Value
res js.Value
}
started := make(chan handlerArgs, 1)
deferred := evalJS(t, `(() => {
let resolve
const promise = new Promise(r => { resolve = r })
return {promise, resolve}
})()`)
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
started <- handlerArgs{req: args[0], res: args[1]}
return deferred.Get("promise")
})
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)
}()
var jsArgs handlerArgs
select {
case jsArgs = <-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")
}
readResult := jsArgs.req.Call("readBodyChunk")
if readResult.Type() != js.TypeObject {
t.Fatalf("readBodyChunk after cancellation returned %s, want Promise", readResult.Type())
}
if result := awaitFulfilledPromise(t, readResult); !result.IsNull() {
t.Fatalf("readBodyChunk after cancellation = %v, want null", result)
}
lateChunk := js.Global().Get("Uint8Array").New(1)
js.CopyBytesToJS(lateChunk, []byte("x"))
jsArgs.res.Call("write", lateChunk)
if recorder.Body.Len() != 0 {
t.Fatalf("late JS write changed response body to %q", recorder.Body.String())
}
deferred.Call("resolve")
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 TestDriveHandlerFulfillmentCompletesRequest(t *testing.T) {
handler := evalJS(t, `(function() { return Promise.resolve() })`)
fs := new(jsFileSystemForRemote)
fs.setHandler(handler)
recorder := httptest.NewRecorder()
done := make(chan struct{})
go func() {
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil))
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("fulfilled drive handler did not complete request")
}
}
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 TestJSStreamReaderCloseTimesOut(t *testing.T) {
r := &jsStreamReader{
reader: evalJS(t, `({cancel() { return new Promise(() => {}) }})`),
testCancelTimeout: 10 * time.Millisecond,
}
started := time.Now()
if err := r.Close(); err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("Close error = %v, want timeout", err)
}
if elapsed := time.Since(started); elapsed >= 250*time.Millisecond {
t.Fatalf("Close took %v, want less than 250ms", elapsed)
}
if _, err := r.Read(make([]byte, 1)); err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("Read error after Close timeout = %v, want sticky timeout", 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()
}
}
func awaitFulfilledPromise(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.Fatalf("promise rejected with %v; want fulfillment", result.value)
}
return result.value
case <-time.After(time.Second):
t.Fatal("promise did not settle")
return js.Undefined()
}
}
+132 -86
View File
@@ -6,17 +6,14 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"sync"
"syscall/js"
"tailscale.com/cmd/tsconnect/driveprobe"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
@@ -33,6 +30,73 @@ type jsFileSystemForRemote struct {
fn js.Value
}
type driveResponse struct {
mu sync.Mutex
w http.ResponseWriter
doneCh chan error
live bool
responseStarted bool
testBeforeWriteCheck func()
}
func (r *driveResponse) isLive() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.live
}
func (r *driveResponse) writeHead(args []js.Value) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.live || len(args) < 1 {
return
}
status := args[0].Int()
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
for k, vs := range jsHeadersToGo(args[1]) {
for _, v := range vs {
r.w.Header().Add(k, v)
}
}
}
r.w.WriteHeader(status)
r.responseStarted = true
}
func (r *driveResponse) write(args []js.Value) {
if r.testBeforeWriteCheck != nil {
r.testBeforeWriteCheck()
}
r.mu.Lock()
defer r.mu.Unlock()
if !r.live || len(args) < 1 {
return
}
data := args[0]
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
r.responseStarted = true
if _, err := r.w.Write(buf); err != nil {
select {
case r.doneCh <- err:
default:
}
return
}
if f, ok := r.w.(http.Flusher); ok {
f.Flush()
}
}
func (r *driveResponse) finish(resultErr, contextErr error) {
r.mu.Lock()
defer r.mu.Unlock()
if resultErr != nil && contextErr == nil && !r.responseStarted {
http.Error(r.w, "drive handler failed", http.StatusInternalServerError)
}
r.live = false
}
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
fs.mu.Lock()
fs.fn = fn
@@ -53,7 +117,8 @@ func (fs *jsFileSystemForRemote) Close() error { return nil }
// response body back via write()/end() callbacks, so no full-body buffering
// occurs regardless of file size.
//
// The call blocks until JS calls end() (or a write error occurs).
// The call blocks until JS calls end(), a write or handler error occurs, or
// the request context is cancelled.
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
) {
@@ -61,14 +126,21 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
fn := fs.fn
fs.mu.RUnlock()
if fn.IsUndefined() || fn.IsNull() {
if fn.Type() != js.TypeFunction {
http.NotFound(w, r)
return
}
response := &driveResponse{w: w, doneCh: make(chan error, 1), live: true}
// readBodyChunk is exposed to JS as req.readBodyChunk().
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
if !response.isLive() {
return makePromise(func() (any, error) {
return js.Null(), nil
})
}
return makePromise(func() (any, error) {
buf := make([]byte, 65536)
n, err := r.Body.Read(buf)
@@ -84,62 +156,54 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
})
})
// doneCh receives nil when JS calls end(), or a write error if Write fails.
doneCh := make(chan error, 1)
// writeHead sets response headers and status code. Must be called before write().
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
status := args[0].Int()
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
for k, vs := range jsHeadersToGo(args[1]) {
for _, v := range vs {
w.Header().Add(k, v)
}
}
}
w.WriteHeader(status)
response.writeHead(args)
return nil
})
// write streams a single response body chunk to the client.
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
data := args[0]
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
if _, werr := w.Write(buf); werr != nil {
select {
case doneCh <- werr:
default:
}
return nil
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
response.write(args)
return nil
})
// end signals that the response is complete.
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
select {
case doneCh <- nil:
case response.doneCh <- nil:
default:
}
return nil
})
defer func() {
readBodyChunk.Release()
writeHead.Release()
write.Release()
end.Release()
}()
var fulfilled, rejected js.Func
var handlerSettled sync.Once
settleHandler := func(err error) {
handlerSettled.Do(func() {
select {
case response.doneCh <- err:
default:
}
readBodyChunk.Release()
writeHead.Release()
write.Release()
end.Release()
fulfilled.Release()
rejected.Release()
})
}
fulfilled = js.FuncOf(func(_ js.Value, _ []js.Value) any {
settleHandler(nil)
return nil
})
rejected = js.FuncOf(func(_ js.Value, args []js.Value) any {
err := errors.New("JavaScript drive handler rejected")
if len(args) > 0 && args[0].Type() == js.TypeString {
err = fmt.Errorf("JavaScript drive handler rejected: %s", args[0].String())
}
settleHandler(err)
return nil
})
jsReq := map[string]any{
"method": r.Method,
@@ -154,11 +218,28 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
"end": end,
}
fn.Invoke(jsReq, jsRes, drivePermsToJS(perms))
result, handlerErr := callJSFunction(fn, jsReq, jsRes, drivePermsToJS(perms))
if handlerErr == nil {
if hasThen, err := hasJSFunctionProperty(result, "then"); err != nil {
handlerErr = err
} else if hasThen {
_, handlerErr = callJSMethod(result, "then", fulfilled, rejected)
} else {
settleHandler(nil)
}
}
if handlerErr != nil {
settleHandler(handlerErr)
}
// Block this goroutine until JS calls end() or a write error occurs.
// The Go WASM scheduler yields back to JS while we wait.
<-doneCh
var resultErr error
select {
case resultErr = <-response.doneCh:
case <-r.Context().Done():
resultErr = r.Context().Err()
}
response.finish(resultErr, r.Context().Err())
}
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
@@ -231,37 +312,11 @@ func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
return nil
})
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
var hasShares bool
if len(args) > 0 && !args[0].IsUndefined() && !args[0].IsNull() {
if v := args[0].Get("hasShares"); v.Type() == js.TypeBoolean {
hasShares = v.Bool()
}
}
return i.listDrivePeers(hasShares)
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return i.listDrivePeers()
})
}
// filterPeersWithShares keeps only the peers whose Taildrive endpoint lists at
// least one share for us, preserving the order of the input.
func (i *jsIPN) filterPeersWithShares(peers []jsDrivePeer) []jsDrivePeer {
urls := make([]string, len(peers))
for n, p := range peers {
urls[n] = p.PeerAPIURL
}
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
results := driveprobe.HasSharesMulti(context.Background(), client, urls, log.Printf)
kept := make([]jsDrivePeer, 0, len(peers))
for n, p := range peers {
if results[n] {
kept = append(kept, p)
}
}
return kept
}
type jsDrivePeer struct {
Name string `json:"name"`
PeerAPIURL string `json:"peerAPIURL"`
@@ -277,12 +332,7 @@ type jsDrivePeer struct {
//
// The cap means a peer is allowed to share with us, not that it currently
// exposes any share, so the result is a superset of the peers with shares.
//
// hasShares narrows it to peers we confirmed are exposing at least one share,
// at the cost of one peerAPI round-trip per candidate (run in parallel). It is
// a positive filter: a peer we could not reach is left out, which is not the
// same as knowing it has no shares.
func (i *jsIPN) listDrivePeers(hasShares bool) js.Value {
func (i *jsIPN) listDrivePeers() js.Value {
return makePromise(func() (any, error) {
if !i.lb.DriveAccessEnabled() {
return "[]", nil
@@ -335,10 +385,6 @@ func (i *jsIPN) listDrivePeers(hasShares bool) js.Value {
})
}
if hasShares && len(peers) > 0 {
peers = i.filterPeersWithShares(peers)
}
b, err := json.Marshal(peers)
if err != nil {
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
+1 -1
View File
@@ -20,7 +20,7 @@ func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil }
func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {}
// listDrivePeers returns an empty list when the drive feature is omitted.
func (i *jsIPN) listDrivePeers(_ bool) js.Value {
func (i *jsIPN) listDrivePeers() js.Value {
return makePromise(func() (any, error) {
return "[]", nil
})
+138 -10
View File
@@ -17,6 +17,7 @@ import (
"net/http"
"net/url"
"os"
"sync"
"syscall/js"
"time"
@@ -109,7 +110,10 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declare
return nil, fmt.Errorf("bogus peer URL: %w", err)
}
reader := stream.Call("getReader")
reader, err := callJSMethod(stream, "getReader")
if err != nil {
return nil, err
}
body := &jsStreamReader{reader: reader}
outgoing := ipn.OutgoingFile{
@@ -242,40 +246,100 @@ func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) {
// ReadableStreamDefaultReader. Each Read call awaits one reader.read() Promise,
// using the channel+FuncOf pattern so Go blocks until JS delivers the chunk.
type jsStreamReader struct {
reader js.Value
buf []byte
done bool
mu sync.Mutex
reader js.Value
buf []byte
done bool
err error
testCancelTimeout time.Duration
}
const jsStreamCancelTimeout = time.Second
func (r *jsStreamReader) Read(p []byte) (int, error) {
r.mu.Lock()
if r.err != nil {
err := r.err
r.mu.Unlock()
return 0, err
}
if r.done {
r.mu.Unlock()
return 0, io.EOF
}
if len(r.buf) > 0 {
n := copy(p, r.buf)
r.buf = r.buf[n:]
r.mu.Unlock()
return n, nil
}
r.mu.Unlock()
type chunkResult struct {
data []byte
done bool
err error
}
ch := make(chan chunkResult, 1)
settle := func(result chunkResult) {
select {
case ch <- result:
default:
}
}
thenFn := js.FuncOf(func(this js.Value, args []js.Value) any {
defer func() {
if recovered := recover(); recovered != nil {
settle(chunkResult{err: recoveredJSError(recovered)})
}
}()
if len(args) == 0 || args[0].Type() != js.TypeObject {
settle(chunkResult{err: errors.New("JavaScript stream read returned an invalid result")})
return nil
}
result := args[0]
if result.Get("done").Bool() {
ch <- chunkResult{done: true}
done := result.Get("done")
if done.Type() != js.TypeBoolean {
settle(chunkResult{err: errors.New("JavaScript stream read result has no boolean done property")})
return nil
}
if done.Bool() {
settle(chunkResult{done: true})
} else {
value := result.Get("value")
uint8Array := js.Global().Get("Uint8Array")
if value.Type() != js.TypeObject || uint8Array.Type() != js.TypeFunction || !value.InstanceOf(uint8Array) {
settle(chunkResult{err: errors.New("JavaScript stream read result is not a Uint8Array")})
return nil
}
b := make([]byte, value.Get("byteLength").Int())
js.CopyBytesToGo(b, value)
ch <- chunkResult{data: b}
settle(chunkResult{data: b})
}
return nil
})
rejectFn := js.FuncOf(func(this js.Value, args []js.Value) any {
err := errors.New("JavaScript stream read rejected")
if len(args) > 0 && args[0].Type() == js.TypeString {
err = fmt.Errorf("JavaScript stream read rejected: %s", args[0].String())
}
settle(chunkResult{err: err})
return nil
})
defer thenFn.Release()
r.reader.Call("read").Call("then", thenFn)
defer rejectFn.Release()
promise, err := callJSMethod(r.reader, "read")
if err != nil {
return 0, r.setError(err)
}
if _, err := callJSMethod(promise, "then", thenFn, rejectFn); err != nil {
return 0, r.setError(err)
}
result := <-ch
if result.err != nil {
return 0, r.setError(result.err)
}
r.mu.Lock()
defer r.mu.Unlock()
if result.done {
r.done = true
return 0, io.EOF
@@ -286,8 +350,72 @@ func (r *jsStreamReader) Read(p []byte) (int, error) {
}
func (r *jsStreamReader) Close() error {
r.reader.Call("cancel")
return nil
r.mu.Lock()
priorErr := r.err
r.mu.Unlock()
promise, err := callJSMethod(r.reader, "cancel")
if err != nil {
return r.setError(err)
}
if hasThen, err := hasJSFunctionProperty(promise, "then"); err != nil {
return r.setError(err)
} else if !hasThen {
r.mu.Lock()
r.done = true
r.mu.Unlock()
return priorErr
}
resultCh := make(chan error, 1)
settle := func(err error) {
select {
case resultCh <- err:
default:
}
}
resolveFn := js.FuncOf(func(this js.Value, args []js.Value) any {
settle(nil)
return nil
})
rejectFn := js.FuncOf(func(this js.Value, args []js.Value) any {
err := errors.New("JavaScript stream cancel rejected")
if len(args) > 0 && args[0].Type() == js.TypeString {
err = fmt.Errorf("JavaScript stream cancel rejected: %s", args[0].String())
}
settle(err)
return nil
})
defer resolveFn.Release()
defer rejectFn.Release()
if _, err := callJSMethod(promise, "then", resolveFn, rejectFn); err != nil {
return r.setError(err)
}
timeout := jsStreamCancelTimeout
if r.testCancelTimeout > 0 {
timeout = r.testCancelTimeout
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-resultCh:
if err != nil {
return r.setError(err)
}
case <-timer.C:
return r.setError(errors.New("JavaScript stream cancel timed out"))
}
r.mu.Lock()
r.done = true
r.mu.Unlock()
return priorErr
}
func (r *jsStreamReader) setError(err error) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.err == nil {
r.err = err
}
return r.err
}
// jsReadableStream wraps rc in a pull-based JS ReadableStream. Each pull call
+291 -123
View File
@@ -24,9 +24,11 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall/js"
"time"
@@ -42,7 +44,6 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/localapi"
"tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy"
@@ -51,7 +52,6 @@ import (
"tailscale.com/net/netns"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/types/logid"
@@ -64,21 +64,62 @@ import (
// ControlURL defines the URL to be used for connection to Control.
var ControlURL = ipn.DefaultControlURL
// initCallbackEnv names the JS global holding the callback that this runtime
// hands its bridge to. The loader generates a name, installs the callback under
// it, and passes the name in through go.env before starting the runtime.
//
// Publishing the bridge through a caller-supplied callback rather than a fixed
// global means the loader never has to guess when the Go scheduler has run far
// enough to expose it, and two runtimes in one JS realm cannot collide on the
// name.
const initCallbackEnv = "TSCONNECT_INIT_CALLBACK"
func main() {
name := os.Getenv(initCallbackEnv)
if name == "" {
log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv)
}
callback := js.Global().Get(name)
if callback.Type() != js.TypeFunction {
log.Fatalf("globalThis[%q] is not a function", name)
}
shutdownCh := make(chan struct{})
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Fatal("Usage: newIPN(config)")
return nil
}
return newIPN(args[0], shutdownCh)
}))
var terminateOnce sync.Once
terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) }
var claimed atomic.Bool
newIPNFn := js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
if len(args) != 1 {
return nil, errors.New("newIPN takes exactly one argument")
}
// One IPN per runtime: shutdown exits the shared Go runtime, so a
// second IPN here would be torn down by the first one's shutdown.
if !claimed.CompareAndSwap(false, true) {
return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead")
}
return newIPN(args[0], terminate)
})
})
// A failed newIPN leaves the runtime blocked below with nothing to shut it
// down, so the loader gets a way to exit it. Leaving that to the loader
// keeps the ordering right: closing shutdownCh here would let main return
// before makePromise had delivered the rejection.
terminateFn := js.FuncOf(func(this js.Value, args []js.Value) any {
terminate()
return nil
})
callback.Invoke(newIPNFn, terminateFn)
// Block until shutdown() is called on the IPN, then let main return so the
// Go runtime (and all its goroutines) can be collected by the JS engine.
<-shutdownCh
}
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
netns.SetEnabled(false)
var store ipn.StateStore
@@ -133,13 +174,13 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
EventBus: sys.Bus.Get(),
})
if err != nil {
log.Fatal(err)
return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", err)
}
sys.Set(eng)
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil {
log.Fatalf("netstack.Create: %v", err)
return nil, fmt.Errorf("netstack.Create: %w", err)
}
sys.Set(ns)
ns.ProcessLocalIPs = true
@@ -176,20 +217,17 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
// initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
driveFS := initDriveForRemote(sys)
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
if err != nil {
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
}
if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err)
return nil, fmt.Errorf("starting netstack: %w", err)
}
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
srv.SetLocalBackend(lb)
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
lb: lb,
ns: ns,
controlURL: controlURL,
@@ -197,7 +235,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
hostname: hostname,
logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry),
shutdownCh: shutdownCh,
terminate: terminate,
}
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
@@ -377,12 +415,11 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
}),
}
wireDriveJS(jsIPN, driveFS, m)
return m
return m, nil
}
type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
ns *netstack.Impl
controlURL string
@@ -393,11 +430,11 @@ type jsIPN struct {
funnelMu sync.Mutex
funnelPorts map[uint16]*funnelListenerEntry
// ln is the safesocket listener created by run(); stored here so shutdown
// can close it and unblock srv.Run.
ln net.Listener
shutdownCh chan struct{} // closed by shutdown() to unblock main()
terminate func() // unblocks main() so the Go runtime can exit
shutdownOnce sync.Once
notifyMu sync.Mutex
notifyCancel context.CancelFunc
notifyDone <-chan struct{}
}
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
@@ -423,13 +460,137 @@ var jsMachineStatus = map[tailcfg.MachineStatus]string{
tailcfg.MachineInvalid: "MachineInvalid",
}
const jsIPNNotifyWatchMask = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyPeerChanges | ipn.NotifyPeerPatches
func notifyRefreshesNetMap(n ipn.Notify) bool {
if n.SelfChange != nil || len(n.PeersChanged) > 0 || len(n.PeersRemoved) > 0 {
return true
}
for _, patch := range n.PeerChangedPatch {
if patch.Online != nil {
return true
}
}
return false
}
func (i *jsIPN) watchNotifications(ctx context.Context, registered, done chan struct{}, notify func(ipn.Notify), refresh func()) {
defer close(done)
var registeredOnce sync.Once
for {
watchAdded := false
i.lb.WatchNotifications(ctx, jsIPNNotifyWatchMask, func() {
watchAdded = true
refresh()
registeredOnce.Do(func() { close(registered) })
}, func(n *ipn.Notify) bool {
if n.ErrMessage != nil {
log.Printf("IPN notification error: %s", *n.ErrMessage)
}
notify(*n)
return true
})
if ctx.Err() != nil {
return
}
if !watchAdded {
log.Printf("IPN notification watcher stopped before registration")
return
}
log.Printf("IPN notification watcher stopped; reconnecting")
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
}
}
}
func (i *jsIPN) refreshNetMap(jsCallbacks js.Value) {
nm := i.lb.NetMapWithPeers()
if nm == nil {
return
}
var selfHave4, selfHave6 bool
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if a.Addr().Is4() {
selfHave4 = true
} else if a.Addr().Is6() {
selfHave6 = true
}
}
selfPeerAPIURL := ""
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if port, ok := i.lb.GetPeerAPIPort(a.Addr()); ok && port != 0 {
selfPeerAPIURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), port))
break
}
}
jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.SelfName(),
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
},
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
},
Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
name := p.Name()
if name == "" {
name = p.Hostinfo().Hostname()
}
addrs := make([]string, p.Addresses().Len())
for idx, ap := range p.Addresses().All() {
addrs[idx] = ap.Addr().String()
}
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: addrs,
MachineKey: p.Machine().String(),
NodeKey: p.Key().String(),
PeerAPIURL: buildPeerAPIURL(p, selfHave4, selfHave6),
Services: userServicesFromView(p.Hostinfo().Services()),
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()),
StableNodeID: string(p.StableID()),
}
}),
LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
}
if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
} else {
log.Printf("Could not generate JSON netmap: %v", err)
}
}
func (i *jsIPN) run(jsCallbacks js.Value) {
notifyState := func(state ipn.State) {
jsCallbacks.Call("notifyState", jsIPNState[state])
}
notifyState(ipn.NoState)
i.lb.SetNotifyCallback(func(n ipn.Notify) {
notify := func(n ipn.Notify) {
// Panics in the notify callback are likely due to be due to bugs in
// this bridging module (as opposed to actual bugs in Tailscale) and
// thus may be recoverable. Let the UI know, and allow the user to
@@ -444,86 +605,8 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
if n.State != nil {
notifyState(*n.State)
}
if n.SelfChange != nil {
// Self changed: rebuild the JS-side NetMap snapshot. Peers
// don't ride on the bus anymore, so fetch them on demand
// from LocalBackend.
nm := i.lb.NetMapWithPeers()
if nm != nil {
// Determine which address families we have, for peer peerAPI URL selection.
var selfHave4, selfHave6 bool
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if a.Addr().Is4() {
selfHave4 = true
} else if a.Addr().Is6() {
selfHave6 = true
}
}
// Self peerAPI URL: own port as reported by LocalBackend.
selfPeerAPIURL := ""
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if port, ok := i.lb.GetPeerAPIPort(a.Addr()); ok && port != 0 {
selfPeerAPIURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), port))
break
}
}
jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.SelfName(),
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
},
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
},
Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
name := p.Name()
if name == "" {
// In practice this should only happen for Hello.
name = p.Hostinfo().Hostname()
}
addrs := make([]string, p.Addresses().Len())
for idx, ap := range p.Addresses().All() {
addrs[idx] = ap.Addr().String()
}
// Peer peerAPI URL from the peer's advertised Services.
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: addrs,
MachineKey: p.Machine().String(),
NodeKey: p.Key().String(),
PeerAPIURL: peerURL,
Services: userServicesFromView(p.Hostinfo().Services()),
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()),
StableNodeID: string(p.StableID()),
}
}),
LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
}
if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
} else {
log.Printf("Could not generate JSON netmap: %v", err)
}
}
if notifyRefreshesNetMap(n) {
i.refreshNetMap(jsCallbacks)
}
if n.Prefs != nil && n.Prefs.Valid() {
jsCallbacks.Call("notifyExitNode", string(n.Prefs.ExitNodeID()))
@@ -571,9 +654,32 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
log.Printf("could not marshal OutgoingFiles: %v", err)
}
}
})
}
refresh := func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic recovered:", r)
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
}
}()
i.refreshNetMap(jsCallbacks)
}
registered := make(chan struct{})
notifyCtx, notifyCancel := context.WithCancel(context.Background())
notifyDone := make(chan struct{})
i.notifyMu.Lock()
i.notifyCancel = notifyCancel
i.notifyDone = notifyDone
i.notifyMu.Unlock()
go i.watchNotifications(notifyCtx, registered, notifyDone, notify, refresh)
go func() {
select {
case <-registered:
case <-notifyDone:
return
}
err := i.lb.Start(ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: i.controlURL,
@@ -588,18 +694,6 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
}
}()
ln, err := safesocket.Listen("")
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
i.ln = ln
go func() {
err := i.srv.Run(context.Background(), ln)
if err != nil && !errors.Is(err, net.ErrClosed) {
log.Fatalf("ipnserver.Run exited: %v", err)
}
}()
}
func (i *jsIPN) login() {
@@ -617,16 +711,26 @@ func (i *jsIPN) logout() {
}()
}
// shutdown tears down the backend and lets main return, which exits the whole
// Go runtime. Callers should await the runtime's exit rather than the promise
// returned here: terminating races with makePromise resolving, so the promise
// may never settle.
func (i *jsIPN) shutdown() js.Value {
return makePromise(func() (any, error) {
i.shutdownOnce.Do(func() {
i.notifyMu.Lock()
notifyCancel, notifyDone := i.notifyCancel, i.notifyDone
i.notifyMu.Unlock()
if notifyCancel != nil {
notifyCancel()
}
if notifyDone != nil {
<-notifyDone
}
if i.lb != nil {
i.lb.Shutdown()
}
if i.ln != nil {
i.ln.Close()
}
close(i.shutdownCh)
i.terminate()
})
return nil, nil
})
@@ -1568,19 +1672,83 @@ func makePromise(f func() (any, error)) js.Value {
resolve := args[0]
reject := args[1]
go func() {
defer func() {
if recovered := recover(); recovered != nil {
rejectJSError(reject, recoveredJSError(recovered))
}
}()
if res, err := f(); err == nil {
resolve.Invoke(res)
} else {
reject.Invoke(err.Error())
rejectJSError(reject, err)
}
}()
return nil
})
defer handler.Release()
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
func callJSFunction(fn js.Value, args ...any) (ret js.Value, err error) {
if fn.Type() != js.TypeFunction {
return js.Undefined(), fmt.Errorf("expected JavaScript function, got %s", fn.Type())
}
defer func() {
if recovered := recover(); recovered != nil {
err = recoveredJSError(recovered)
}
}()
return fn.Invoke(args...), nil
}
func callJSMethod(receiver js.Value, method string, args ...any) (ret js.Value, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = recoveredJSError(recovered)
}
}()
if receiver.Type() != js.TypeObject && receiver.Type() != js.TypeFunction {
return js.Undefined(), fmt.Errorf("cannot call JavaScript method %q on %s", method, receiver.Type())
}
if receiver.Get(method).Type() != js.TypeFunction {
return js.Undefined(), fmt.Errorf("JavaScript property %q is not a function", method)
}
return receiver.Call(method, args...), nil
}
func hasJSFunctionProperty(receiver js.Value, property string) (ok bool, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = recoveredJSError(recovered)
}
}()
if receiver.Type() != js.TypeObject && receiver.Type() != js.TypeFunction {
return false, nil
}
return receiver.Get(property).Type() == js.TypeFunction, nil
}
func recoveredJSError(recovered any) error {
switch recovered := recovered.(type) {
case js.Error:
return recovered
case *js.ValueError:
return recovered
default:
panic(recovered)
}
}
func rejectJSError(reject js.Value, err error) {
if jsErr, ok := err.(js.Error); ok {
reject.Invoke(jsErr.Value)
return
}
reject.Invoke(err.Error())
}
const logPolicyStateKey = "log-policy"
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {
+14
View File
@@ -0,0 +1,14 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"tailscale.com/cmd/tsconnect/wasmbuild"
)
func main() {
fmt.Print(wasmbuild.Tags())
}
+1 -8
View File
@@ -5,22 +5,15 @@ package safesocket
import (
"context"
"fmt"
"net"
"sync/atomic"
"github.com/akutz/memconn"
)
const memName = "Tailscale-IPN"
// memSeq ensures each IPN instance in the same WASM process gets a distinct
// memconn address, so concurrent instances do not conflict on the registry.
var memSeq atomic.Int64
func listen(path string) (net.Listener, error) {
name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1))
return memconn.Listen("memu", name)
return memconn.Listen("memu", memName)
}
func connect(ctx context.Context, _ string) (net.Conn, error) {
BIN
View File
Binary file not shown.