wasm: one IPN per runtime with an explicit readiness handshake #20

Merged
codinget merged 7 commits from feat/single-ipn-lifecycle into webnet 2026-08-30 20:53:30 +02:00
6 changed files with 177 additions and 68 deletions
+16 -13
View File
@@ -5,6 +5,7 @@ import "../wasm_exec"
import wasmUrl from "./main.wasm" import wasmUrl from "./main.wasm"
import { sessionStateStorage } from "../lib/js-state-store" import { sessionStateStorage } from "../lib/js-state-store"
import { renderApp } from "./app" import { renderApp } from "./app"
import { startIPN } from "../lib/start-ipn"
async function main() { async function main() {
const app = await renderApp() const app = await renderApp()
@@ -13,23 +14,25 @@ async function main() {
fetch(`./dist/${wasmUrl}`), fetch(`./dist/${wasmUrl}`),
go.importObject 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 params = new URLSearchParams(window.location.search)
const authKey = params.get("authkey") ?? undefined const authKey = params.get("authkey") ?? undefined
const ipn = newIPN({ // The Go process should never exit, if it does then it's an unhandled panic.
// Persist IPN state in sessionStorage in development, so that we don't need const ipn = await startIPN(
// to re-authorize every time we reload the page. go,
stateStorage: DEBUG ? sessionStateStorage : undefined, wasmInstance.instance,
// authKey allows for an auth key to be {
// specified as a url param which automatically // Persist IPN state in sessionStorage in development, so that we don't
// authorizes the client for use. // need to re-authorize every time we reload the page.
authKey: DEBUG ? authKey : undefined, 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) 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" /> /// <reference path="../types/wasm_js.d.ts" />
import "../wasm_exec" import "../wasm_exec"
import { startIPN } from "../lib/start-ipn"
import wasmURL from "./main.wasm" import wasmURL from "./main.wasm"
/** /**
@@ -30,11 +31,7 @@ export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
go.importObject go.importObject
) )
// The Go process should never exit, if it does then it's an unhandled panic. // The Go process should never exit, if it does then it's an unhandled panic.
go.run(wasmInstance.instance).then(() => return startIPN(go, wasmInstance.instance, config, config.panicHandler)
config.panicHandler("Unexpected shutdown")
)
return newIPN(config)
} }
export { runSSHSession } from "../lib/ssh" export { runSSHSession } from "../lib/ssh"
+8 -2
View File
@@ -7,12 +7,18 @@
*/ */
declare global { declare global {
function newIPN(config: IPNConfig): IPN
interface IPN { interface IPN {
run(callbacks: IPNCallbacks): void run(callbacks: IPNCallbacks): void
login(): void login(): void
logout(): 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( ssh(
host: string, host: string,
username: string, username: string,
+63 -40
View File
@@ -24,9 +24,11 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"syscall/js" "syscall/js"
"time" "time"
@@ -42,7 +44,6 @@ import (
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnauth" "tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/localapi" "tailscale.com/ipn/localapi"
"tailscale.com/ipn/store/mem" "tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy" "tailscale.com/logpolicy"
@@ -51,7 +52,6 @@ import (
"tailscale.com/net/netns" "tailscale.com/net/netns"
"tailscale.com/net/tsaddr" "tailscale.com/net/tsaddr"
"tailscale.com/net/tsdial" "tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tsd" "tailscale.com/tsd"
"tailscale.com/types/logid" "tailscale.com/types/logid"
@@ -64,21 +64,62 @@ import (
// ControlURL defines the URL to be used for connection to Control. // ControlURL defines the URL to be used for connection to Control.
var ControlURL = ipn.DefaultControlURL 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() { 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{}) shutdownCh := make(chan struct{})
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any { var terminateOnce sync.Once
if len(args) != 1 { terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) }
log.Fatal("Usage: newIPN(config)")
return nil var claimed atomic.Bool
} newIPNFn := js.FuncOf(func(this js.Value, args []js.Value) any {
return newIPN(args[0], shutdownCh) 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 // 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. // Go runtime (and all its goroutines) can be collected by the JS engine.
<-shutdownCh <-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) netns.SetEnabled(false)
var store ipn.StateStore var store ipn.StateStore
@@ -133,13 +174,13 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
EventBus: sys.Bus.Get(), EventBus: sys.Bus.Get(),
}) })
if err != nil { if err != nil {
log.Fatal(err) return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", err)
} }
sys.Set(eng) sys.Set(eng)
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil { if err != nil {
log.Fatalf("netstack.Create: %v", err) return nil, fmt.Errorf("netstack.Create: %w", err)
} }
sys.Set(ns) sys.Set(ns)
ns.ProcessLocalIPs = true 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). // initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
driveFS := initDriveForRemote(sys) driveFS := initDriveForRemote(sys)
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral) lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
if err != nil { if err != nil {
log.Fatalf("ipnlocal.NewLocalBackend: %v", err) return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
} }
if err := ns.Start(lb); err != nil { 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")) wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
srv.SetLocalBackend(lb)
jsIPN := &jsIPN{ jsIPN := &jsIPN{
dialer: dialer, dialer: dialer,
srv: srv,
lb: lb, lb: lb,
ns: ns, ns: ns,
controlURL: controlURL, controlURL: controlURL,
@@ -197,7 +235,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
hostname: hostname, hostname: hostname,
logID: logid, logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry), funnelPorts: make(map[uint16]*funnelListenerEntry),
shutdownCh: shutdownCh, terminate: terminate,
} }
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP) lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
@@ -377,12 +415,11 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
}), }),
} }
wireDriveJS(jsIPN, driveFS, m) wireDriveJS(jsIPN, driveFS, m)
return m return m, nil
} }
type jsIPN struct { type jsIPN struct {
dialer *tsdial.Dialer dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend lb *ipnlocal.LocalBackend
ns *netstack.Impl ns *netstack.Impl
controlURL string controlURL string
@@ -393,10 +430,7 @@ type jsIPN struct {
funnelMu sync.Mutex funnelMu sync.Mutex
funnelPorts map[uint16]*funnelListenerEntry funnelPorts map[uint16]*funnelListenerEntry
// ln is the safesocket listener created by run(); stored here so shutdown terminate func() // unblocks main() so the Go runtime can exit
// can close it and unblock srv.Run.
ln net.Listener
shutdownCh chan struct{} // closed by shutdown() to unblock main()
shutdownOnce sync.Once shutdownOnce sync.Once
} }
@@ -588,18 +622,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() { func (i *jsIPN) login() {
@@ -617,16 +639,17 @@ 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 { func (i *jsIPN) shutdown() js.Value {
return makePromise(func() (any, error) { return makePromise(func() (any, error) {
i.shutdownOnce.Do(func() { i.shutdownOnce.Do(func() {
if i.lb != nil { if i.lb != nil {
i.lb.Shutdown() i.lb.Shutdown()
} }
if i.ln != nil { i.terminate()
i.ln.Close()
}
close(i.shutdownCh)
}) })
return nil, nil return nil, nil
}) })
+1 -8
View File
@@ -5,22 +5,15 @@ package safesocket
import ( import (
"context" "context"
"fmt"
"net" "net"
"sync/atomic"
"github.com/akutz/memconn" "github.com/akutz/memconn"
) )
const memName = "Tailscale-IPN" 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) { func listen(path string) (net.Listener, error) {
name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1)) return memconn.Listen("memu", memName)
return memconn.Listen("memu", name)
} }
func connect(ctx context.Context, _ string) (net.Conn, error) { func connect(ctx context.Context, _ string) (net.Conn, error) {