Two lifecycle gaps in startIPN, both found in review. The runtime was only raced until the readiness callback fired. Building the backend happens after that, in a Go goroutine, and if the runtime dies partway through, that goroutine dies with it and the promise it would have settled never settles. startIPN hung forever instead of rejecting. Race the factory too. createIPN also handed callers the bridge's own shutdown, whose promise races the runtime tearing itself down and may never settle, and which the public type did not declare at all. Replace it in place with one that resolves when the runtime has actually exited, and declare it. Replacing rather than wrapping keeps the object the bridge built, instead of a copy that only looks like it. That also gives the exit handler the distinction it was missing: only an exit the caller did not ask for is a panic now, so a deliberate shutdown is no longer reported as one. Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
88 lines
3.2 KiB
TypeScript
88 lines
3.2 KiB
TypeScript
// 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<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
|
|
|
|
// 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<void> = 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<never> = 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<IPN>
|
|
type Terminate = () => void
|
|
|
|
/** Must match initCallbackEnv in wasm_js.go. */
|
|
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
|