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 })