// 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. * * 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, 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<[NewIPN, Terminate]>((resolve) => { globals[name] = (newIPN: NewIPN, terminate: Terminate) => { delete globals[name] resolve([newIPN, terminate]) } }) go.env[INIT_CALLBACK_ENV] = name // 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 (!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, failed]) let ipn: IPN try { // 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 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 type Terminate = () => void /** Must match initCallbackEnv in wasm_js.go. */ const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"