From 3c63a954469e0e34af67de656a31eba9d07a7c5b Mon Sep 17 00:00:00 2001 From: Codinget Date: Sun, 30 Aug 2026 01:06:16 +0000 Subject: [PATCH] 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 })