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,