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
Showing only changes of commit 24ee15e524 - Show all commits
+41 -12
View File
@@ -24,9 +24,11 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall/js"
"time"
@@ -64,21 +66,48 @@ 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 claimed atomic.Bool
callback.Invoke(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], shutdownCh)
})
}))
// 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, shutdownCh chan struct{}) (map[string]any, error) {
netns.SetEnabled(false)
var store ipn.StateStore
@@ -133,13 +162,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
@@ -179,10 +208,10 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
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)
@@ -377,7 +406,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
}),
}
wireDriveJS(jsIPN, driveFS, m)
return m
return m, nil
}
type jsIPN struct {