From 24ee15e524fedaa9472c47614167156609f6291a Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 30 Aug 2026 00:57:59 +0000 Subject: [PATCH 1/7] 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 { -- 2.54.0 From 00d16d6ae259348b2223c9885a7739820284fc0b Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 30 Aug 2026 00:59:47 +0000 Subject: [PATCH 2/7] 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 --- cmd/tsconnect/wasm/wasm_js.go | 24 ------------------------ safesocket/safesocket_js.go | 9 +-------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 1778e597e..67208be18 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -44,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" @@ -53,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" @@ -205,7 +203,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) (map[string]any, error) // 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 { return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err) @@ -214,11 +211,9 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) (map[string]any, error) 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, @@ -411,7 +406,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) (map[string]any, error) type jsIPN struct { dialer *tsdial.Dialer - srv *ipnserver.Server lb *ipnlocal.LocalBackend ns *netstack.Impl controlURL string @@ -422,9 +416,6 @@ 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() shutdownOnce sync.Once } @@ -617,18 +608,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() { @@ -652,9 +631,6 @@ func (i *jsIPN) shutdown() js.Value { if i.lb != nil { i.lb.Shutdown() } - if i.ln != nil { - i.ln.Close() - } close(i.shutdownCh) }) return nil, nil diff --git a/safesocket/safesocket_js.go b/safesocket/safesocket_js.go index 0807885a8..746fea511 100644 --- a/safesocket/safesocket_js.go +++ b/safesocket/safesocket_js.go @@ -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) { -- 2.54.0 From c98a03dfa57ee235372219ef295fe1f34ee4f58c Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 30 Aug 2026 01:00:32 +0000 Subject: [PATCH 3/7] 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 --- cmd/tsconnect/wasm/wasm_js.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 67208be18..4d6a7c057 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -625,6 +625,11 @@ 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: closing shutdownCh races with makePromise resolving, so the +// promise may never settle. There is exactly one IPN per runtime, so the once +// and the channel belong to the same instance and a second call is a no-op. func (i *jsIPN) shutdown() js.Value { return makePromise(func() (any, error) { i.shutdownOnce.Do(func() { -- 2.54.0 From 7ca658b028dce2d684f9c6b588225e47c7777693 Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 30 Aug 2026 01:01:41 +0000 Subject: [PATCH 4/7] 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 --- cmd/tsconnect/src/app/index.ts | 29 ++++++++++--------- cmd/tsconnect/src/lib/start-ipn.ts | 43 ++++++++++++++++++++++++++++ cmd/tsconnect/src/pkg/pkg.ts | 7 ++--- cmd/tsconnect/src/types/wasm_js.d.ts | 2 -- 4 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 cmd/tsconnect/src/lib/start-ipn.ts diff --git a/cmd/tsconnect/src/app/index.ts b/cmd/tsconnect/src/app/index.ts index bdbcaf3e5..aec47dbea 100644 --- a/cmd/tsconnect/src/app/index.ts +++ b/cmd/tsconnect/src/app/index.ts @@ -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) } diff --git a/cmd/tsconnect/src/lib/start-ipn.ts b/cmd/tsconnect/src/lib/start-ipn.ts new file mode 100644 index 000000000..a9b7666ba --- /dev/null +++ b/cmd/tsconnect/src/lib/start-ipn.ts @@ -0,0 +1,43 @@ +// 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. + */ +export async function startIPN( + go: Go, + instance: WebAssembly.Instance, + config: IPNConfig, + onExit: (reason: string) => void +): Promise { + const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}` + const globals = globalThis as Record + + const ready = new Promise<(config: IPNConfig) => Promise>( + (resolve) => { + globals[name] = (newIPN: (config: IPNConfig) => Promise) => { + delete globals[name] + resolve(newIPN) + } + } + ) + + go.env[INIT_CALLBACK_ENV] = name + const exited = go.run(instance).then(() => { + delete globals[name] + onExit("Unexpected shutdown") + throw new Error("Go runtime exited before the IPN was ready") + }) + + const newIPN = await Promise.race([ready, exited]) + return newIPN(config) +} + +/** Must match initCallbackEnv in wasm_js.go. */ +const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK" diff --git a/cmd/tsconnect/src/pkg/pkg.ts b/cmd/tsconnect/src/pkg/pkg.ts index a44c57150..b28bb2bc3 100644 --- a/cmd/tsconnect/src/pkg/pkg.ts +++ b/cmd/tsconnect/src/pkg/pkg.ts @@ -7,6 +7,7 @@ /// 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 { 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" diff --git a/cmd/tsconnect/src/types/wasm_js.d.ts b/cmd/tsconnect/src/types/wasm_js.d.ts index 938ec759c..629cf2869 100644 --- a/cmd/tsconnect/src/types/wasm_js.d.ts +++ b/cmd/tsconnect/src/types/wasm_js.d.ts @@ -7,8 +7,6 @@ */ declare global { - function newIPN(config: IPNConfig): IPN - interface IPN { run(callbacks: IPNCallbacks): void login(): void -- 2.54.0 From 3c63a954469e0e34af67de656a31eba9d07a7c5b Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 30 Aug 2026 01:06:16 +0000 Subject: [PATCH 5/7] 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 --- cmd/tsconnect/src/lib/start-ipn.ts | 27 ++++++++++++++++-------- cmd/tsconnect/wasm/wasm_js.go | 33 +++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/cmd/tsconnect/src/lib/start-ipn.ts b/cmd/tsconnect/src/lib/start-ipn.ts index a9b7666ba..9b364548e 100644 --- a/cmd/tsconnect/src/lib/start-ipn.ts +++ b/cmd/tsconnect/src/lib/start-ipn.ts @@ -19,14 +19,12 @@ export async function startIPN( const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}` const globals = globalThis as Record - const ready = new Promise<(config: IPNConfig) => Promise>( - (resolve) => { - globals[name] = (newIPN: (config: IPNConfig) => Promise) => { - delete globals[name] - resolve(newIPN) - } + 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 const exited = go.run(instance).then(() => { @@ -35,9 +33,20 @@ export async function startIPN( throw new Error("Go runtime exited before the IPN was ready") }) - const newIPN = await Promise.race([ready, exited]) - return newIPN(config) + const [newIPN, terminate] = await Promise.race([ready, exited]) + try { + return await newIPN(config) + } 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. + terminate() + await exited.catch(() => {}) + throw err + } } +type NewIPN = (config: IPNConfig) => Promise +type Terminate = () => void + /** Must match initCallbackEnv in wasm_js.go. */ const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK" diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 4d6a7c057..27729797c 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -85,8 +85,11 @@ func main() { } shutdownCh := make(chan struct{}) + var terminateOnce sync.Once + terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) } + var claimed atomic.Bool - callback.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any { + 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") @@ -96,16 +99,27 @@ func main() { 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) + 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, error) { +func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) { netns.SetEnabled(false) var store ipn.StateStore @@ -221,7 +235,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) (map[string]any, error) hostname: hostname, logID: logid, funnelPorts: make(map[uint16]*funnelListenerEntry), - shutdownCh: shutdownCh, + terminate: terminate, } lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP) @@ -416,7 +430,7 @@ type jsIPN struct { funnelMu sync.Mutex funnelPorts map[uint16]*funnelListenerEntry - shutdownCh chan struct{} // closed by shutdown() to unblock main() + terminate func() // unblocks main() so the Go runtime can exit shutdownOnce sync.Once } @@ -627,16 +641,15 @@ 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: closing shutdownCh races with makePromise resolving, so the -// promise may never settle. There is exactly one IPN per runtime, so the once -// and the channel belong to the same instance and a second call is a no-op. +// 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() { if i.lb != nil { i.lb.Shutdown() } - close(i.shutdownCh) + i.terminate() }) return nil, nil }) -- 2.54.0 From 738fea52f834e14896456f7822139b62f237aab2 Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 30 Aug 2026 01:27:21 +0000 Subject: [PATCH 6/7] 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 --- cmd/tsconnect/src/lib/start-ipn.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/cmd/tsconnect/src/lib/start-ipn.ts b/cmd/tsconnect/src/lib/start-ipn.ts index 9b364548e..378297f5e 100644 --- a/cmd/tsconnect/src/lib/start-ipn.ts +++ b/cmd/tsconnect/src/lib/start-ipn.ts @@ -27,15 +27,29 @@ export async function startIPN( }) go.env[INIT_CALLBACK_ENV] = name - const exited = go.run(instance).then(() => { + + // An exit before the IPN reaches the caller is a startup failure, and throwing + // hands it back as a rejection. Afterwards the caller holds the only shutdown + // path, so an exit is either that shutdown or a panic; report it either way, + // because the IPN is dead in both cases. + let handedOver = false + const exited: Promise = go.run(instance).then(() => { delete globals[name] - onExit("Unexpected shutdown") - throw new Error("Go runtime exited before the IPN was ready") + if (handedOver) onExit("Go runtime exited") + // Always reject: before the handover this is what fails the race below, + // and after it the race has settled, so nothing observes the rejection. + throw new Error( + handedOver + ? "Go runtime exited" + : "Go runtime exited before the IPN was ready" + ) }) const [newIPN, terminate] = await Promise.race([ready, exited]) try { - return await newIPN(config) + const ipn = await newIPN(config) + handedOver = true + return ipn } 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. -- 2.54.0 From 0a6e85834a217e741a4bd0af8c58b693bcf3522f Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 30 Aug 2026 15:30:28 +0000 Subject: [PATCH 7/7] 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 --- cmd/tsconnect/src/lib/start-ipn.ts | 59 +++++++++++++++++++--------- cmd/tsconnect/src/types/wasm_js.d.ts | 8 ++++ 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/cmd/tsconnect/src/lib/start-ipn.ts b/cmd/tsconnect/src/lib/start-ipn.ts index 378297f5e..efa474ec1 100644 --- a/cmd/tsconnect/src/lib/start-ipn.ts +++ b/cmd/tsconnect/src/lib/start-ipn.ts @@ -9,6 +9,10 @@ * 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, @@ -28,35 +32,52 @@ export async function startIPN( go.env[INIT_CALLBACK_ENV] = name - // An exit before the IPN reaches the caller is a startup failure, and throwing - // hands it back as a rejection. Afterwards the caller holds the only shutdown - // path, so an exit is either that shutdown or a panic; report it either way, - // because the IPN is dead in both cases. - let handedOver = false - const exited: Promise = go.run(instance).then(() => { + // 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 = go.run(instance).then(() => { delete globals[name] - if (handedOver) onExit("Go runtime exited") - // Always reject: before the handover this is what fails the race below, - // and after it the race has settled, so nothing observes the rejection. - throw new Error( - handedOver - ? "Go runtime exited" - : "Go runtime exited before the IPN was ready" - ) + 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 = exited.then(() => { + throw new Error("Go runtime exited before the IPN was ready") }) - const [newIPN, terminate] = await Promise.race([ready, exited]) + const [newIPN, terminate] = await Promise.race([ready, failed]) + let ipn: IPN try { - const ipn = await newIPN(config) - handedOver = true - return ipn + // 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.catch(() => {}) + 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 diff --git a/cmd/tsconnect/src/types/wasm_js.d.ts b/cmd/tsconnect/src/types/wasm_js.d.ts index 629cf2869..dd08be1be 100644 --- a/cmd/tsconnect/src/types/wasm_js.d.ts +++ b/cmd/tsconnect/src/types/wasm_js.d.ts @@ -11,6 +11,14 @@ declare global { 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 ssh( host: string, username: string, -- 2.54.0