6 Commits
Author SHA1 Message Date
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
6 changed files with 148 additions and 68 deletions
+11 -8
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,
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, stateStorage: DEBUG ? sessionStateStorage : undefined,
// authKey allows for an auth key to be // authKey allows for an auth key to be
// specified as a url param which automatically // specified as a url param which automatically
// authorizes the client for use. // authorizes the client for use.
authKey: DEBUG ? authKey : undefined, authKey: DEBUG ? authKey : undefined,
}) },
(reason) => app.handleGoPanic(reason)
)
app.runWithIPN(ipn) app.runWithIPN(ipn)
} }
+66
View File
@@ -0,0 +1,66 @@
// 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<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
// 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<never> = 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"
)
})
const [newIPN, terminate] = await Promise.race([ready, exited])
try {
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.
terminate()
await exited.catch(() => {})
throw err
}
}
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"
-2
View File
@@ -7,8 +7,6 @@
*/ */
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
+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() {
shutdownCh := make(chan struct{}) name := os.Getenv(initCallbackEnv)
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any { if name == "" {
if len(args) != 1 { log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv)
log.Fatal("Usage: newIPN(config)")
return nil
} }
return newIPN(args[0], shutdownCh) callback := js.Global().Get(name)
})) if callback.Type() != js.TypeFunction {
log.Fatalf("globalThis[%q] is not a function", name)
}
shutdownCh := make(chan struct{})
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 // 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) {