From 24ee15e524fedaa9472c47614167156609f6291a Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 30 Aug 2026 00:57:59 +0000 Subject: [PATCH] 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 --- cmd/tsconnect/wasm/wasm_js.go | 53 +++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index bc5ed3e80..1778e597e 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -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 {